Compare commits
13 commits
134e308d50
...
926f616d89
| Author | SHA1 | Date | |
|---|---|---|---|
| 926f616d89 | |||
| a3ff3a0d5f | |||
| 0e5de2aee1 | |||
| 36efa5ef5d | |||
| 0ec0dfc67c | |||
| 88801d2595 | |||
| 7def6f8ab7 | |||
| 8b10d87e76 | |||
| 5be8ffd4ab | |||
| b758279bc3 | |||
| 94e2be6fd7 | |||
| 6bacb718e7 | |||
| 88e493e83b |
48 changed files with 4686 additions and 296 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
/target
|
||||
.env
|
||||
*.snap.new
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ repos:
|
|||
hooks:
|
||||
- id: cargo-fmt
|
||||
- id: cargo-clippy
|
||||
args: ["--all", "--features=rss", "--", "-D", "warnings"]
|
||||
args: ["--all", "--tests", "--", "-D", "warnings"]
|
||||
|
|
|
|||
48
Cargo.lock
generated
48
Cargo.lock
generated
|
|
@ -748,6 +748,15 @@ dependencies = [
|
|||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty-collections"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d0cc2d109e55588331f7b7d07f2406bdf07be14c52bc92af1b711cb2b311d94"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.4"
|
||||
|
|
@ -821,6 +830,14 @@ version = "1.18.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
|
||||
|
||||
[[package]]
|
||||
name = "otvec"
|
||||
version = "0.1.0"
|
||||
source = "git+https://code.thetadev.de/ThetaDev/otvec.git#3c3016e961dd635202b6282f5b1de51ea32e594e"
|
||||
dependencies = [
|
||||
"range-ext",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.1"
|
||||
|
|
@ -1028,6 +1045,12 @@ dependencies = [
|
|||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-ext"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "929e16e56d606ca0646a500a118252cbcdb5fd6c635ab718be354cd311012763"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.3.5"
|
||||
|
|
@ -1187,6 +1210,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_plain"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.5"
|
||||
|
|
@ -1225,6 +1257,12 @@ version = "2.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54ac45299ccbd390721be55b412d41931911f654fa99e2cb8bfb57184b2061fe"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
|
|
@ -1340,6 +1378,7 @@ dependencies = [
|
|||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
|
|
@ -1445,6 +1484,7 @@ dependencies = [
|
|||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
|
|
@ -1485,6 +1525,7 @@ dependencies = [
|
|||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
|
|
@ -1509,6 +1550,7 @@ dependencies = [
|
|||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1637,15 +1679,20 @@ version = "0.0.1"
|
|||
dependencies = [
|
||||
"futures",
|
||||
"insta",
|
||||
"nonempty-collections",
|
||||
"once_cell",
|
||||
"otvec",
|
||||
"path_macro",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_plain",
|
||||
"siphasher",
|
||||
"sqlx",
|
||||
"sqlx-database-tester",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1789,6 +1836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
12
Cargo.toml
12
Cargo.toml
|
|
@ -6,11 +6,6 @@ license = "AGPL-3.0"
|
|||
description = "FOSS music streaming service"
|
||||
repository = "https://github.com/TirayaMusic/Tiraya"
|
||||
|
||||
# This profile significantly speeds up build time. If debug info is needed you can comment the line
|
||||
# out temporarily, but make sure to leave this in the main branch.
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
|
@ -31,6 +26,7 @@ reqwest = { version = "0.11.11", default-features = false, features = [
|
|||
] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.82"
|
||||
serde_plain = "1.0.1"
|
||||
time = { version = "0.3.15", features = [
|
||||
"macros",
|
||||
"serde",
|
||||
|
|
@ -45,8 +41,14 @@ sqlx = { version = "0.7.0", default-features = false, features = [
|
|||
"macros",
|
||||
"time",
|
||||
"json",
|
||||
"uuid",
|
||||
] }
|
||||
uuid = { version = "1.4.0", features = ["v4", "serde"] }
|
||||
siphasher = "1.0.0"
|
||||
nonempty-collections = { version = "0.1.3", features = ["serde"] }
|
||||
otvec = { git = "https://code.thetadev.de/ThetaDev/otvec.git" }
|
||||
|
||||
# Dev dependencies
|
||||
sqlx-database-tester = { path = "crates/sqlx-database-tester" }
|
||||
rstest = { version = "0.18.2", default-features = false }
|
||||
insta = { version = "1.30.0", features = ["ron", "redactions"] }
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@ license.workspace = true
|
|||
description.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_plain.workspace = true
|
||||
sqlx.workspace = true
|
||||
time.workspace = true
|
||||
thiserror.workspace = true
|
||||
futures.workspace = true
|
||||
uuid.workspace = true
|
||||
siphasher.workspace = true
|
||||
nonempty-collections.workspace = true
|
||||
otvec.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
|
|
@ -21,3 +21,5 @@ DROP TYPE IF EXISTS album_type;
|
|||
DROP TYPE IF EXISTS date_precision;
|
||||
DROP TYPE IF EXISTS playlist_type;
|
||||
DROP TYPE IF EXISTS playlist_img_type;
|
||||
|
||||
DROP FUNCTION IF EXISTS set_updated_at;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
-- PostgreSQL version: 15.0
|
||||
|
||||
CREATE TYPE music_service AS
|
||||
ENUM ('youtube','spotify','tiraya','musixmatch');
|
||||
ENUM ('yt','ty','sp','mx');
|
||||
ALTER TYPE music_service OWNER TO postgres;
|
||||
COMMENT ON TYPE music_service IS E'Music services providing data for Tiraya';
|
||||
|
||||
|
|
@ -152,6 +152,12 @@ USING btree
|
|||
service
|
||||
);
|
||||
|
||||
CREATE INDEX album_release_date ON albums
|
||||
USING btree
|
||||
(
|
||||
release_date
|
||||
);
|
||||
|
||||
CREATE INDEX artist_last_sync_at ON artists
|
||||
USING btree
|
||||
(
|
||||
|
|
@ -163,7 +169,7 @@ ENUM ('local','remote','favorites','artist_tracks');
|
|||
ALTER TYPE playlist_type OWNER TO postgres;
|
||||
|
||||
CREATE TYPE playlist_img_type AS
|
||||
ENUM ('single','mosaic','custom');
|
||||
ENUM ('album','mosaic','custom');
|
||||
ALTER TYPE playlist_img_type OWNER TO postgres;
|
||||
COMMENT ON TYPE playlist_img_type IS E'Playlist image type';
|
||||
|
||||
|
|
@ -257,7 +263,7 @@ ALTER TABLE artist_aliases OWNER TO postgres;
|
|||
|
||||
ALTER TABLE artist_aliases ADD CONSTRAINT artist_aliases_artists_fk FOREIGN KEY (artist_id)
|
||||
REFERENCES artists (id) MATCH FULL
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE track_aliases (
|
||||
src_id text NOT NULL,
|
||||
|
|
@ -272,7 +278,7 @@ ALTER TABLE track_aliases OWNER TO postgres;
|
|||
|
||||
ALTER TABLE track_aliases ADD CONSTRAINT track_aliases_tracks_fk FOREIGN KEY (track_id)
|
||||
REFERENCES tracks (id) MATCH FULL
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE UNIQUE INDEX playlist_src ON playlists
|
||||
USING btree
|
||||
|
|
@ -302,3 +308,39 @@ USING btree
|
|||
(
|
||||
track_id
|
||||
);
|
||||
|
||||
CREATE FUNCTION set_updated_at ()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF (NEW IS DISTINCT FROM OLD AND NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at) THEN
|
||||
NEW.updated_at := CURRENT_TIMESTAMP;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER artists_set_updated_at
|
||||
BEFORE UPDATE OF id,src_id,service,name,description,image_url,image_hash,header_image_url,header_image_hash,wikipedia_url
|
||||
ON artists
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE set_updated_at();
|
||||
|
||||
CREATE TRIGGER albums_set_updated_at
|
||||
BEFORE UPDATE OF id,src_id,service,name,release_date,release_date_precision,album_type,ul_artists,by_va,image_url,image_hash
|
||||
ON albums
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE set_updated_at();
|
||||
|
||||
CREATE TRIGGER tracks_set_updated_at
|
||||
BEFORE UPDATE OF id,src_id,service,name,duration,duration_ms,size,loudness,album_pos,album_id,ul_artists,isrc
|
||||
ON tracks
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE set_updated_at();
|
||||
|
||||
CREATE TRIGGER playlists_set_updated_at
|
||||
BEFORE UPDATE OF id,src_id,service,name,description,owner_name,owner_url,playlist_type,image_url,image_hash,image_type
|
||||
ON playlists
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE set_updated_at();
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
DROP TRIGGER IF EXISTS set_updated_at ON artists;
|
||||
DROP TRIGGER IF EXISTS set_updated_at ON albums;
|
||||
DROP TRIGGER IF EXISTS set_updated_at ON tracks;
|
||||
DROP TRIGGER IF EXISTS set_updated_at ON playlists;
|
||||
|
||||
DROP FUNCTION IF EXISTS set_updated_at;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
CREATE FUNCTION set_updated_at ()
|
||||
RETURNS TRIGGER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF (NEW IS DISTINCT FROM OLD AND NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at) THEN
|
||||
NEW.updated_at := CURRENT_TIMESTAMP;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE OF
|
||||
id, src_id, service, name, description, image_url, image_hash, header_image_url, header_image_hash, wikipedia_url
|
||||
ON artists
|
||||
FOR EACH ROW EXECUTE PROCEDURE set_updated_at();
|
||||
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE OF
|
||||
id, src_id, service, name, release_date, release_date_precision, album_type, ul_artists, by_va, image_url, image_hash
|
||||
ON albums
|
||||
FOR EACH ROW EXECUTE PROCEDURE set_updated_at();
|
||||
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE OF
|
||||
id, src_id, service, name, duration, duration_ms, size, loudness, album_pos, album_id, ul_artists, isrc
|
||||
ON tracks
|
||||
FOR EACH ROW EXECUTE PROCEDURE set_updated_at();
|
||||
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE OF
|
||||
id, src_id, service, name, description, owner_name, owner_url, playlist_type, image_url, image_hash, image_type
|
||||
ON playlists
|
||||
FOR EACH ROW EXECUTE PROCEDURE set_updated_at();
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
ALTER TABLE playlist_heads DROP CONSTRAINT IF EXISTS playlists_playlist_changes_fk;
|
||||
ALTER TABLE playlist_heads DROP CONSTRAINT IF EXISTS playlist_changes_playlists_fk;
|
||||
ALTER TABLE playlist_changes DROP CONSTRAINT IF EXISTS playlist_change_parent2_fk;
|
||||
ALTER TABLE playlist_changes DROP CONSTRAINT IF EXISTS playlist_change_parent1_fk;
|
||||
ALTER TABLE playlist_changes DROP CONSTRAINT IF EXISTS playlist_changes_playlists_fk;
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlist_cache_version;
|
||||
|
||||
ALTER TABLE playlists DROP COLUMN IF EXISTS cache_data;
|
||||
ALTER TABLE playlists DROP COLUMN IF EXISTS cache_version;
|
||||
|
||||
DROP TABLE IF EXISTS playlist_heads;
|
||||
DROP TABLE IF EXISTS playlist_changes;
|
||||
61
crates/db/migrations/20230906110102_playlist_changes.up.sql
Normal file
61
crates/db/migrations/20230906110102_playlist_changes.up.sql
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
|
||||
-- pgModeler version: 1.0.5
|
||||
-- Diff date: 2023-09-06 13:37:54
|
||||
-- Source model: tiraya
|
||||
-- Database: tiraya
|
||||
-- PostgreSQL version: 15.0
|
||||
|
||||
-- [ Created objects ] --
|
||||
CREATE TABLE playlist_changes (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
seq integer NOT NULL,
|
||||
operations jsonb NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT now(),
|
||||
playlist_id integer NOT NULL,
|
||||
parent1_id uuid,
|
||||
parent2_id uuid,
|
||||
CONSTRAINT playlist_change_pk PRIMARY KEY (id)
|
||||
);
|
||||
ALTER TABLE playlist_changes OWNER TO postgres;
|
||||
|
||||
CREATE TABLE playlist_heads (
|
||||
playlist_id integer NOT NULL,
|
||||
playlist_change_id uuid NOT NULL,
|
||||
CONSTRAINT playlist_heads_pk PRIMARY KEY (playlist_id,playlist_change_id)
|
||||
);
|
||||
|
||||
ALTER TABLE playlists ADD COLUMN cache_version uuid;
|
||||
ALTER TABLE playlists ADD COLUMN cache_data jsonb;
|
||||
|
||||
-- [ Created foreign keys ] --
|
||||
ALTER TABLE playlist_changes ADD CONSTRAINT playlist_changes_playlists_fk FOREIGN KEY (playlist_id)
|
||||
REFERENCES playlists (id) MATCH FULL
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE playlist_changes ADD CONSTRAINT playlist_change_parent1_fk FOREIGN KEY (parent1_id)
|
||||
REFERENCES playlist_changes (id) MATCH FULL
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE playlist_changes ADD CONSTRAINT playlist_change_parent2_fk FOREIGN KEY (parent2_id)
|
||||
REFERENCES playlist_changes (id) MATCH FULL
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE playlist_heads ADD CONSTRAINT playlist_changes_playlists_fk FOREIGN KEY (playlist_id)
|
||||
REFERENCES playlists (id) MATCH FULL
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE playlist_heads ADD CONSTRAINT playlists_playlist_changes_fk FOREIGN KEY (playlist_change_id)
|
||||
REFERENCES playlist_changes (id) MATCH FULL
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE playlists ADD CONSTRAINT playlist_cache_version_fk FOREIGN KEY (cache_version)
|
||||
REFERENCES playlist_changes (id) MATCH FULL
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
DROP TRIGGER playlists_set_updated_at ON playlists;
|
||||
CREATE TRIGGER playlists_set_updated_at
|
||||
BEFORE UPDATE OF id,src_id,service,name,description,owner_name,owner_url,playlist_type,image_url,image_hash,image_type,
|
||||
cache_version,cache_data
|
||||
ON playlists
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE set_updated_at();
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#![warn(clippy::dbg_macro)]
|
||||
|
||||
pub mod models;
|
||||
mod util;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod testutil;
|
||||
|
||||
pub const DB_CONCURRENCY: usize = 8;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
use futures::{stream, StreamExt, TryStreamExt};
|
||||
use serde::Serialize;
|
||||
use sqlx::{types::Json, FromRow};
|
||||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::{Date, PrimitiveDateTime};
|
||||
|
||||
use crate::util::DB_CONCURRENCY;
|
||||
|
||||
use super::{
|
||||
artist::{ArtistId, ArtistJsonb},
|
||||
map_artists, AlbumType, DatabaseError, DatePrecision, Id, MusicService, SrcId, SrcIdOwned,
|
||||
map_artists, AlbumType, Artist, DatabaseError, DatePrecision, Id, MusicService, OptionalRes,
|
||||
SrcId, SrcIdOwned, TrackSlim, TrackSlimRow,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
|
|
@ -13,20 +17,40 @@ pub struct Album {
|
|||
pub src_id: String,
|
||||
pub service: MusicService,
|
||||
pub name: String,
|
||||
pub artists: Vec<ArtistId>,
|
||||
pub release_date: Option<Date>,
|
||||
pub release_date_precision: Option<DatePrecision>,
|
||||
pub album_type: Option<AlbumType>,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
pub by_va: bool,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub updated_at: PrimitiveDateTime,
|
||||
pub hidden: bool,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct AlbumRow {
|
||||
id: i32,
|
||||
src_id: String,
|
||||
service: MusicService,
|
||||
name: String,
|
||||
release_date: Option<Date>,
|
||||
release_date_precision: Option<DatePrecision>,
|
||||
album_type: Option<AlbumType>,
|
||||
artists: Option<Json<Vec<ArtistJsonb>>>,
|
||||
ul_artists: Option<Vec<String>>,
|
||||
by_va: bool,
|
||||
image_url: Option<String>,
|
||||
image_hash: Option<String>,
|
||||
created_at: PrimitiveDateTime,
|
||||
updated_at: PrimitiveDateTime,
|
||||
hidden: bool,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
/// Data for creating an album
|
||||
#[derive(Debug)]
|
||||
pub struct AlbumNew {
|
||||
pub src_id: String,
|
||||
pub service: MusicService,
|
||||
|
|
@ -42,17 +66,18 @@ pub struct AlbumNew {
|
|||
}
|
||||
|
||||
/// Data for updating an album
|
||||
#[derive(Debug)]
|
||||
#[derive(Default)]
|
||||
pub struct AlbumUpdate {
|
||||
pub name: Option<String>,
|
||||
pub release_date: Option<Option<Date>>,
|
||||
pub release_date_precision: Option<Option<DatePrecision>>,
|
||||
pub album_type: Option<AlbumType>,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
pub by_va: bool,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub hidden: bool,
|
||||
pub by_va: Option<bool>,
|
||||
pub image_url: Option<Option<String>>,
|
||||
pub image_hash: Option<Option<String>>,
|
||||
pub hidden: Option<bool>,
|
||||
pub dirty: Option<bool>,
|
||||
}
|
||||
|
||||
/// Album item (for display)
|
||||
|
|
@ -105,11 +130,17 @@ impl Album {
|
|||
match id {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
r#"select id, src_id, service as "service: _", name, release_date,
|
||||
release_date_precision as "release_date_precision: _", album_type as "album_type: _",
|
||||
ul_artists, by_va, image_url, image_hash, created_at, updated_at, hidden
|
||||
from albums where id=$1"#,
|
||||
AlbumRow,
|
||||
r#"select b.id, b.src_id, b.service as "service: _", b.name, b.release_date,
|
||||
b.release_date_precision as "release_date_precision: _", b.album_type as "album_type: _",
|
||||
b.ul_artists, b.by_va, b.image_url, b.image_hash, b.created_at, b.updated_at, b.hidden, b.dirty,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by arb.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from albums b
|
||||
left join artists_albums arb on arb.album_id=b.id
|
||||
left join artists a on a.id=arb.artist_id
|
||||
where b.id=$1
|
||||
group by b.id"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
|
|
@ -117,11 +148,17 @@ from albums where id=$1"#,
|
|||
}
|
||||
Id::Src(src_id, srv) => {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
r#"select id, src_id, service as "service: _", name, release_date,
|
||||
release_date_precision as "release_date_precision: _", album_type as "album_type: _",
|
||||
ul_artists, by_va, image_url, image_hash, created_at, updated_at, hidden
|
||||
from albums where src_id=$1 and service=$2"#,
|
||||
AlbumRow,
|
||||
r#"select b.id, b.src_id, b.service as "service: _", b.name, b.release_date,
|
||||
b.release_date_precision as "release_date_precision: _", b.album_type as "album_type: _",
|
||||
b.ul_artists, b.by_va, b.image_url, b.image_hash, b.created_at, b.updated_at, b.hidden, b.dirty,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by arb.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from albums b
|
||||
left join artists_albums arb on arb.album_id=b.id
|
||||
left join artists a on a.id=arb.artist_id
|
||||
where b.src_id=$1 and b.service=$2
|
||||
group by b.id"#,
|
||||
src_id,
|
||||
srv as MusicService
|
||||
)
|
||||
|
|
@ -131,6 +168,7 @@ from albums where src_id=$1 and service=$2"#,
|
|||
}
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))
|
||||
.map(Album::from)
|
||||
}
|
||||
|
||||
pub async fn get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError>
|
||||
|
|
@ -160,6 +198,43 @@ from albums 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>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
Id::Db(id) => Ok(id),
|
||||
Id::Src(src_id, srv) => {
|
||||
let srcid = SrcId(src_id, srv);
|
||||
Self::get_id(srcid, exec)
|
||||
.await?
|
||||
.ok_or_else(|| DatabaseError::NotFound(srcid.to_owned_id()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_artists(
|
||||
id: i32,
|
||||
artists: &[Id<'_>],
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(r#"delete from artists_albums where album_id=$1"#, id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for artist_id in artists {
|
||||
let artist_id = Artist::resolve_id(*artist_id, tx).await?;
|
||||
sqlx::query!(
|
||||
r#"insert into artists_albums (album_id, artist_id) values ($1, $2)"#,
|
||||
id,
|
||||
artist_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete<'a, E>(id: i32, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
|
@ -170,13 +245,202 @@ from albums where src_id=$1 and service=$2"#,
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_dirty<'a, E>(id: i32, dirty: bool, exec: E) -> Result<(), DatabaseError>
|
||||
pub async fn tracks<'a, E>(id: i32, exec: E) -> Result<Vec<TrackSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(r#"update albums set dirty=$2 where id=$1"#, id, dirty)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
sqlx::query_as!(
|
||||
TrackSlimRow,
|
||||
r#"select t.src_id, t.service as "service: _", t.name, t.duration, t.album_pos,
|
||||
b.src_id as album_src_id, b.name as album_name, b.service as "album_service: _",
|
||||
b.image_url, b.image_hash, b.release_date, b.album_type as "album_type: _",
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _",
|
||||
t.ul_artists
|
||||
from tracks t
|
||||
left join artists_tracks art on art.track_id = t.id
|
||||
left join artists a on a.id = art.artist_id
|
||||
join albums b on b.id = t.album_id
|
||||
where b.id=$1
|
||||
group by (t.id, b.id)
|
||||
order by t.album_pos"#,
|
||||
id
|
||||
)
|
||||
.fetch(exec)
|
||||
.map_ok(TrackSlim::from)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(DatabaseError::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl AlbumNew {
|
||||
pub async fn insert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into albums (src_id, service, name, release_date, release_date_precision,
|
||||
album_type, ul_artists, by_va, image_url, image_hash, hidden)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as MusicService,
|
||||
self.name,
|
||||
self.release_date,
|
||||
self.release_date_precision as Option<DatePrecision>,
|
||||
self.album_type as AlbumType,
|
||||
self.ul_artists.as_deref(),
|
||||
self.by_va,
|
||||
self.image_url,
|
||||
self.image_hash,
|
||||
self.hidden,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
Ok(res.id)
|
||||
}
|
||||
|
||||
pub async fn upsert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into albums (src_id, service, name, release_date, release_date_precision,
|
||||
album_type, ul_artists, by_va, image_url, image_hash, hidden)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
on conflict (src_id, service) do update set
|
||||
name = excluded.name,
|
||||
release_date = coalesce(excluded.release_date, albums.release_date),
|
||||
release_date_precision = coalesce(excluded.release_date_precision, albums.release_date_precision),
|
||||
album_type = excluded.album_type,
|
||||
ul_artists = coalesce(excluded.ul_artists, albums.ul_artists),
|
||||
by_va = excluded.by_va,
|
||||
image_url = coalesce(excluded.image_url, albums.image_url),
|
||||
image_hash = coalesce(excluded.image_hash, albums.image_hash),
|
||||
hidden = excluded.hidden
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as MusicService,
|
||||
self.name,
|
||||
self.release_date,
|
||||
self.release_date_precision as Option<DatePrecision>,
|
||||
self.album_type as AlbumType,
|
||||
self.ul_artists.as_deref(),
|
||||
self.by_va,
|
||||
self.image_url,
|
||||
self.image_hash,
|
||||
self.hidden,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
Ok(res.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl AlbumUpdate {
|
||||
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let mut query = QueryBuilder::new("update albums set ");
|
||||
let mut n = 0;
|
||||
|
||||
if let Some(name) = &self.name {
|
||||
query.push("name=");
|
||||
query.push_bind(name);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(release_date) = &self.release_date {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("release_date=");
|
||||
query.push_bind(release_date);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(release_date_precision) = &self.release_date_precision {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("release_date_precision=");
|
||||
query.push_bind(release_date_precision);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(album_type) = &self.album_type {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("album_type=");
|
||||
query.push_bind(album_type);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(ul_artists) = &self.ul_artists {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("ul_artists=");
|
||||
query.push_bind(ul_artists);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(by_va) = &self.by_va {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("by_va=");
|
||||
query.push_bind(by_va);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(image_url) = &self.image_url {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("image_url=");
|
||||
query.push_bind(image_url);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(image_hash) = &self.image_hash {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("image_hash=");
|
||||
query.push_bind(image_hash);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(hidden) = &self.hidden {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("hidden=");
|
||||
query.push_bind(hidden);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(dirty) = &self.dirty {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("dirty=");
|
||||
query.push_bind(dirty);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
match id {
|
||||
Id::Db(id) => {
|
||||
query.push("id=");
|
||||
query.push_bind(id);
|
||||
}
|
||||
Id::Src(src_id, srv) => {
|
||||
query.push("src_id=");
|
||||
query.push_bind(src_id);
|
||||
query.push(" and service=");
|
||||
query.push_bind(srv);
|
||||
}
|
||||
}
|
||||
|
||||
query.build().execute(exec).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -192,7 +456,8 @@ impl AlbumSlim {
|
|||
AlbumSlimRow,
|
||||
r#"select b.src_id, b.service as "service: _", b.name, b.release_date,
|
||||
b.album_type as "album_type: _", b.image_url, b.image_hash, b.ul_artists,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name)) filter (where a.src_id is not null) as "artists: _"
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by arb.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from albums b
|
||||
left join artists_albums arb on arb.album_id=b.id
|
||||
left join artists a on a.id=arb.artist_id
|
||||
|
|
@ -208,13 +473,15 @@ group by b.id"#,
|
|||
AlbumSlimRow,
|
||||
r#"select b.src_id, b.service as "service: _", b.name, b.release_date,
|
||||
b.album_type as "album_type: _", b.image_url, b.image_hash, b.ul_artists,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name)) filter (where a.src_id is not null) as "artists: _"
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by arb.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from albums b
|
||||
left join artists_albums arb on arb.album_id=b.id
|
||||
left join artists a on a.id=arb.artist_id
|
||||
where b.src_id=$1 and b.service=$2
|
||||
group by b.id"#,
|
||||
src_id, srv as MusicService
|
||||
src_id,
|
||||
srv as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
|
|
@ -225,6 +492,39 @@ group by b.id"#,
|
|||
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Option<Self>>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
stream::iter(ids)
|
||||
.map(|id| async move { AlbumSlim::get(Id::Db(*id), exec).await.to_optional() })
|
||||
.buffered(DB_CONCURRENCY)
|
||||
.try_collect()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AlbumRow> for Album {
|
||||
fn from(value: AlbumRow) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
src_id: value.src_id,
|
||||
service: value.service,
|
||||
name: value.name,
|
||||
artists: map_artists(value.artists, value.ul_artists),
|
||||
release_date: value.release_date,
|
||||
release_date_precision: value.release_date_precision,
|
||||
album_type: value.album_type,
|
||||
by_va: value.by_va,
|
||||
image_url: value.image_url,
|
||||
image_hash: value.image_hash,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
hidden: value.hidden,
|
||||
dirty: value.dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AlbumSlimRow> for AlbumSlim {
|
||||
|
|
@ -241,3 +541,121 @@ impl From<AlbumSlimRow> for AlbumSlim {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use time::macros::date;
|
||||
|
||||
use crate::testutil::{self, ids};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn crud() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let album = AlbumNew {
|
||||
src_id: "MPREb_nlBWQROfvjo".to_owned(),
|
||||
service: MusicService::YouTube,
|
||||
name: "Märchen enden gut".to_owned(),
|
||||
release_date: Some(date!(2016 - 10 - 21)),
|
||||
release_date_precision: Some(DatePrecision::Day),
|
||||
album_type: AlbumType::Album,
|
||||
ul_artists: Some(vec!["Other artist".to_owned()]),
|
||||
by_va: false,
|
||||
image_url: Some("https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w544-h544-l90-rj".to_owned()),
|
||||
image_hash: Some("aeafabf677bb186378a539a197cc087e2a94c33bc5c3ee41c2e6513fd79442a3".to_owned()),
|
||||
hidden: false,
|
||||
};
|
||||
let album_artists = [ids::ARTIST_LEA, ids::ARTIST_CYRIL];
|
||||
|
||||
// Create
|
||||
let mut c_tx = pool.begin().await.unwrap();
|
||||
let new_id = album.insert(&mut *c_tx).await.unwrap();
|
||||
let id = Id::Db(new_id);
|
||||
Album::set_artists(new_id, &album_artists, &mut c_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
c_tx.commit().await.unwrap();
|
||||
|
||||
// Request
|
||||
let inserted = Album::get(id, &pool).await.unwrap();
|
||||
assert_eq!(inserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", inserted, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = SrcId(&album.src_id, album.service);
|
||||
assert_eq!(
|
||||
Album::get_src_id(new_id, &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_srcid(),
|
||||
srcid
|
||||
);
|
||||
assert_eq!(Album::get_id(srcid, &pool).await.unwrap().unwrap(), new_id);
|
||||
|
||||
// Update
|
||||
let mut u_tx = pool.begin().await.unwrap();
|
||||
let clear = AlbumUpdate {
|
||||
name: Some("empty".to_owned()),
|
||||
release_date: Some(None),
|
||||
release_date_precision: Some(None),
|
||||
album_type: None,
|
||||
ul_artists: Some(vec![]),
|
||||
by_va: None,
|
||||
image_url: Some(None),
|
||||
image_hash: Some(None),
|
||||
hidden: Some(true),
|
||||
dirty: Some(true),
|
||||
};
|
||||
clear.update(id, &mut *u_tx).await.unwrap();
|
||||
Album::set_artists(new_id, &[], &mut u_tx).await.unwrap();
|
||||
u_tx.commit().await.unwrap();
|
||||
|
||||
let got_empty = Album::get(id, &pool).await.unwrap();
|
||||
assert_eq!(got_empty.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_empty", got_empty, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
// Upsert
|
||||
let mut us_tx = pool.begin().await.unwrap();
|
||||
let ups_id = album.upsert(&mut *us_tx).await.unwrap();
|
||||
assert_eq!(ups_id, new_id);
|
||||
Album::set_artists(new_id, &album_artists, &mut us_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
us_tx.commit().await.unwrap();
|
||||
|
||||
let upserted = Album::get(id, &pool).await.unwrap();
|
||||
assert_eq!(upserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", upserted, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
assert!(upserted.updated_at > inserted.updated_at);
|
||||
|
||||
// Delete
|
||||
Album::delete(new_id, &pool).await.unwrap();
|
||||
assert!(Album::get_src_id(new_id, &pool).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn album_tracks() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let tracks_vakuum = Album::tracks(ids::ALBUM_ID_VAKUUM, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("album_tracks_vakuum", tracks_vakuum);
|
||||
|
||||
let tracks_iwwus = Album::tracks(ids::ALBUM_ID_IWWUS, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("album_tracks_iwwus", tracks_iwwus);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::{types::Json, QueryBuilder};
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
use crate::DB_CONCURRENCY;
|
||||
use crate::util::DB_CONCURRENCY;
|
||||
|
||||
use super::{
|
||||
album::AlbumSlimRow, AlbumSlim, DatabaseError, Id, IdOwned, MusicService, SrcId, SrcIdOwned,
|
||||
SyncData,
|
||||
album::AlbumSlimRow, AlbumSlim, DatabaseError, Id, IdOwned, MusicService, OptionalRes,
|
||||
PlaylistSlim, SrcId, SrcIdOwned, SyncData, TrackSlim, TrackSlimRow,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
|
|
@ -34,7 +34,6 @@ pub struct Artist {
|
|||
}
|
||||
|
||||
/// Data for creating an artist
|
||||
#[derive(Debug)]
|
||||
pub struct ArtistNew {
|
||||
pub src_id: String,
|
||||
pub service: MusicService,
|
||||
|
|
@ -53,7 +52,7 @@ pub struct ArtistNew {
|
|||
}
|
||||
|
||||
/// Data for updating an artist
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Default)]
|
||||
pub struct ArtistUpdate {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<Option<String>>,
|
||||
|
|
@ -93,7 +92,7 @@ pub struct ArtistJsonb {
|
|||
/// Artist identifier
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ArtistId {
|
||||
pub src_id: Option<SrcIdOwned>,
|
||||
pub id: Option<SrcIdOwned>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +254,7 @@ where aa.src_id=$1 and aa.service=$2"#,
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_id(
|
||||
pub async fn resolve_id(
|
||||
id: Id<'_>,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<i32, DatabaseError> {
|
||||
|
|
@ -263,9 +262,9 @@ where aa.src_id=$1 and aa.service=$2"#,
|
|||
Id::Db(id) => Ok(id),
|
||||
Id::Src(src_id, srv) => {
|
||||
let srcid = SrcId(src_id, srv);
|
||||
Self::get_id_tx(srcid, tx).await?.ok_or_else(|| {
|
||||
DatabaseError::NotFound(IdOwned::Src(srcid.0.to_string(), srcid.1))
|
||||
})
|
||||
Self::get_id_tx(srcid, tx)
|
||||
.await?
|
||||
.ok_or_else(|| DatabaseError::NotFound(srcid.to_owned_id()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,9 +285,7 @@ where aa.src_id=$1 and aa.service=$2"#,
|
|||
Self::get_id_tx(srcid, tx)
|
||||
.await?
|
||||
.map(|id| (id, srcid.to_owned()))
|
||||
.ok_or_else(|| {
|
||||
DatabaseError::NotFound(IdOwned::Src(srcid.0.to_string(), srcid.1))
|
||||
})
|
||||
.ok_or_else(|| DatabaseError::NotFound(srcid.to_owned_id()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -382,17 +379,6 @@ where a2.artist_id=$1 and a1.artist_id=$2)"#,
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn related_artists<'a, E>(&self, exec: E) -> Result<Vec<ArtistSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
if let Some(related_artists) = &self.related_artists {
|
||||
ArtistSlim::get_vec(related_artists, exec).await
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn albums<'a, E>(id: i32, exec: E) -> Result<Vec<AlbumSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
|
@ -401,12 +387,17 @@ where a2.artist_id=$1 and a1.artist_id=$2)"#,
|
|||
AlbumSlimRow,
|
||||
r#"select b.src_id, b.service as "service: _", b.name, b.release_date,
|
||||
b.album_type as "album_type: _", b.image_url, b.image_hash, b.ul_artists,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name)) filter (where a.src_id is not null) as "artists: _"
|
||||
(
|
||||
select jsonb_agg(json_build_object('id', xa.src_id, 'sv', xa.service, 'n', xa.name) order by xarb.seq)
|
||||
filter (where xa.src_id is not null)
|
||||
from artists xa
|
||||
join artists_albums xarb on xarb.artist_id=xa.id
|
||||
where xarb.album_id=b.id
|
||||
) as "artists: _"
|
||||
from albums b
|
||||
left join artists_albums arb on arb.album_id=b.id
|
||||
left join artists a on a.id=arb.artist_id
|
||||
where a.id=$1
|
||||
group by b.id"#,
|
||||
join artists_albums arb on arb.album_id=b.id
|
||||
where arb.artist_id=$1
|
||||
order by b.release_date"#,
|
||||
id
|
||||
)
|
||||
.fetch(exec)
|
||||
|
|
@ -414,6 +405,83 @@ group by b.id"#,
|
|||
.try_collect::<Vec<_>>().await
|
||||
.map_err(DatabaseError::from)
|
||||
}
|
||||
|
||||
pub async fn tracks<'a, E>(id: i32, exec: E) -> Result<Vec<TrackSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
TrackSlimRow,
|
||||
r#"select t.src_id, t.service as "service: _", t.name, t.duration, t.album_pos, t.ul_artists,
|
||||
b.src_id as album_src_id, b.name as album_name, b.service as "album_service: _",
|
||||
b.image_url, b.image_hash, b.release_date, b.album_type as "album_type: _",
|
||||
(
|
||||
select jsonb_agg(json_build_object('id', xa.src_id, 'sv', xa.service, 'n', xa.name))
|
||||
filter (where xa.src_id is not null)
|
||||
from artists xa
|
||||
join artists_tracks xart on xart.artist_id=xa.id
|
||||
where xart.track_id=t.id
|
||||
) as "artists: _"
|
||||
from tracks t
|
||||
join artists_tracks art on art.track_id = t.id
|
||||
join albums b on b.id = t.album_id
|
||||
where art.artist_id=$1
|
||||
order by b.release_date, t.album_pos"#,
|
||||
id
|
||||
)
|
||||
.fetch(exec)
|
||||
.map_ok(TrackSlim::from)
|
||||
.try_collect::<Vec<_>>().await
|
||||
.map_err(DatabaseError::from)
|
||||
}
|
||||
|
||||
pub async fn top_tracks<'a, E>(&self, exec: E) -> Result<Vec<TrackSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
if let Some(top_tracks) = &self.top_tracks {
|
||||
Ok(TrackSlim::get_vec(top_tracks, exec)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn related_artists<'a, E>(&self, exec: E) -> Result<Vec<ArtistSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
if let Some(related_artists) = &self.related_artists {
|
||||
Ok(ArtistSlim::get_vec(related_artists, exec)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn related_playlists<'a, E>(
|
||||
&self,
|
||||
exec: E,
|
||||
) -> Result<Vec<PlaylistSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
if let Some(related_playlists) = &self.related_playlists {
|
||||
Ok(PlaylistSlim::get_vec(related_playlists, exec)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtistNew {
|
||||
|
|
@ -457,7 +525,7 @@ image_url, image_hash, header_image_url, header_image_hash, subscribers, wikiped
|
|||
related_artists, related_playlists, top_tracks)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
on conflict (src_id, service) do update set
|
||||
name = coalesce(excluded.name, artists.name),
|
||||
name = excluded.name,
|
||||
description = coalesce(excluded.description, artists.description),
|
||||
image_url = coalesce(excluded.image_url, artists.image_url),
|
||||
image_hash = coalesce(excluded.image_hash, artists.image_hash),
|
||||
|
|
@ -502,7 +570,7 @@ impl ArtistUpdate {
|
|||
if let Some(name) = &self.name {
|
||||
query.push("name=");
|
||||
query.push_bind(name);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(description) = &self.description {
|
||||
if n != 0 {
|
||||
|
|
@ -510,7 +578,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("description=");
|
||||
query.push_bind(description);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(image_url) = &self.image_url {
|
||||
if n != 0 {
|
||||
|
|
@ -518,7 +586,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("image_url=");
|
||||
query.push_bind(image_url);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(image_hash) = &self.image_hash {
|
||||
if n != 0 {
|
||||
|
|
@ -526,7 +594,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("image_hash=");
|
||||
query.push_bind(image_hash);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(header_image_url) = &self.header_image_url {
|
||||
if n != 0 {
|
||||
|
|
@ -534,7 +602,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("header_image_url=");
|
||||
query.push_bind(header_image_url);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(header_image_hash) = &self.header_image_hash {
|
||||
if n != 0 {
|
||||
|
|
@ -542,7 +610,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("header_image_hash=");
|
||||
query.push_bind(header_image_hash);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(subscribers) = &self.subscribers {
|
||||
if n != 0 {
|
||||
|
|
@ -550,7 +618,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("subscribers=");
|
||||
query.push_bind(subscribers);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(wikipedia_url) = &self.wikipedia_url {
|
||||
if n != 0 {
|
||||
|
|
@ -558,7 +626,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("wikipedia_url=");
|
||||
query.push_bind(wikipedia_url);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(playlist_id) = &self.playlist_id {
|
||||
if n != 0 {
|
||||
|
|
@ -566,7 +634,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("playlist_id=");
|
||||
query.push_bind(playlist_id);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(related_artists) = &self.related_artists {
|
||||
if n != 0 {
|
||||
|
|
@ -574,7 +642,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("related_artists=");
|
||||
query.push_bind(related_artists);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(related_playlists) = &self.related_playlists {
|
||||
if n != 0 {
|
||||
|
|
@ -582,7 +650,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("related_playlists=");
|
||||
query.push_bind(related_playlists);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
if let Some(top_tracks) = &self.top_tracks {
|
||||
if n != 0 {
|
||||
|
|
@ -590,7 +658,7 @@ impl ArtistUpdate {
|
|||
}
|
||||
query.push("top_tracks=");
|
||||
query.push_bind(top_tracks);
|
||||
n = n + 1;
|
||||
n += 1;
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
|
|
@ -640,12 +708,12 @@ where src_id=$1 and service=$2"#,
|
|||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))
|
||||
}
|
||||
|
||||
pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Self>, DatabaseError>
|
||||
pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Option<Self>>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
stream::iter(ids)
|
||||
.map(|id| async move { ArtistSlim::get(Id::Db(*id), exec).await })
|
||||
.map(|id| async move { ArtistSlim::get(Id::Db(*id), exec).await.to_optional() })
|
||||
.buffered(DB_CONCURRENCY)
|
||||
.try_collect()
|
||||
.await
|
||||
|
|
@ -659,10 +727,10 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn artist_crud() {
|
||||
async fn crud() {
|
||||
let artist = ArtistNew {
|
||||
src_id: "UCRw0x9_EfawqmgDI2IgQLLg".to_owned(),
|
||||
service: MusicService::Youtube,
|
||||
service: MusicService::YouTube,
|
||||
name: "Oonagh".to_owned(),
|
||||
description: Some("Senta-Sofia Delliponti is a German singer, songwriter and actress. Since January 2014, she used the stage name Oonagh, until she changed it to Senta in 2022. Her signature musical style is inspired by the mystical lore of J. R. R. Tolkien's universe and by ethnic sounds throughout the world.".to_owned()),
|
||||
image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w500-h500-p-l90-rj".to_owned()),
|
||||
|
|
@ -679,9 +747,10 @@ mod tests {
|
|||
|
||||
// Create
|
||||
let new_id = artist.insert(&pool).await.unwrap();
|
||||
let id = Id::Db(new_id);
|
||||
|
||||
// Request
|
||||
let inserted = Artist::get(Id::Db(new_id), &pool).await.unwrap();
|
||||
let inserted = Artist::get(id, &pool).await.unwrap();
|
||||
assert_eq!(inserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", inserted, {
|
||||
".id" => "[id]",
|
||||
|
|
@ -700,7 +769,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(Artist::get_id(srcid, &pool).await.unwrap().unwrap(), new_id);
|
||||
|
||||
let slim = ArtistSlim::get(Id::Db(new_id), &pool).await.unwrap();
|
||||
let slim = ArtistSlim::get(id, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("slim", slim);
|
||||
|
||||
// Update
|
||||
|
|
@ -718,9 +787,9 @@ mod tests {
|
|||
related_playlists: Some(Vec::new()),
|
||||
top_tracks: Some(Vec::new()),
|
||||
};
|
||||
clear.update(Id::Db(new_id), &pool).await.unwrap();
|
||||
clear.update(id, &pool).await.unwrap();
|
||||
|
||||
let got_empty = Artist::get(Id::Db(new_id), &pool).await.unwrap();
|
||||
let got_empty = Artist::get(id, &pool).await.unwrap();
|
||||
assert_eq!(got_empty.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_empty", got_empty, {
|
||||
".id" => "[id]",
|
||||
|
|
@ -732,7 +801,7 @@ mod tests {
|
|||
let ups_id = artist.upsert(&pool).await.unwrap();
|
||||
assert_eq!(ups_id, new_id);
|
||||
|
||||
let upserted = Artist::get(Id::Db(new_id), &pool).await.unwrap();
|
||||
let upserted = Artist::get(id, &pool).await.unwrap();
|
||||
assert_eq!(upserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", upserted, {
|
||||
".id" => "[id]",
|
||||
|
|
@ -790,12 +859,45 @@ mod tests {
|
|||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let albums_lea = Artist::albums(ids::ARTIST_ID_LEA, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(albums_lea);
|
||||
insta::assert_ron_snapshot!("albums_lea", albums_lea);
|
||||
|
||||
let albums_cyril = Artist::albums(ids::ARTIST_ID_CYRIL, &pool).await.unwrap();
|
||||
assert_eq!(albums_cyril.len(), 1);
|
||||
assert_eq!(&albums_cyril[0].src_id, &albums_lea[1].src_id);
|
||||
// TODO: fix album artists
|
||||
insta::assert_ron_snapshot!("albums_cyril", albums_cyril);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn tracks() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let tracks = Artist::tracks(ids::ARTIST_ID_LEA, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(tracks);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn top_tracks() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let artist = Artist::get(ids::ARTIST_LEA, &pool).await.unwrap();
|
||||
let tracks = artist.top_tracks(&pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(tracks);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn related_artists() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let artist = Artist::get(ids::ARTIST_LEA, &pool).await.unwrap();
|
||||
let related = artist.related_artists(&pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(related);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn related_playlists() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let artist = Artist::get(ids::ARTIST_LEA, &pool).await.unwrap();
|
||||
let related = artist.related_playlists(&pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(related);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
|
|
@ -824,7 +926,18 @@ mod tests {
|
|||
);
|
||||
|
||||
// Album artists should be changed
|
||||
let album = AlbumSlim::get(ids::ALBUM_DSMDW, &pool).await.unwrap();
|
||||
assert_eq!(album.artists.len(), 1);
|
||||
let album1 = AlbumSlim::get(ids::ALBUM_IWWUS, &pool).await.unwrap();
|
||||
assert_eq!(album1.artists.len(), 1);
|
||||
assert_eq!(
|
||||
album1.artists[0].id.as_ref().unwrap().as_srcid(),
|
||||
ids::ARTIST_SRC_LEA
|
||||
);
|
||||
|
||||
let album2 = AlbumSlim::get(ids::ALBUM_DSMDW, &pool).await.unwrap();
|
||||
assert_eq!(album2.artists.len(), 1);
|
||||
assert_eq!(
|
||||
album2.artists[0].id.as_ref().unwrap().as_srcid(),
|
||||
ids::ARTIST_SRC_LEA
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
crates/db/src/models/change_operation.rs
Normal file
135
crates/db/src/models/change_operation.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
use otvec::Operation;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
use super::{DatabaseError, PlaylistChange, PlaylistEntry, SrcIdOwned};
|
||||
|
||||
/// Change operation stored in the database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "typ")]
|
||||
pub enum ChangeOperation {
|
||||
/// Insert
|
||||
Ins { pos: u32, val: Vec<SrcIdOwned> },
|
||||
/// Delete
|
||||
Del { pos: u32, n: u32 },
|
||||
/// Move
|
||||
Mov { pos: u32, n: u32, to: u32 },
|
||||
}
|
||||
|
||||
impl ChangeOperation {
|
||||
pub fn to_op(&self, dt: PrimitiveDateTime) -> Result<Operation<PlaylistEntry>, DatabaseError> {
|
||||
match self {
|
||||
ChangeOperation::Ins { pos, val } => Ok(Operation::Ins {
|
||||
pos: (*pos).try_into()?,
|
||||
val: val
|
||||
.iter()
|
||||
.map(|id| PlaylistEntry {
|
||||
id: id.to_owned(),
|
||||
dt,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
ChangeOperation::Del { pos, n } => Ok(Operation::Del {
|
||||
pos: (*pos).try_into()?,
|
||||
n: (*n).try_into()?,
|
||||
}),
|
||||
ChangeOperation::Mov { pos, n, to } => {
|
||||
if *n == 1 {
|
||||
Ok(Operation::Mov {
|
||||
pos: (*pos).try_into()?,
|
||||
to: (*to).try_into()?,
|
||||
})
|
||||
} else {
|
||||
/*
|
||||
let start = usize::try_from(pos)?;
|
||||
let end = usize::try_from(pos + n)?;
|
||||
let to = usize::try_from(to)?;
|
||||
Ok(Operation::Seq {
|
||||
ops: (start..end)
|
||||
.rev()
|
||||
.enumerate()
|
||||
.map(|(i, pos)| Operation::Mov { pos, to })
|
||||
.collect(),
|
||||
})
|
||||
*/
|
||||
todo!("figure out moving ranges")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn changes_to_op<'a, T: IntoIterator<Item = &'a PlaylistChange>>(
|
||||
changes: T,
|
||||
) -> Result<Operation<PlaylistEntry>, DatabaseError> {
|
||||
let changes = changes.into_iter();
|
||||
let mut ops = Vec::with_capacity(changes.size_hint().0);
|
||||
for c in changes {
|
||||
ops.append(&mut c.operations()?);
|
||||
}
|
||||
Ok(Operation::Seq { ops })
|
||||
}
|
||||
|
||||
pub fn op_to_changes(
|
||||
op: Operation<PlaylistEntry>,
|
||||
list: &mut Vec<ChangeOperation>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let changeop = match op {
|
||||
Operation::Nop => return Ok(()),
|
||||
Operation::Ins { pos, val } => ChangeOperation::Ins {
|
||||
pos: pos.try_into()?,
|
||||
val: val.into_iter().map(|itm| itm.id).collect(),
|
||||
},
|
||||
Operation::Del { pos, n } => ChangeOperation::Del {
|
||||
pos: pos.try_into()?,
|
||||
n: n.try_into()?,
|
||||
},
|
||||
Operation::Mov { pos, to } => ChangeOperation::Mov {
|
||||
pos: pos.try_into()?,
|
||||
n: 1,
|
||||
to: to.try_into()?,
|
||||
},
|
||||
Operation::Seq { ops } => {
|
||||
return ops
|
||||
.into_iter()
|
||||
.try_for_each(|op| Self::op_to_changes(op, list))
|
||||
}
|
||||
};
|
||||
list.push(changeop);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::models::MusicService;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
pub fn to_json() {
|
||||
let ins = ChangeOperation::Ins {
|
||||
pos: 1,
|
||||
val: vec![SrcIdOwned("Zeerrnuli5E".to_owned(), MusicService::YouTube)],
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ins).unwrap(),
|
||||
r#"{"typ":"INS","pos":1,"val":["yt:Zeerrnuli5E"]}"#
|
||||
);
|
||||
|
||||
let del = ChangeOperation::Del { pos: 1, n: 2 };
|
||||
assert_eq!(
|
||||
serde_json::to_string(&del).unwrap(),
|
||||
r#"{"typ":"DEL","pos":1,"n":2}"#
|
||||
);
|
||||
|
||||
let del = ChangeOperation::Mov {
|
||||
pos: 1,
|
||||
n: 2,
|
||||
to: 3,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&del).unwrap(),
|
||||
r#"{"typ":"MOV","pos":1,"n":2,"to":3}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,29 @@
|
|||
use std::{borrow::Cow, fmt::Write};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{de::Visitor, Deserialize, Serialize};
|
||||
use sqlx::types::Json;
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
mod album;
|
||||
mod artist;
|
||||
mod change_operation;
|
||||
mod playlist;
|
||||
mod playlist_change;
|
||||
mod track;
|
||||
mod types;
|
||||
|
||||
pub use album::{Album, AlbumId, AlbumNew, AlbumSlim, AlbumUpdate};
|
||||
pub use artist::{Artist, ArtistId, ArtistNew, ArtistSlim, ArtistUpdate};
|
||||
pub use playlist::{Playlist, PlaylistNew, PlaylistSlim, PlaylistUpdate};
|
||||
pub use playlist::{
|
||||
Playlist, PlaylistEntry, PlaylistEntryTrack, PlaylistNew, PlaylistSlim, PlaylistUpdate,
|
||||
};
|
||||
pub use track::{Track, TrackNew, TrackSlim, TrackUpdate};
|
||||
pub use types::{
|
||||
AlbumType, DatePrecision, MusicService, PlaylistImgType, PlaylistType, SyncData, SyncError,
|
||||
};
|
||||
|
||||
pub(crate) use change_operation::ChangeOperation;
|
||||
pub(crate) use playlist_change::{PlaylistChange, PlaylistChangeNew};
|
||||
pub(crate) use track::TrackSlimRow;
|
||||
|
||||
use artist::ArtistJsonb;
|
||||
|
||||
|
|
@ -24,10 +35,18 @@ pub enum DatabaseError {
|
|||
Json(#[from] serde_json::Error),
|
||||
#[error("Item {0} not found")]
|
||||
NotFound(IdOwned),
|
||||
#[error("Playlist VCS error: {0}")]
|
||||
PlaylistVcs(Cow<'static, str>),
|
||||
#[error("DB error: {0}")]
|
||||
Other(Cow<'static, str>),
|
||||
}
|
||||
|
||||
impl From<std::num::TryFromIntError> for DatabaseError {
|
||||
fn from(value: std::num::TryFromIntError) -> Self {
|
||||
Self::Other(value.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum Id<'a> {
|
||||
Db(i32),
|
||||
|
|
@ -40,89 +59,12 @@ pub enum IdOwned {
|
|||
Src(String, MusicService),
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub struct SrcId<'a>(pub &'a str, pub MusicService);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct SrcIdOwned(pub String, pub MusicService);
|
||||
|
||||
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "music_service", rename_all = "snake_case")]
|
||||
pub enum MusicService {
|
||||
#[default]
|
||||
Youtube,
|
||||
Spotify,
|
||||
Tiraya,
|
||||
Musixmatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "album_type", rename_all = "snake_case")]
|
||||
pub enum AlbumType {
|
||||
Album,
|
||||
Single,
|
||||
Ep,
|
||||
Mv,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "date_precision", rename_all = "snake_case")]
|
||||
pub enum DatePrecision {
|
||||
Day,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "playlist_type", rename_all = "snake_case")]
|
||||
pub enum PlaylistType {
|
||||
Local,
|
||||
Remote,
|
||||
Favorites,
|
||||
ArtistTracks,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "playlist_img_type", rename_all = "snake_case")]
|
||||
pub enum PlaylistImgType {
|
||||
Single,
|
||||
Mosaic,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncError {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncData {
|
||||
/// Version ID of the synchronized item
|
||||
Version(String),
|
||||
/// Update date of the synchronized item
|
||||
Date(PrimitiveDateTime),
|
||||
/// Error during sync
|
||||
Error(SyncError),
|
||||
}
|
||||
|
||||
impl MusicService {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
MusicService::Youtube => "yt",
|
||||
MusicService::Spotify => "sp",
|
||||
MusicService::Tiraya => "ty",
|
||||
MusicService::Musixmatch => "mx",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Id<'_> {
|
||||
pub fn to_owned(&self) -> IdOwned {
|
||||
match self {
|
||||
|
|
@ -159,6 +101,10 @@ 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<'_> {
|
||||
|
|
@ -166,7 +112,7 @@ impl std::fmt::Display for Id<'_> {
|
|||
match self {
|
||||
Id::Db(id) => id.fmt(f),
|
||||
Id::Src(src_id, srv) => {
|
||||
srv.id().fmt(f)?;
|
||||
srv.fmt(f)?;
|
||||
f.write_char(':')?;
|
||||
src_id.fmt(f)
|
||||
}
|
||||
|
|
@ -179,7 +125,7 @@ impl std::fmt::Display for IdOwned {
|
|||
match self {
|
||||
IdOwned::Db(id) => id.fmt(f),
|
||||
IdOwned::Src(src_id, srv) => {
|
||||
srv.id().fmt(f)?;
|
||||
srv.fmt(f)?;
|
||||
f.write_char(':')?;
|
||||
src_id.fmt(f)
|
||||
}
|
||||
|
|
@ -189,12 +135,104 @@ impl std::fmt::Display for IdOwned {
|
|||
|
||||
impl std::fmt::Display for SrcId<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.1.id().fmt(f)?;
|
||||
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<'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)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait OptionalRes<T> {
|
||||
fn to_optional(self) -> Result<Option<T>, DatabaseError>;
|
||||
}
|
||||
|
|
@ -217,18 +255,21 @@ pub(crate) fn map_artists(
|
|||
if let Some(jsonb) = jsonb {
|
||||
for a in jsonb.0 {
|
||||
artists.push(ArtistId {
|
||||
src_id: Some(SrcIdOwned(a.src_id, a.service)),
|
||||
id: Some(SrcIdOwned(a.src_id, a.service)),
|
||||
name: a.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(ul) = ul {
|
||||
for a in ul {
|
||||
artists.push(ArtistId {
|
||||
src_id: None,
|
||||
name: a,
|
||||
});
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
use serde::Serialize;
|
||||
use sqlx::{types::Json, FromRow};
|
||||
use futures::{stream, StreamExt, TryStreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::PrimitiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{MusicService, PlaylistImgType, PlaylistType, SyncData};
|
||||
use crate::util::DB_CONCURRENCY;
|
||||
|
||||
use super::{
|
||||
ChangeOperation, DatabaseError, Id, MusicService, OptionalRes, PlaylistChange,
|
||||
PlaylistChangeNew, PlaylistImgType, PlaylistType, SrcId, SrcIdOwned, SyncData, TrackSlim,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct Playlist {
|
||||
|
|
@ -21,10 +28,11 @@ pub struct Playlist {
|
|||
pub updated_at: PrimitiveDateTime,
|
||||
pub last_sync_at: Option<PrimitiveDateTime>,
|
||||
pub last_sync_data: Option<Json<SyncData>>,
|
||||
pub cache_version: Option<Uuid>,
|
||||
pub cache_data: Option<Json<Vec<PlaylistEntry>>>,
|
||||
}
|
||||
|
||||
/// Data for creating a playlist
|
||||
#[derive(Debug)]
|
||||
pub struct PlaylistNew {
|
||||
pub src_id: Option<String>,
|
||||
pub service: Option<MusicService>,
|
||||
|
|
@ -39,7 +47,7 @@ pub struct PlaylistNew {
|
|||
}
|
||||
|
||||
/// Data for updating a playlist
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Default)]
|
||||
pub struct PlaylistUpdate {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<Option<String>>,
|
||||
|
|
@ -52,7 +60,7 @@ pub struct PlaylistUpdate {
|
|||
}
|
||||
|
||||
/// Playlist item (for display)
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PlaylistSlim {
|
||||
pub src_id: String,
|
||||
pub service: MusicService,
|
||||
|
|
@ -62,3 +70,769 @@ pub struct PlaylistSlim {
|
|||
pub owner_name: Option<String>,
|
||||
pub owner_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct PlaylistSlimRow {
|
||||
pub src_id: Option<String>,
|
||||
pub service: Option<MusicService>,
|
||||
pub name: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PlaylistEntry {
|
||||
pub id: SrcIdOwned,
|
||||
pub dt: PrimitiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PlaylistEntryTrack {
|
||||
pub n: u32,
|
||||
pub dt: PrimitiveDateTime,
|
||||
pub track: Option<TrackSlim>,
|
||||
}
|
||||
|
||||
impl Playlist {
|
||||
pub fn id(&self) -> Id {
|
||||
Id::Db(self.id)
|
||||
}
|
||||
|
||||
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
r#"select id, src_id, service as "service: _", name, description,
|
||||
owner_name, owner_url, playlist_type as "playlist_type: _", image_url, image_hash,
|
||||
image_type as "image_type: _", created_at, updated_at, last_sync_at, last_sync_data as "last_sync_data: _",
|
||||
cache_version, cache_data as "cache_data: _"
|
||||
from playlists where id=$1"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
}
|
||||
Id::Src(src_id, srv) => {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
r#"select id, src_id, service as "service: _", name, description,
|
||||
owner_name, owner_url, playlist_type as "playlist_type: _", image_url, image_hash,
|
||||
image_type as "image_type: _", created_at, updated_at, last_sync_at, last_sync_data as "last_sync_data: _",
|
||||
cache_version, cache_data as "cache_data: _"
|
||||
from playlists where src_id=$1 and service=$2"#,
|
||||
src_id,
|
||||
srv as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))
|
||||
}
|
||||
|
||||
pub async fn get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
Ok(sqlx::query!(
|
||||
r#"select id from playlists where src_id=$1 and service=$2"#,
|
||||
id.0,
|
||||
id.1 as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?
|
||||
.map(|r| r.id))
|
||||
}
|
||||
|
||||
pub async fn get_src_id<'a, E>(id: i32, exec: E) -> Result<Option<SrcIdOwned>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"select src_id, service as "service: MusicService" from playlists where id=$1"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
Ok(res.and_then(|res| match (res.src_id, res.service) {
|
||||
(Some(src_id), Some(service)) => Some(SrcIdOwned(src_id, service)),
|
||||
_ => None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn resolve_id<'a, E>(id: Id<'_>, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
Id::Db(id) => Ok(id),
|
||||
Id::Src(src_id, srv) => {
|
||||
let srcid = SrcId(src_id, srv);
|
||||
Self::get_id(srcid, exec)
|
||||
.await?
|
||||
.ok_or_else(|| DatabaseError::NotFound(srcid.to_owned_id()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete<'a, E>(id: i32, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(r#"delete from playlists where id=$1"#, id)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_last_sync<'a, E>(
|
||||
id: i32,
|
||||
last_sync_data: SyncData,
|
||||
exec: E,
|
||||
) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"update playlists set last_sync_at=now(), last_sync_data=$2 where id=$1"#,
|
||||
id,
|
||||
serde_json::to_value(last_sync_data)?,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_cache<'a, E>(
|
||||
id: i32,
|
||||
cache_version: Uuid,
|
||||
cache_data: &[PlaylistEntry],
|
||||
exec: E,
|
||||
) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"update playlists set cache_version=$2, cache_data=$3 where id=$1"#,
|
||||
id,
|
||||
cache_version,
|
||||
serde_json::to_value(cache_data)?,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_heads<'a, E>(id: i32, exec: E) -> Result<Vec<Uuid>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"select ph.playlist_change_id from playlist_heads ph
|
||||
join playlist_changes pc on pc.id=ph.playlist_change_id
|
||||
where ph.playlist_id=$1
|
||||
order by pc.created_at"#,
|
||||
id
|
||||
)
|
||||
.fetch(exec)
|
||||
.map_ok(|r| r.playlist_change_id)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(DatabaseError::from)
|
||||
}
|
||||
|
||||
/// Merge 2 playlist heads and return the id of the new merged head
|
||||
async fn merge<'a, E>(
|
||||
playlist_id: i32,
|
||||
h1: Uuid,
|
||||
h2: Uuid,
|
||||
exec: E,
|
||||
) -> Result<Uuid, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let m = PlaylistChange::merge(h1, h2, exec).await?;
|
||||
Self::add_head(playlist_id, m, exec).await?;
|
||||
if m != h1 {
|
||||
Self::remove_head(playlist_id, h1, exec).await?;
|
||||
}
|
||||
if m != h2 {
|
||||
Self::remove_head(playlist_id, h2, exec).await?;
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Get the id of a single playlist head (or none if the playlist is empty)
|
||||
/// Run a merge operation if necessary
|
||||
async fn get_single_head<'a, E>(
|
||||
playlist_id: i32,
|
||||
exec: E,
|
||||
) -> Result<Option<Uuid>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let mut heads = Self::get_heads(playlist_id, exec).await?;
|
||||
while heads.len() > 1 {
|
||||
Self::merge(playlist_id, heads[0], heads[1], exec).await?;
|
||||
heads = Self::get_heads(playlist_id, exec).await?;
|
||||
}
|
||||
Ok(heads.first().copied())
|
||||
}
|
||||
|
||||
/// Get a list of playlist entries from the current revision
|
||||
///
|
||||
/// Merges heads and updates the cache if necessary.
|
||||
pub async fn get_entries<'a, E>(&self, exec: E) -> Result<Vec<PlaylistEntry>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
if let Some(head) = Self::get_single_head(self.id, exec).await? {
|
||||
// Get list of items and changes to apply
|
||||
let (mut items, changes) = if let (Some(cache_version), Some(Json(cache_data))) =
|
||||
(self.cache_version, self.cache_data.clone())
|
||||
{
|
||||
(
|
||||
cache_data,
|
||||
if head != cache_version {
|
||||
PlaylistChange::get_changes(Some(cache_version), head, exec).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Vec::new(),
|
||||
PlaylistChange::get_changes(None, head, exec).await?,
|
||||
)
|
||||
};
|
||||
|
||||
if !changes.is_empty() {
|
||||
// Apply changes
|
||||
for c in changes.iter().rev() {
|
||||
c.apply(&mut items)?;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
Self::set_cache(self.id, head, &items, exec).await?;
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_entries_from<'a, E>(
|
||||
head: Uuid,
|
||||
exec: E,
|
||||
) -> Result<Vec<PlaylistEntry>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let mut items = Vec::new();
|
||||
let changes = PlaylistChange::get_changes(None, head, exec).await?;
|
||||
|
||||
// Apply changes
|
||||
for c in changes.iter().rev() {
|
||||
c.apply(&mut items)?;
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub async fn get_tracks<'a, E>(&self, exec: E) -> Result<Vec<PlaylistEntryTrack>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let entries = self.get_entries(exec).await?;
|
||||
Self::tracks_from_entries(&entries, exec).await
|
||||
}
|
||||
|
||||
pub async fn get_tracks_from<'a, E>(
|
||||
head: Uuid,
|
||||
exec: E,
|
||||
) -> Result<Vec<PlaylistEntryTrack>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let entries = Self::get_entries_from(head, exec).await?;
|
||||
Self::tracks_from_entries(&entries, exec).await
|
||||
}
|
||||
|
||||
async fn tracks_from_entries<'a, E>(
|
||||
entries: &[PlaylistEntry],
|
||||
exec: E,
|
||||
) -> Result<Vec<PlaylistEntryTrack>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
stream::iter(entries)
|
||||
.enumerate()
|
||||
.map(|(i, e)| async move {
|
||||
TrackSlim::get(e.id.id(), exec)
|
||||
.await
|
||||
.to_optional()
|
||||
.map(|track| PlaylistEntryTrack {
|
||||
n: i as u32,
|
||||
dt: e.dt,
|
||||
track,
|
||||
})
|
||||
})
|
||||
.buffered(DB_CONCURRENCY)
|
||||
.try_collect()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn add_head<'a, E>(playlist_id: i32, head: Uuid, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(
|
||||
"insert into playlist_heads (playlist_id, playlist_change_id) values ($1,$2) on conflict do nothing",
|
||||
playlist_id,
|
||||
head
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_head<'a, E>(playlist_id: i32, head: Uuid, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(
|
||||
"delete from playlist_heads where playlist_id=$1 and playlist_change_id=$2",
|
||||
playlist_id,
|
||||
head
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new change to the database
|
||||
///
|
||||
/// The change will be added on top of the given revision (or the root of the playlist
|
||||
/// if no revision is given).
|
||||
pub async fn add_change(
|
||||
rev: Option<Uuid>,
|
||||
playlist_id: i32,
|
||||
operations: Vec<ChangeOperation>,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<Uuid, DatabaseError> {
|
||||
let seq = match rev {
|
||||
Some(rev) => PlaylistChange::get(rev, &mut **tx).await?.seq + 1,
|
||||
None => 1,
|
||||
};
|
||||
|
||||
let change = PlaylistChangeNew {
|
||||
seq,
|
||||
operations,
|
||||
playlist_id,
|
||||
parent1_id: rev,
|
||||
..Default::default()
|
||||
};
|
||||
let cid = change.insert(&mut **tx).await?;
|
||||
|
||||
Self::add_head(playlist_id, cid, &mut **tx).await?;
|
||||
if let Some(rev) = rev {
|
||||
Self::remove_head(playlist_id, rev, &mut **tx).await?;
|
||||
}
|
||||
|
||||
Ok(cid)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistNew {
|
||||
pub async fn insert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into playlists (src_id, service, name, description,
|
||||
owner_name, owner_url, playlist_type, image_url, image_hash, image_type)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as Option<MusicService>,
|
||||
self.name,
|
||||
self.description,
|
||||
self.owner_name,
|
||||
self.owner_url,
|
||||
self.playlist_type as PlaylistType,
|
||||
self.image_url,
|
||||
self.image_hash,
|
||||
self.image_type as Option<PlaylistImgType>,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
Ok(res.id)
|
||||
}
|
||||
|
||||
pub async fn upsert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into playlists (src_id, service, name, description,
|
||||
owner_name, owner_url, playlist_type, image_url, image_hash, image_type)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
on conflict (src_id, service) do update set
|
||||
name = coalesce(excluded.name, playlists.name),
|
||||
description = coalesce(excluded.description, playlists.description),
|
||||
owner_name = coalesce(excluded.owner_name, playlists.owner_name),
|
||||
owner_url = coalesce(excluded.owner_url, playlists.owner_url),
|
||||
playlist_type = excluded.playlist_type,
|
||||
image_url = coalesce(excluded.image_url, playlists.image_url),
|
||||
image_hash = coalesce(excluded.image_hash, playlists.image_hash),
|
||||
image_type = coalesce(excluded.image_type, playlists.image_type)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as Option<MusicService>,
|
||||
self.name,
|
||||
self.description,
|
||||
self.owner_name,
|
||||
self.owner_url,
|
||||
self.playlist_type as PlaylistType,
|
||||
self.image_url,
|
||||
self.image_hash,
|
||||
self.image_type as Option<PlaylistImgType>,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
Ok(res.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistUpdate {
|
||||
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let mut query = QueryBuilder::new("update playlists set ");
|
||||
let mut n = 0;
|
||||
|
||||
if let Some(name) = &self.name {
|
||||
query.push("name=");
|
||||
query.push_bind(name);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(description) = &self.description {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("description=");
|
||||
query.push_bind(description);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(owner_name) = &self.owner_name {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("owner_name=");
|
||||
query.push_bind(owner_name);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(owner_url) = &self.owner_url {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("owner_url=");
|
||||
query.push_bind(owner_url);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(playlist_type) = &self.playlist_type {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("playlist_type=");
|
||||
query.push_bind(playlist_type);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(image_url) = &self.image_url {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("image_url=");
|
||||
query.push_bind(image_url);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(image_hash) = &self.image_hash {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("image_hash=");
|
||||
query.push_bind(image_hash);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(image_type) = &self.image_type {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("image_type=");
|
||||
query.push_bind(image_type);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
match id {
|
||||
Id::Db(id) => {
|
||||
query.push("id=");
|
||||
query.push_bind(id);
|
||||
}
|
||||
Id::Src(src_id, srv) => {
|
||||
query.push("src_id=");
|
||||
query.push_bind(src_id);
|
||||
query.push(" and service=");
|
||||
query.push_bind(srv);
|
||||
}
|
||||
}
|
||||
|
||||
query.build().execute(exec).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistSlim {
|
||||
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
PlaylistSlimRow,
|
||||
r#"select src_id, service as "service: _", name, image_url, image_hash,
|
||||
owner_name, owner_url
|
||||
from playlists where id=$1"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
}
|
||||
Id::Src(src_id, srv) => {
|
||||
sqlx::query_as!(
|
||||
PlaylistSlimRow,
|
||||
r#"select src_id, service as "service: _", name, image_url, image_hash,
|
||||
owner_name, owner_url
|
||||
from playlists where src_id=$1 and service=$2"#,
|
||||
src_id,
|
||||
srv as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
}
|
||||
}?
|
||||
.and_then(|x| PlaylistSlim::try_from(x).ok())
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))
|
||||
}
|
||||
|
||||
pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Option<Self>>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
stream::iter(ids)
|
||||
.map(|id| async move { PlaylistSlim::get(Id::Db(*id), exec).await.to_optional() })
|
||||
.buffered(DB_CONCURRENCY)
|
||||
.try_collect()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PlaylistSlimRow> for PlaylistSlim {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: PlaylistSlimRow) -> Result<Self, Self::Error> {
|
||||
let (Some(src_id), Some(service), Some(name)) = (value.src_id, value.service, value.name)
|
||||
else {
|
||||
return Err(());
|
||||
};
|
||||
Ok(Self {
|
||||
src_id,
|
||||
service,
|
||||
name,
|
||||
image_url: value.image_url,
|
||||
image_hash: value.image_hash,
|
||||
owner_name: value.owner_name,
|
||||
owner_url: value.owner_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::{self, ids};
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn crud() {
|
||||
let playlist = PlaylistNew {
|
||||
src_id: Some("RDCLAK5uy_m5BMYwuJbcooMFbKC821i2yIljq-MC-fk".to_owned()),
|
||||
service: Some(MusicService::YouTube),
|
||||
name: Some("Party Time".to_owned()),
|
||||
description: Some("Die besten Party-Songs um so richtig loszulassen.".to_owned()),
|
||||
owner_name: Some("YouTube Music".to_owned()),
|
||||
owner_url: Some("https://music.youtube.com".to_owned()),
|
||||
playlist_type: PlaylistType::Remote,
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj".to_owned()),
|
||||
image_hash: Some("37670c46aee28f6f282ea51eecec7e6399bb27ecd50a4f3e172b4433a7db8275".to_owned()),
|
||||
image_type: Some(PlaylistImgType::Custom),
|
||||
};
|
||||
|
||||
// Create
|
||||
let new_id = playlist.insert(&pool).await.unwrap();
|
||||
let id = Id::Db(new_id);
|
||||
// Request
|
||||
let inserted = Playlist::get(Id::Db(new_id), &pool).await.unwrap();
|
||||
assert_eq!(inserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", inserted, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = SrcId(
|
||||
playlist.src_id.as_deref().unwrap(),
|
||||
playlist.service.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
Playlist::get_src_id(new_id, &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_srcid(),
|
||||
srcid,
|
||||
);
|
||||
assert_eq!(
|
||||
Playlist::get_id(srcid, &pool).await.unwrap().unwrap(),
|
||||
new_id
|
||||
);
|
||||
|
||||
let slim = PlaylistSlim::get(id, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("slim", slim);
|
||||
|
||||
// Update
|
||||
let clear = PlaylistUpdate {
|
||||
name: Some("empty".to_owned()),
|
||||
description: Some(None),
|
||||
owner_name: Some(None),
|
||||
owner_url: Some(None),
|
||||
playlist_type: Some(PlaylistType::Local),
|
||||
image_url: Some(None),
|
||||
image_hash: Some(None),
|
||||
image_type: Some(None),
|
||||
};
|
||||
clear.update(id, &pool).await.unwrap();
|
||||
|
||||
let got_empty = Playlist::get(id, &pool).await.unwrap();
|
||||
assert_eq!(got_empty.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_empty", got_empty, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
// Upsert
|
||||
let ups_id = playlist.upsert(&pool).await.unwrap();
|
||||
assert_eq!(ups_id, new_id);
|
||||
|
||||
let upserted = Playlist::get(id, &pool).await.unwrap();
|
||||
assert_eq!(upserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", upserted, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
assert!(upserted.updated_at > inserted.updated_at);
|
||||
|
||||
// Delete
|
||||
Playlist::delete(new_id, &pool).await.unwrap();
|
||||
assert!(Playlist::get_src_id(new_id, &pool).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn get_tracks() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let pl = Playlist::get(ids::PLAYLIST_TEST1, &pool).await.unwrap();
|
||||
let tracks = pl.get_tracks(&pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("get_tracks", tracks);
|
||||
|
||||
// Check cache
|
||||
let pl = Playlist::get(ids::PLAYLIST_TEST1, &pool).await.unwrap();
|
||||
assert_eq!(pl.cache_version.unwrap(), ids::PLAYLIST_CHANGE_HEAD);
|
||||
for (i, entry) in pl.cache_data.unwrap().0.iter().enumerate() {
|
||||
let t = &tracks[i];
|
||||
let track = t.track.as_ref().unwrap();
|
||||
assert_eq!(t.n, u32::try_from(i).unwrap());
|
||||
assert_eq!(entry.id.0, track.src_id);
|
||||
assert_eq!(entry.id.1, track.service);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn update_heads() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
Playlist::add_head(ids::PLAYLIST_ID_TEST1, ids::PLAYLIST_CHANGE_I3, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
Playlist::add_head(ids::PLAYLIST_ID_TEST1, ids::PLAYLIST_CHANGE_BR1, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
Playlist::remove_head(ids::PLAYLIST_ID_TEST1, ids::PLAYLIST_CHANGE_HEAD, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let heads = Playlist::get_heads(ids::PLAYLIST_ID_TEST1, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(heads, &[ids::PLAYLIST_CHANGE_I3, ids::PLAYLIST_CHANGE_BR1]);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn add_change() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
Playlist::add_change(
|
||||
Some(ids::PLAYLIST_CHANGE_HEAD),
|
||||
ids::PLAYLIST_ID_TEST1,
|
||||
vec![ChangeOperation::Ins {
|
||||
pos: 3,
|
||||
val: vec![SrcIdOwned("2txScm52-QI".to_owned(), MusicService::YouTube)],
|
||||
}],
|
||||
&mut tx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let pl = Playlist::get(ids::PLAYLIST_TEST1, &pool).await.unwrap();
|
||||
let entries = pl.get_entries(&pool).await.unwrap();
|
||||
|
||||
assert!(
|
||||
testutil::compare_pl_entries(
|
||||
&entries,
|
||||
&[
|
||||
SrcId("WSBUeFdXiSs", MusicService::YouTube),
|
||||
SrcId("OCgE2GSL1Pk", MusicService::YouTube),
|
||||
SrcId("6485PhOtHzY", MusicService::YouTube),
|
||||
SrcId("2txScm52-QI", MusicService::YouTube)
|
||||
],
|
||||
),
|
||||
"got: {entries:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
411
crates/db/src/models/playlist_change.rs
Normal file
411
crates/db/src/models/playlist_change.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
use nonempty_collections::{nev, NonEmptyIterator};
|
||||
use otvec::Operation;
|
||||
use serde::Serialize;
|
||||
use sqlx::types::Json;
|
||||
use time::PrimitiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{playlist::PlaylistEntry, ChangeOperation, DatabaseError};
|
||||
use crate::util::{self, MergeIds};
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||
pub struct PlaylistChange {
|
||||
pub id: Uuid,
|
||||
pub seq: i32,
|
||||
pub operations: Json<Vec<ChangeOperation>>,
|
||||
pub playlist_id: i32,
|
||||
pub parent1_id: Option<Uuid>,
|
||||
pub parent2_id: Option<Uuid>,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
}
|
||||
|
||||
/// Data for creating a playlist change
|
||||
#[derive(Default)]
|
||||
pub struct PlaylistChangeNew {
|
||||
pub seq: i32,
|
||||
pub operations: Vec<ChangeOperation>,
|
||||
pub playlist_id: i32,
|
||||
pub parent1_id: Option<Uuid>,
|
||||
pub parent2_id: Option<Uuid>,
|
||||
pub created_at: Option<PrimitiveDateTime>,
|
||||
}
|
||||
|
||||
impl PartialEq for PlaylistChange {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistChange {
|
||||
pub async fn get<'a, E>(id: Uuid, exec: E) -> Result<PlaylistChange, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
PlaylistChange,
|
||||
r#"SELECT id, seq, operations as "operations: _", playlist_id, parent1_id, parent2_id, created_at from playlist_changes where id=$1"#, id
|
||||
).fetch_one(exec).await.map_err(DatabaseError::from)
|
||||
}
|
||||
|
||||
/// Return a list of change operations between 2 revisions (or from the root to a revision)
|
||||
///
|
||||
/// **Note:** the returned list is in reverse order (from end to start)
|
||||
pub async fn get_changes<'a, E>(
|
||||
from_id: Option<Uuid>,
|
||||
to_id: Uuid,
|
||||
exec: E,
|
||||
) -> Result<Vec<PlaylistChange>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match from_id {
|
||||
Some(from_id) => sqlx::query_as_unchecked!(
|
||||
PlaylistChange,
|
||||
r#"with recursive rectree AS (
|
||||
select id, seq, operations, playlist_id, parent1_id, parent2_id, created_at
|
||||
from playlist_changes where id=$1
|
||||
union all
|
||||
select t.id, t.seq, t.operations, t.playlist_id, t.parent1_id, t.parent2_id, t.created_at
|
||||
from playlist_changes t
|
||||
join rectree on rectree.parent1_id = t.id
|
||||
where t.id != $2
|
||||
)
|
||||
select id, seq, operations as "operations: _", playlist_id, parent1_id, parent2_id, created_at FROM rectree"#,
|
||||
to_id,
|
||||
from_id
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await,
|
||||
// Get changes from root if no from_id specified
|
||||
None => {
|
||||
sqlx::query_as_unchecked!(
|
||||
PlaylistChange,
|
||||
r#"with recursive rectree AS (
|
||||
select id, seq, operations, playlist_id, parent1_id, parent2_id, created_at
|
||||
from playlist_changes where id=$1
|
||||
union all
|
||||
select t.id, t.seq, t.operations, t.playlist_id, t.parent1_id, t.parent2_id, t.created_at
|
||||
from playlist_changes t
|
||||
join rectree on rectree.parent1_id = t.id
|
||||
)
|
||||
select id, seq, operations as "operations: _", playlist_id, parent1_id, parent2_id, created_at FROM rectree"#,
|
||||
to_id
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await
|
||||
}
|
||||
}.map_err(DatabaseError::from)
|
||||
}
|
||||
|
||||
/// Get all change operations for a given playlist
|
||||
///
|
||||
/// Changes are ordered by sequence number (from start to end)
|
||||
pub async fn get_all_changes<'a, E>(
|
||||
playlist_id: i32,
|
||||
exec: E,
|
||||
) -> Result<Vec<PlaylistChange>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
PlaylistChange,
|
||||
r#"select id, seq, operations as "operations: _", playlist_id,
|
||||
parent1_id, parent2_id, created_at from playlist_changes where playlist_id=$1 order by seq, created_at"#,
|
||||
playlist_id
|
||||
).fetch_all(exec).await
|
||||
}
|
||||
|
||||
pub fn operations(&self) -> Result<Vec<Operation<PlaylistEntry>>, DatabaseError> {
|
||||
self.operations
|
||||
.iter()
|
||||
.map(|op| op.to_op(self.created_at))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
|
||||
pub fn apply(&self, items: &mut Vec<PlaylistEntry>) -> Result<(), DatabaseError> {
|
||||
for op in &self.operations.0 {
|
||||
op.to_op(self.created_at)?.apply(items);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the largest common ancestor between two revisions and return the paths
|
||||
/// leading to it from either revision.
|
||||
///
|
||||
/// Paths start at the revision after the given revision and lead to the LCA.
|
||||
async fn paths_to_lca<'a, E>(
|
||||
h1: Uuid,
|
||||
h2: Uuid,
|
||||
exec: E,
|
||||
) -> Result<(Vec<PlaylistChange>, Vec<PlaylistChange>), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
// Get the 2 nodes and put them at the start position of the paths
|
||||
let mut paths = nev![
|
||||
nev![Self::get(h1, exec).await?],
|
||||
nev![Self::get(h2, exec).await?]
|
||||
];
|
||||
|
||||
// Move deeper nodes to their parent until all have the same depth
|
||||
// If there is a merge, add the second node to the list
|
||||
let target_depth = paths.iter().map(|n| n.first().seq).min();
|
||||
|
||||
let mut i = 0;
|
||||
while i < paths.len() {
|
||||
while paths[i].last().seq > target_depth {
|
||||
if let Some(p1) = paths[i].last().parent1_id {
|
||||
if let Some(p2) = paths[i].last().parent2_id {
|
||||
let mut np = paths[i].clone();
|
||||
np.push(Self::get(p2, exec).await?);
|
||||
paths.push(np);
|
||||
}
|
||||
paths[i].push(Self::get(p1, exec).await?);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Move all nodes to their parent until 2 of them meet
|
||||
loop {
|
||||
for i in 0..paths.len() {
|
||||
for j in (i + 1)..paths.len() {
|
||||
if paths[i].last() == paths[j].last() {
|
||||
return Ok((
|
||||
paths[i]
|
||||
.iter()
|
||||
.into_iter()
|
||||
.take(paths[i].len() - 1)
|
||||
.cloned()
|
||||
.collect(),
|
||||
paths[j]
|
||||
.iter()
|
||||
.into_iter()
|
||||
.take(paths[j].len() - 1)
|
||||
.cloned()
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_parents = false;
|
||||
for i in 0..paths.len() {
|
||||
let change = paths[i].last();
|
||||
if let Some(p1) = change.parent1_id {
|
||||
if let Some(p2) = change.parent2_id {
|
||||
let mut np = paths[i].clone();
|
||||
np.push(Self::get(p2, exec).await?);
|
||||
paths.push(np);
|
||||
}
|
||||
|
||||
paths[i].push(Self::get(p1, exec).await?);
|
||||
new_parents = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !new_parents {
|
||||
return Err(DatabaseError::PlaylistVcs(
|
||||
format!("could not find common ancestor between changes {h1} and {h2}").into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn merge<'a, E>(h1: Uuid, h2: Uuid, exec: E) -> Result<Uuid, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let (path1, path2) = Self::paths_to_lca(h1, h2, exec).await?;
|
||||
|
||||
// If path 1 is empty, it means that revision 1 is the largest
|
||||
// common ancestor of the revisions to merge -> revision 2 already contains
|
||||
// revision 1. Therefore no changes need to be made.
|
||||
if path1.is_empty() {
|
||||
return Ok(h2);
|
||||
}
|
||||
if path2.is_empty() {
|
||||
return Ok(h1);
|
||||
}
|
||||
|
||||
let h1_change = &path1[0];
|
||||
let h2_change = &path2[0];
|
||||
let playlist_id = h1_change.playlist_id;
|
||||
let seq = h1_change.seq;
|
||||
|
||||
let mut op1 = ChangeOperation::changes_to_op(path1.iter().rev())?;
|
||||
let mut op2 = ChangeOperation::changes_to_op(path2.iter().rev())?;
|
||||
otvec::transform(&mut op1, &mut op2);
|
||||
|
||||
let mut changes1 = Vec::new();
|
||||
ChangeOperation::op_to_changes(op2, &mut changes1)?;
|
||||
let mut changes2 = Vec::new();
|
||||
ChangeOperation::op_to_changes(op1, &mut changes2)?;
|
||||
|
||||
let merge_ids = MergeIds::new(h1_change.id, h2_change.id);
|
||||
|
||||
let nc1 = PlaylistChangeNew {
|
||||
seq: seq + 1,
|
||||
operations: changes1,
|
||||
playlist_id,
|
||||
parent1_id: Some(h1_change.id),
|
||||
parent2_id: None,
|
||||
created_at: Some(h1_change.created_at),
|
||||
};
|
||||
nc1.insert_with_id(merge_ids.op1, exec).await?;
|
||||
let nc2 = PlaylistChangeNew {
|
||||
seq: seq + 1,
|
||||
operations: changes2,
|
||||
playlist_id,
|
||||
parent1_id: Some(h2_change.id),
|
||||
parent2_id: None,
|
||||
created_at: Some(h2_change.created_at),
|
||||
};
|
||||
nc2.insert_with_id(merge_ids.op2, exec).await?;
|
||||
let ncm = PlaylistChangeNew {
|
||||
seq: seq + 2,
|
||||
operations: Vec::new(),
|
||||
playlist_id,
|
||||
parent1_id: Some(merge_ids.op1),
|
||||
parent2_id: Some(merge_ids.op2),
|
||||
created_at: None,
|
||||
};
|
||||
ncm.insert_with_id(merge_ids.merge, exec).await?;
|
||||
Ok(merge_ids.merge)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistChangeNew {
|
||||
pub async fn insert<'a, E>(&self, exec: E) -> Result<Uuid, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into playlist_changes (seq,operations,playlist_id,parent1_id,parent2_id,created_at)
|
||||
values ($1,$2,$3,$4,$5,$6) returning id"#,
|
||||
self.seq,
|
||||
serde_json::to_value(&self.operations)?,
|
||||
self.playlist_id,
|
||||
self.parent1_id,
|
||||
self.parent2_id,
|
||||
self.created_at.unwrap_or_else(|| util::primitive_now()),
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
Ok(res.id)
|
||||
}
|
||||
|
||||
pub async fn insert_with_id<'a, 'b, E>(&'b self, id: Uuid, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"insert into playlist_changes (id,seq,operations,playlist_id,parent1_id,parent2_id,created_at)
|
||||
values ($1,$2,$3,$4,$5,$6,$7)
|
||||
on conflict (id) do nothing"#,
|
||||
id,
|
||||
self.seq,
|
||||
serde_json::to_value(&self.operations)?,
|
||||
self.playlist_id,
|
||||
self.parent1_id,
|
||||
self.parent2_id,
|
||||
self.created_at.unwrap_or_else(|| util::primitive_now()),
|
||||
).execute(exec).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
models::Playlist,
|
||||
testutil::{self, ids},
|
||||
};
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn get_change() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let res = PlaylistChange::get(ids::PLAYLIST_CHANGE_I3, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!(res);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn get_changes() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let res = PlaylistChange::get_changes(None, ids::PLAYLIST_CHANGE_HEAD, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!(res);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn get_all_changes() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let res = PlaylistChange::get_all_changes(ids::PLAYLIST_ID_TEST1, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!(res);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn paths_to_lca() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let paths = PlaylistChange::paths_to_lca(
|
||||
ids::PLAYLIST_CHANGE_BR1T,
|
||||
ids::PLAYLIST_CHANGE_BR2T,
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!(paths);
|
||||
}
|
||||
|
||||
/// When running concurrent merge operations, it may happen that the system tries to merge
|
||||
/// children of a merge change with the merge itself.
|
||||
/// In this case, the merge function should simply return the already present merge change
|
||||
/// without making any changes.
|
||||
///
|
||||
/// **Example:** Merge 1 with M
|
||||
/// ```txt
|
||||
/// 1 -> 2' \
|
||||
/// M
|
||||
/// 2 -> 1' /
|
||||
/// ```
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn paths_to_lca_merged() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let paths = PlaylistChange::paths_to_lca(
|
||||
ids::PLAYLIST_CHANGE_HEAD,
|
||||
ids::PLAYLIST_CHANGE_BR1,
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!(paths);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn merge1() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let new_head =
|
||||
PlaylistChange::merge(ids::PLAYLIST_CHANGE_BR1, ids::PLAYLIST_CHANGE_BR2, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let old_entries = Playlist::get_entries_from(ids::PLAYLIST_CHANGE_HEAD, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let new_entries = Playlist::get_entries_from(new_head, &pool).await.unwrap();
|
||||
|
||||
assert_eq!(old_entries, new_entries);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
source: crates/db/src/models/album.rs
|
||||
expression: tracks_iwwus
|
||||
---
|
||||
[
|
||||
TrackSlim(
|
||||
src_id: "hWFarQmaQAQ",
|
||||
service: yt,
|
||||
name: "Immer wenn wir uns sehn (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
duration: Some(186000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_2Kv3bcPa6zp",
|
||||
service: yt,
|
||||
name: "Immer wenn wir uns sehn (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
release_date: Some((2018, 229)),
|
||||
album_type: single,
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
---
|
||||
source: crates/db/src/models/album.rs
|
||||
expression: tracks_vakuum
|
||||
---
|
||||
[
|
||||
TrackSlim(
|
||||
src_id: "2txScm52-QI",
|
||||
service: yt,
|
||||
name: "Die Segel sind gesetzt",
|
||||
duration: Some(186000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "oZKv47vyqQU",
|
||||
service: yt,
|
||||
name: "Monster",
|
||||
duration: Some(224000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(2),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "7WXlMU9ItnA",
|
||||
service: yt,
|
||||
name: "Dach",
|
||||
duration: Some(231000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(3),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "ySChj_9rT5Y",
|
||||
service: yt,
|
||||
name: "Kennst du das",
|
||||
duration: Some(191000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(4),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "revpIT2HiNs",
|
||||
service: yt,
|
||||
name: "Wohin willst du",
|
||||
duration: Some(255000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(5),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "LeEgBsYfjLU",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
duration: Some(220000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(6),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "-i5XjMkQN8M",
|
||||
service: yt,
|
||||
name: "Melodie",
|
||||
duration: Some(251000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(7),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "DhlIZkoPsxg",
|
||||
service: yt,
|
||||
name: "Du & Ich",
|
||||
duration: Some(238000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(8),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "LCoomBMOkgU",
|
||||
service: yt,
|
||||
name: "Schwerelos",
|
||||
duration: Some(198000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(9),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "c6Ot-Z3HEBo",
|
||||
service: yt,
|
||||
name: "Lichtermeer",
|
||||
duration: Some(164000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(10),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "ybm_4hQG0ok",
|
||||
service: yt,
|
||||
name: "Nachtzug",
|
||||
duration: Some(290000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(11),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "DJKmtK5PmSY",
|
||||
service: yt,
|
||||
name: "Rückenwind",
|
||||
duration: Some(263000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(12),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
source: crates/db/src/models/album.rs
|
||||
expression: got_empty
|
||||
---
|
||||
Album(
|
||||
id: "[id]",
|
||||
src_id: "MPREb_nlBWQROfvjo",
|
||||
service: yt,
|
||||
name: "empty",
|
||||
artists: [],
|
||||
release_date: None,
|
||||
release_date_precision: None,
|
||||
album_type: Some(album),
|
||||
by_va: false,
|
||||
image_url: None,
|
||||
image_hash: None,
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
hidden: true,
|
||||
dirty: true,
|
||||
)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
source: crates/db/src/models/album.rs
|
||||
expression: inserted
|
||||
---
|
||||
Album(
|
||||
id: "[id]",
|
||||
src_id: "MPREb_nlBWQROfvjo",
|
||||
service: yt,
|
||||
name: "Märchen enden gut",
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
ArtistId(
|
||||
id: None,
|
||||
name: "Other artist",
|
||||
),
|
||||
],
|
||||
release_date: Some((2016, 295)),
|
||||
release_date_precision: Some(day),
|
||||
album_type: Some(album),
|
||||
by_va: false,
|
||||
image_url: Some("https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w544-h544-l90-rj"),
|
||||
image_hash: Some("aeafabf677bb186378a539a197cc087e2a94c33bc5c3ee41c2e6513fd79442a3"),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
hidden: false,
|
||||
dirty: true,
|
||||
)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
source: crates/db/src/models/artist.rs
|
||||
expression: albums_cyril
|
||||
---
|
||||
[
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_2Kv3bcPa6zp",
|
||||
service: yt,
|
||||
name: "Immer wenn wir uns sehn (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
],
|
||||
release_date: Some((2018, 229)),
|
||||
album_type: single,
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_uRzxugVdRXQ",
|
||||
service: yt,
|
||||
name: "Das schönste Mädchen der Welt (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
],
|
||||
release_date: Some((2018, 236)),
|
||||
album_type: single,
|
||||
image_url: Some("https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
source: crates/db/src/models/artist.rs
|
||||
expression: albums_lea
|
||||
---
|
||||
[
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_2Kv3bcPa6zp",
|
||||
service: yt,
|
||||
name: "Immer wenn wir uns sehn (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
],
|
||||
release_date: Some((2018, 229)),
|
||||
album_type: single,
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
]
|
||||
|
|
@ -5,7 +5,7 @@ expression: got_empty
|
|||
Artist(
|
||||
id: "[id]",
|
||||
src_id: "UCRw0x9_EfawqmgDI2IgQLLg",
|
||||
service: youtube,
|
||||
service: yt,
|
||||
name: "empty",
|
||||
description: None,
|
||||
image_url: None,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ expression: inserted
|
|||
Artist(
|
||||
id: "[id]",
|
||||
src_id: "UCRw0x9_EfawqmgDI2IgQLLg",
|
||||
service: youtube,
|
||||
service: yt,
|
||||
name: "Oonagh",
|
||||
description: Some("Senta-Sofia Delliponti is a German singer, songwriter and actress. Since January 2014, she used the stage name Oonagh, until she changed it to Senta in 2022. Her signature musical style is inspired by the mystical lore of J. R. R. Tolkien\'s universe and by ethnic sounds throughout the world."),
|
||||
image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w500-h500-p-l90-rj"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
source: crates/db/src/models/artist.rs
|
||||
expression: related
|
||||
---
|
||||
[
|
||||
ArtistSlim(
|
||||
src_id: "UCpJyCbFbdTrx0M90HCNBHFQ",
|
||||
service: yt,
|
||||
name: "Madeline Juno",
|
||||
image_url: Some("https://lh3.googleusercontent.com/HqCLfHryo1bEwL99KJgn909ft_O-YZaJlHgSa3X9p4Yk8RJ3CnxlGgFLPVpdSPbTeqSFnsAyVQ=w544-h544-p-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
ArtistSlim(
|
||||
src_id: "UCkQRXVZuBMktEdVyptoUgGg",
|
||||
service: yt,
|
||||
name: "Mark Forster",
|
||||
image_url: Some("https://lh3.googleusercontent.com/RIRL6gRqXnOOAXcSh2pXkRPyuCVzL5PcQvvLrJLDpz3L0XhHo9nlj2ewnwKHwNZ82jjgh9JXPcOvO9Y=w544-h544-p-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
ArtistSlim(
|
||||
src_id: "UCA-uIWGyE0n9YvJ-titY8zA",
|
||||
service: yt,
|
||||
name: "LOTTE",
|
||||
image_url: Some("https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w544-h544-p-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
source: crates/db/src/models/artist.rs
|
||||
expression: related
|
||||
---
|
||||
[
|
||||
PlaylistSlim(
|
||||
src_id: "RDCLAK5uy_nCUL5fa0G5mSAxmXU9tu4uGM1SoZ44OPA",
|
||||
service: yt,
|
||||
name: "Happy German Pop",
|
||||
image_url: Some("https://lh3.googleusercontent.com/Mx7jwNSTNbl7WGboxuxwJg-W2Bj059MT0WoYVFN5ml477zUyjyaTXLOca1gMgS1VdvtXAQDncPhRwAk=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
owner_name: Some("YouTube Music"),
|
||||
owner_url: None,
|
||||
),
|
||||
]
|
||||
|
|
@ -4,7 +4,7 @@ expression: slim
|
|||
---
|
||||
ArtistSlim(
|
||||
src_id: "UCRw0x9_EfawqmgDI2IgQLLg",
|
||||
service: youtube,
|
||||
service: yt,
|
||||
name: "Oonagh",
|
||||
image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w500-h500-p-l90-rj"),
|
||||
image_hash: Some("b41f4a1f7a23ba0b7304f1e660af32125de7c77e59d5fa2b2b5ba9f796f1d8a2"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
source: crates/db/src/models/artist.rs
|
||||
expression: tracks
|
||||
---
|
||||
[
|
||||
TrackSlim(
|
||||
src_id: "LeEgBsYfjLU",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
duration: Some(220000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(6),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "LCoomBMOkgU",
|
||||
service: yt,
|
||||
name: "Schwerelos",
|
||||
duration: Some(198000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(9),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "c6Ot-Z3HEBo",
|
||||
service: yt,
|
||||
name: "Lichtermeer",
|
||||
duration: Some(164000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(10),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "hWFarQmaQAQ",
|
||||
service: yt,
|
||||
name: "Immer wenn wir uns sehn (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
duration: Some(186000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_2Kv3bcPa6zp",
|
||||
service: yt,
|
||||
name: "Immer wenn wir uns sehn (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
release_date: Some((2018, 229)),
|
||||
album_type: single,
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "2txScm52-QI",
|
||||
service: yt,
|
||||
name: "Die Segel sind gesetzt",
|
||||
duration: Some(186000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
---
|
||||
source: crates/db/src/models/artist.rs
|
||||
expression: tracks
|
||||
---
|
||||
[
|
||||
TrackSlim(
|
||||
src_id: "2txScm52-QI",
|
||||
service: yt,
|
||||
name: "Die Segel sind gesetzt",
|
||||
duration: Some(186000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "oZKv47vyqQU",
|
||||
service: yt,
|
||||
name: "Monster",
|
||||
duration: Some(224000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(2),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "7WXlMU9ItnA",
|
||||
service: yt,
|
||||
name: "Dach",
|
||||
duration: Some(231000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(3),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "ySChj_9rT5Y",
|
||||
service: yt,
|
||||
name: "Kennst du das",
|
||||
duration: Some(191000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(4),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "revpIT2HiNs",
|
||||
service: yt,
|
||||
name: "Wohin willst du",
|
||||
duration: Some(255000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(5),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "LeEgBsYfjLU",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
duration: Some(220000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(6),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "-i5XjMkQN8M",
|
||||
service: yt,
|
||||
name: "Melodie",
|
||||
duration: Some(251000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(7),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "DhlIZkoPsxg",
|
||||
service: yt,
|
||||
name: "Du & Ich",
|
||||
duration: Some(238000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(8),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "LCoomBMOkgU",
|
||||
service: yt,
|
||||
name: "Schwerelos",
|
||||
duration: Some(198000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(9),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "c6Ot-Z3HEBo",
|
||||
service: yt,
|
||||
name: "Lichtermeer",
|
||||
duration: Some(164000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(10),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "ybm_4hQG0ok",
|
||||
service: yt,
|
||||
name: "Nachtzug",
|
||||
duration: Some(290000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(11),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "DJKmtK5PmSY",
|
||||
service: yt,
|
||||
name: "Rückenwind",
|
||||
duration: Some(263000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(12),
|
||||
),
|
||||
TrackSlim(
|
||||
src_id: "hWFarQmaQAQ",
|
||||
service: yt,
|
||||
name: "Immer wenn wir uns sehn (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
duration: Some(186000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_2Kv3bcPa6zp",
|
||||
service: yt,
|
||||
name: "Immer wenn wir uns sehn (\"Das schönste Mädchen der Welt\", Soundtrack)",
|
||||
release_date: Some((2018, 229)),
|
||||
album_type: single,
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist.rs
|
||||
expression: got_empty
|
||||
---
|
||||
Playlist(
|
||||
id: "[id]",
|
||||
src_id: Some("RDCLAK5uy_m5BMYwuJbcooMFbKC821i2yIljq-MC-fk"),
|
||||
service: Some(yt),
|
||||
name: Some("empty"),
|
||||
description: None,
|
||||
owner_name: None,
|
||||
owner_url: None,
|
||||
playlist_type: local,
|
||||
image_url: None,
|
||||
image_hash: None,
|
||||
image_type: None,
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
last_sync_at: None,
|
||||
last_sync_data: None,
|
||||
cache_version: None,
|
||||
cache_data: None,
|
||||
)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist.rs
|
||||
expression: inserted
|
||||
---
|
||||
Playlist(
|
||||
id: "[id]",
|
||||
src_id: Some("RDCLAK5uy_m5BMYwuJbcooMFbKC821i2yIljq-MC-fk"),
|
||||
service: Some(yt),
|
||||
name: Some("Party Time"),
|
||||
description: Some("Die besten Party-Songs um so richtig loszulassen."),
|
||||
owner_name: Some("YouTube Music"),
|
||||
owner_url: Some("https://music.youtube.com"),
|
||||
playlist_type: remote,
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj"),
|
||||
image_hash: Some("37670c46aee28f6f282ea51eecec7e6399bb27ecd50a4f3e172b4433a7db8275"),
|
||||
image_type: Some(custom),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
last_sync_at: None,
|
||||
last_sync_data: None,
|
||||
cache_version: None,
|
||||
cache_data: None,
|
||||
)
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist.rs
|
||||
expression: tracks
|
||||
---
|
||||
[
|
||||
PlaylistEntryTrack(
|
||||
n: 0,
|
||||
dt: (2023, 249, 23, 48, 12, 399144000),
|
||||
track: Some(TrackSlim(
|
||||
src_id: "WSBUeFdXiSs",
|
||||
service: yt,
|
||||
name: "Leicht",
|
||||
duration: Some(206000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC-2mb3G26qV676d-iXbOTVQ"),
|
||||
name: "LINA",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_AeGbdGn1wG4",
|
||||
service: yt,
|
||||
name: "Unverstärkt - EP",
|
||||
release_date: Some((2017, 307)),
|
||||
album_type: ep,
|
||||
image_url: Some("https://lh3.googleusercontent.com/8Yg43icfIuszQqGjqJasncE5ifPrmwOpesgV1BnC5aZ6rjX3yFS_JEz8QldPSzmdFilZm3rUOA4O3Uh2=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: None,
|
||||
)),
|
||||
),
|
||||
PlaylistEntryTrack(
|
||||
n: 1,
|
||||
dt: (2023, 249, 23, 44, 14, 702707000),
|
||||
track: Some(TrackSlim(
|
||||
src_id: "OCgE2GSL1Pk",
|
||||
service: yt,
|
||||
name: "Smoke Signals",
|
||||
duration: Some(197000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UCQ6yypykkyPLM5FVhOm4Eog"),
|
||||
name: "Dabin",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_VnWDfciJlMK",
|
||||
service: yt,
|
||||
name: "Between Broken",
|
||||
release_date: Some((2021, 288)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/CTbhHfdn9T_dvB2_dk7hTfxmDSSM29buoFfnG6D2QqauOTZsSMrD02p6T1ERtYP5Ut-ko9Zk5Q16D5Ng=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: None,
|
||||
)),
|
||||
),
|
||||
PlaylistEntryTrack(
|
||||
n: 2,
|
||||
dt: (2023, 249, 23, 45, 39, 611352000),
|
||||
track: Some(TrackSlim(
|
||||
src_id: "6485PhOtHzY",
|
||||
service: yt,
|
||||
name: "Lieblingsmensch",
|
||||
duration: Some(190000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UCIh4j8fXWf2U0ro0qnGU8Mg"),
|
||||
name: "Namika",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_RXHxrUFfrvQ",
|
||||
service: yt,
|
||||
name: "Lieblingsmensch",
|
||||
release_date: Some((2015, 191)),
|
||||
album_type: single,
|
||||
image_url: Some("https://lh3.googleusercontent.com/dwrJ5NnlZU7CBziLRlTm1uizuolakRAX7g34-eKeqEZQGZgwmvhqcs3TiZClfm7v6a-KYHieitdakpPo=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(1),
|
||||
)),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist.rs
|
||||
assertion_line: 463
|
||||
expression: slim
|
||||
---
|
||||
PlaylistSlim(
|
||||
src_id: "RDCLAK5uy_m5BMYwuJbcooMFbKC821i2yIljq-MC-fk",
|
||||
service: yt,
|
||||
name: "Party Time",
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj"),
|
||||
image_hash: Some("37670c46aee28f6f282ea51eecec7e6399bb27ecd50a4f3e172b4433a7db8275"),
|
||||
owner_name: Some("YouTube Music"),
|
||||
owner_url: Some("https://music.youtube.com"),
|
||||
)
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist_change.rs
|
||||
expression: res
|
||||
---
|
||||
[
|
||||
PlaylistChange(
|
||||
id: "d67691dd-6cda-4196-9488-93e65d486fc9",
|
||||
seq: 1,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 0,
|
||||
val: [
|
||||
"yt:ZeerrnuLi5E",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: None,
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 21, 35, 56, 296892000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "ee80bafd-6a7d-4d3f-b857-e12b2eb5e1ac",
|
||||
seq: 2,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 1,
|
||||
val: [
|
||||
"yt:OCgE2GSL1Pk",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("d67691dd-6cda-4196-9488-93e65d486fc9"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 44, 14, 702707000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "0d9aab74-deb3-456e-afdc-c4f32413aaea",
|
||||
seq: 3,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 2,
|
||||
val: [
|
||||
"yt:6485PhOtHzY",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("ee80bafd-6a7d-4d3f-b857-e12b2eb5e1ac"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 45, 39, 611352000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "dee860f5-7f18-4157-97c4-28cfcbbce8c6",
|
||||
seq: 4,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "DEL",
|
||||
pos: 0,
|
||||
n: 1,
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("0d9aab74-deb3-456e-afdc-c4f32413aaea"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 48, 12, 399144000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "ff948c8d-06ec-478d-9d22-845331f969df",
|
||||
seq: 4,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 0,
|
||||
val: [
|
||||
"yt:WSBUeFdXiSs",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("0d9aab74-deb3-456e-afdc-c4f32413aaea"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 49, 18, 68330000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec",
|
||||
seq: 5,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 0,
|
||||
val: [
|
||||
"yt:WSBUeFdXiSs",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("dee860f5-7f18-4157-97c4-28cfcbbce8c6"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 48, 12, 399144000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "d2f0e2f4-d7a6-568a-19fc-9752ed80d7e4",
|
||||
seq: 5,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "DEL",
|
||||
pos: 1,
|
||||
n: 1,
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("ff948c8d-06ec-478d-9d22-845331f969df"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 49, 18, 68330000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "99bdde14-c1d5-ef0b-7bdc-218da3b1364b",
|
||||
seq: 6,
|
||||
operations: [],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec"),
|
||||
parent2_id: Some("d2f0e2f4-d7a6-568a-19fc-9752ed80d7e4"),
|
||||
created_at: (2023, 249, 23, 50, 59, 397384000),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist_change.rs
|
||||
expression: res
|
||||
---
|
||||
PlaylistChange(
|
||||
id: "0d9aab74-deb3-456e-afdc-c4f32413aaea",
|
||||
seq: 3,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 2,
|
||||
val: [
|
||||
"yt:6485PhOtHzY",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("ee80bafd-6a7d-4d3f-b857-e12b2eb5e1ac"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 45, 39, 611352000),
|
||||
)
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist_change.rs
|
||||
expression: res
|
||||
---
|
||||
[
|
||||
PlaylistChange(
|
||||
id: "99bdde14-c1d5-ef0b-7bdc-218da3b1364b",
|
||||
seq: 6,
|
||||
operations: [],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec"),
|
||||
parent2_id: Some("d2f0e2f4-d7a6-568a-19fc-9752ed80d7e4"),
|
||||
created_at: (2023, 249, 23, 50, 59, 397384000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec",
|
||||
seq: 5,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 0,
|
||||
val: [
|
||||
"yt:WSBUeFdXiSs",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("dee860f5-7f18-4157-97c4-28cfcbbce8c6"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 48, 12, 399144000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "dee860f5-7f18-4157-97c4-28cfcbbce8c6",
|
||||
seq: 4,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "DEL",
|
||||
pos: 0,
|
||||
n: 1,
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("0d9aab74-deb3-456e-afdc-c4f32413aaea"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 48, 12, 399144000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "0d9aab74-deb3-456e-afdc-c4f32413aaea",
|
||||
seq: 3,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 2,
|
||||
val: [
|
||||
"yt:6485PhOtHzY",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("ee80bafd-6a7d-4d3f-b857-e12b2eb5e1ac"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 45, 39, 611352000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "ee80bafd-6a7d-4d3f-b857-e12b2eb5e1ac",
|
||||
seq: 2,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 1,
|
||||
val: [
|
||||
"yt:OCgE2GSL1Pk",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("d67691dd-6cda-4196-9488-93e65d486fc9"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 44, 14, 702707000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "d67691dd-6cda-4196-9488-93e65d486fc9",
|
||||
seq: 1,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 0,
|
||||
val: [
|
||||
"yt:ZeerrnuLi5E",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: None,
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 21, 35, 56, 296892000),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist_change.rs
|
||||
expression: paths
|
||||
---
|
||||
([
|
||||
PlaylistChange(
|
||||
id: "2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec",
|
||||
seq: 5,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 0,
|
||||
val: [
|
||||
"yt:WSBUeFdXiSs",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("dee860f5-7f18-4157-97c4-28cfcbbce8c6"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 48, 12, 399144000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "dee860f5-7f18-4157-97c4-28cfcbbce8c6",
|
||||
seq: 4,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "DEL",
|
||||
pos: 0,
|
||||
n: 1,
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("0d9aab74-deb3-456e-afdc-c4f32413aaea"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 48, 12, 399144000),
|
||||
),
|
||||
], [
|
||||
PlaylistChange(
|
||||
id: "d2f0e2f4-d7a6-568a-19fc-9752ed80d7e4",
|
||||
seq: 5,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "DEL",
|
||||
pos: 1,
|
||||
n: 1,
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("ff948c8d-06ec-478d-9d22-845331f969df"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 49, 18, 68330000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "ff948c8d-06ec-478d-9d22-845331f969df",
|
||||
seq: 4,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 0,
|
||||
val: [
|
||||
"yt:WSBUeFdXiSs",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("0d9aab74-deb3-456e-afdc-c4f32413aaea"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 49, 18, 68330000),
|
||||
),
|
||||
])
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist_change.rs
|
||||
expression: paths
|
||||
---
|
||||
([
|
||||
PlaylistChange(
|
||||
id: "99bdde14-c1d5-ef0b-7bdc-218da3b1364b",
|
||||
seq: 6,
|
||||
operations: [],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec"),
|
||||
parent2_id: Some("d2f0e2f4-d7a6-568a-19fc-9752ed80d7e4"),
|
||||
created_at: (2023, 249, 23, 50, 59, 397384000),
|
||||
),
|
||||
PlaylistChange(
|
||||
id: "2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec",
|
||||
seq: 5,
|
||||
operations: [
|
||||
ChangeOperation(
|
||||
typ: "INS",
|
||||
pos: 0,
|
||||
val: [
|
||||
"yt:WSBUeFdXiSs",
|
||||
],
|
||||
),
|
||||
],
|
||||
playlist_id: 3,
|
||||
parent1_id: Some("dee860f5-7f18-4157-97c4-28cfcbbce8c6"),
|
||||
parent2_id: None,
|
||||
created_at: (2023, 249, 23, 48, 12, 399144000),
|
||||
),
|
||||
], [])
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
source: crates/db/src/models/track.rs
|
||||
expression: got_empty
|
||||
---
|
||||
Track(
|
||||
id: "[id]",
|
||||
src_id: "g0iRiJ_ck48",
|
||||
service: yt,
|
||||
name: "empty",
|
||||
duration: None,
|
||||
duration_ms: false,
|
||||
artists: [],
|
||||
size: None,
|
||||
loudness: None,
|
||||
album_id: 1,
|
||||
album_pos: None,
|
||||
isrc: None,
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
downloaded_at: None,
|
||||
last_streamed_at: None,
|
||||
n_streams: 0,
|
||||
)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
source: crates/db/src/models/track.rs
|
||||
expression: inserted
|
||||
---
|
||||
Track(
|
||||
id: "[id]",
|
||||
src_id: "g0iRiJ_ck48",
|
||||
service: yt,
|
||||
name: "Aulë und Yavanna",
|
||||
duration: Some(216000),
|
||||
duration_ms: false,
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
ArtistId(
|
||||
id: None,
|
||||
name: "Other artist",
|
||||
),
|
||||
],
|
||||
size: Some(3439414),
|
||||
loudness: Some(6.1513805),
|
||||
album_id: 1,
|
||||
album_pos: Some(1),
|
||||
isrc: Some("DEUM71602459"),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
downloaded_at: None,
|
||||
last_streamed_at: None,
|
||||
n_streams: 0,
|
||||
)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
source: crates/db/src/models/track.rs
|
||||
expression: slim
|
||||
---
|
||||
TrackSlim(
|
||||
src_id: "g0iRiJ_ck48",
|
||||
service: yt,
|
||||
name: "Aulë und Yavanna",
|
||||
duration: Some(216000),
|
||||
artists: [
|
||||
ArtistId(
|
||||
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),
|
||||
name: "LEA",
|
||||
),
|
||||
ArtistId(
|
||||
id: Some("yt:UCZEPIcm6KGYyqWUMaP2axSA"),
|
||||
name: "Cyril aka Aaron Hilmer",
|
||||
),
|
||||
ArtistId(
|
||||
id: None,
|
||||
name: "Other artist",
|
||||
),
|
||||
],
|
||||
album: AlbumId(
|
||||
src_id: "MPREb_9VuoAuPU5NR",
|
||||
service: yt,
|
||||
name: "Vakuum",
|
||||
release_date: Some((2016, 113)),
|
||||
album_type: album,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_hash: None,
|
||||
),
|
||||
album_pos: Some(1),
|
||||
)
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
use futures::{stream, StreamExt, TryStreamExt};
|
||||
use serde::Serialize;
|
||||
use sqlx::FromRow;
|
||||
use time::PrimitiveDateTime;
|
||||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::{Date, PrimitiveDateTime};
|
||||
|
||||
use super::{album::AlbumId, artist::ArtistId, MusicService};
|
||||
use crate::util::DB_CONCURRENCY;
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
use super::{
|
||||
album::AlbumId,
|
||||
artist::{ArtistId, ArtistJsonb},
|
||||
map_artists, AlbumType, Artist, DatabaseError, Id, MusicService, OptionalRes, SrcId,
|
||||
SrcIdOwned,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Track {
|
||||
pub id: i32,
|
||||
pub src_id: String,
|
||||
|
|
@ -12,11 +20,11 @@ pub struct Track {
|
|||
pub name: String,
|
||||
pub duration: Option<i32>,
|
||||
pub duration_ms: bool,
|
||||
pub artists: Vec<ArtistId>,
|
||||
pub size: Option<i64>,
|
||||
pub loudness: Option<f32>,
|
||||
pub album_id: i32,
|
||||
pub album_pos: Option<i16>,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
pub isrc: Option<String>,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub updated_at: PrimitiveDateTime,
|
||||
|
|
@ -25,8 +33,29 @@ pub struct Track {
|
|||
pub n_streams: i32,
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct TrackRow {
|
||||
id: i32,
|
||||
src_id: String,
|
||||
service: MusicService,
|
||||
name: String,
|
||||
duration: Option<i32>,
|
||||
duration_ms: bool,
|
||||
size: Option<i64>,
|
||||
loudness: Option<f32>,
|
||||
album_id: i32,
|
||||
album_pos: Option<i16>,
|
||||
artists: Option<Json<Vec<ArtistJsonb>>>,
|
||||
ul_artists: Option<Vec<String>>,
|
||||
isrc: Option<String>,
|
||||
created_at: PrimitiveDateTime,
|
||||
updated_at: PrimitiveDateTime,
|
||||
downloaded_at: Option<PrimitiveDateTime>,
|
||||
last_streamed_at: Option<PrimitiveDateTime>,
|
||||
n_streams: i32,
|
||||
}
|
||||
|
||||
/// Data for creating a track
|
||||
#[derive(Debug)]
|
||||
pub struct TrackNew {
|
||||
pub src_id: String,
|
||||
pub service: MusicService,
|
||||
|
|
@ -42,7 +71,7 @@ pub struct TrackNew {
|
|||
}
|
||||
|
||||
/// Data for updating a track
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Default)]
|
||||
pub struct TrackUpdate {
|
||||
pub name: Option<String>,
|
||||
pub duration: Option<Option<i32>>,
|
||||
|
|
@ -58,6 +87,7 @@ pub struct TrackUpdate {
|
|||
pub n_streams: Option<i32>,
|
||||
}
|
||||
|
||||
/// Track item (for display)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TrackSlim {
|
||||
pub src_id: String,
|
||||
|
|
@ -68,3 +98,639 @@ pub struct TrackSlim {
|
|||
pub album: AlbumId,
|
||||
pub album_pos: Option<i16>,
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub struct TrackSlimRow {
|
||||
pub src_id: String,
|
||||
pub service: MusicService,
|
||||
pub name: String,
|
||||
pub duration: Option<i32>,
|
||||
pub album_pos: Option<i16>,
|
||||
pub artists: Option<Json<Vec<ArtistJsonb>>>,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
|
||||
// Album data
|
||||
pub album_src_id: String,
|
||||
pub album_service: MusicService,
|
||||
pub album_name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub release_date: Option<Date>,
|
||||
pub album_type: AlbumType,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn id(&self) -> Id {
|
||||
Id::Db(self.id)
|
||||
}
|
||||
|
||||
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
match id {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
TrackRow,
|
||||
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
|
||||
t.size, t.loudness, t.album_id, t.album_pos, t.ul_artists, t.isrc, t.created_at, t.updated_at,
|
||||
t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from tracks t
|
||||
left join artists_tracks art on art.track_id = t.id
|
||||
left join artists a on a.id = art.artist_id
|
||||
where t.id=$1
|
||||
group by t.id"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
}
|
||||
Id::Src(src_id, srv) => {
|
||||
let res = sqlx::query_as!(
|
||||
TrackRow,
|
||||
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
|
||||
t.size, t.loudness, t.album_id, t.album_pos, t.ul_artists, t.isrc, t.created_at, t.updated_at,
|
||||
t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from tracks t
|
||||
left join artists_tracks art on art.track_id = t.id
|
||||
left join artists a on a.id = art.artist_id
|
||||
where t.src_id=$1 and t.service=$2
|
||||
group by t.id"#,
|
||||
src_id,
|
||||
srv as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
// Query aliases if the track was not found
|
||||
match res {
|
||||
Some(res) => Ok(Some(res)),
|
||||
None => {
|
||||
sqlx::query_as!(
|
||||
TrackRow,
|
||||
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
|
||||
t.size, t.loudness, t.album_id, t.album_pos, t.ul_artists, t.isrc, t.created_at, t.updated_at,
|
||||
t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from tracks t
|
||||
left join artists_tracks art on art.track_id = t.id
|
||||
left join artists a on a.id = art.artist_id
|
||||
join track_aliases ta on ta.track_id=t.id
|
||||
where ta.src_id=$1 and ta.service=$2
|
||||
group by t.id"#,
|
||||
src_id,
|
||||
srv as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))
|
||||
.map(Track::from)
|
||||
}
|
||||
|
||||
async fn _get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
Ok(sqlx::query!(
|
||||
r#"select id from tracks where src_id=$1 and service=$2"#,
|
||||
id.0,
|
||||
id.1 as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?
|
||||
.map(|r| r.id))
|
||||
}
|
||||
|
||||
async fn _get_id_alias<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
Ok(sqlx::query!(
|
||||
r#"select t.id from tracks t
|
||||
join track_aliases ta on ta.track_id=t.id
|
||||
where ta.src_id=$1 and ta.service=$2"#,
|
||||
id.0,
|
||||
id.1 as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?
|
||||
.map(|r| r.id))
|
||||
}
|
||||
|
||||
pub async fn get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
// Query aliases if the artist was not found
|
||||
match Self::_get_id(id, exec).await? {
|
||||
Some(id) => Ok(Some(id)),
|
||||
None => Self::_get_id_alias(id, exec).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_id_tx(
|
||||
id: SrcId<'_>,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<Option<i32>, DatabaseError> {
|
||||
// Query aliases if the artist was not found
|
||||
match Self::_get_id(id, &mut **tx).await? {
|
||||
Some(id) => Ok(Some(id)),
|
||||
None => Self::_get_id_alias(id, &mut **tx).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_src_id<'a, E>(id: i32, exec: E) -> Result<Option<SrcIdOwned>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"select src_id, service as "service: MusicService" from tracks where id=$1"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
Ok(res.map(|res| SrcIdOwned(res.src_id, res.service)))
|
||||
}
|
||||
|
||||
pub async fn set_artists(
|
||||
id: i32,
|
||||
artists: &[Id<'_>],
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(r#"delete from artists_tracks where track_id=$1"#, id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for artist_id in artists {
|
||||
let artist_id = Artist::resolve_id(*artist_id, tx).await?;
|
||||
sqlx::query!(
|
||||
r#"insert into artists_tracks (track_id, artist_id) values ($1, $2)"#,
|
||||
id,
|
||||
artist_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete<'a, E>(id: i32, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(r#"delete from tracks where id=$1"#, id)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_id(
|
||||
id: Id<'_>,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<i32, DatabaseError> {
|
||||
match id {
|
||||
Id::Db(id) => Ok(id),
|
||||
Id::Src(src_id, srv) => {
|
||||
let srcid = SrcId(src_id, srv);
|
||||
Self::get_id_tx(srcid, tx)
|
||||
.await?
|
||||
.ok_or_else(|| DatabaseError::NotFound(srcid.to_owned_id()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_alias(
|
||||
id: Id<'_>,
|
||||
alias: SrcId<'_>,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let artist_id = Self::resolve_id(id, tx).await?;
|
||||
|
||||
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"#,
|
||||
alias.0,
|
||||
alias.1 as MusicService,
|
||||
artist_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackNew {
|
||||
pub async fn insert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into tracks (src_id, service, name, duration, duration_ms,
|
||||
size, loudness, album_id, album_pos, ul_artists, isrc)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as MusicService,
|
||||
self.name,
|
||||
self.duration,
|
||||
self.duration_ms,
|
||||
self.size,
|
||||
self.loudness,
|
||||
self.album_id,
|
||||
self.album_pos,
|
||||
self.ul_artists.as_deref(),
|
||||
self.isrc,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
Ok(res.id)
|
||||
}
|
||||
|
||||
pub async fn upsert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into tracks (src_id, service, name, duration, duration_ms,
|
||||
size, loudness, album_id, album_pos, ul_artists, isrc)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
on conflict (src_id, service) do update set
|
||||
name = excluded.name,
|
||||
duration = coalesce(excluded.duration, tracks.duration),
|
||||
duration_ms = excluded.duration_ms,
|
||||
size = coalesce(excluded.size, tracks.size),
|
||||
loudness = coalesce(excluded.loudness, tracks.loudness),
|
||||
album_id = excluded.album_id,
|
||||
album_pos = coalesce(excluded.album_pos, tracks.album_pos),
|
||||
ul_artists = coalesce(excluded.ul_artists, tracks.ul_artists),
|
||||
isrc = coalesce(excluded.isrc, tracks.isrc)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as MusicService,
|
||||
self.name,
|
||||
self.duration,
|
||||
self.duration_ms,
|
||||
self.size,
|
||||
self.loudness,
|
||||
self.album_id,
|
||||
self.album_pos,
|
||||
self.ul_artists.as_deref(),
|
||||
self.isrc,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
Ok(res.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackUpdate {
|
||||
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let mut query = QueryBuilder::new("update tracks set ");
|
||||
let mut n = 0;
|
||||
|
||||
if let Some(name) = &self.name {
|
||||
query.push("name=");
|
||||
query.push_bind(name);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(duration) = &self.duration {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("duration=");
|
||||
query.push_bind(duration);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(duration_ms) = &self.duration_ms {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("duration_ms=");
|
||||
query.push_bind(duration_ms);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(size) = &self.size {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("size=");
|
||||
query.push_bind(size);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(loudness) = &self.loudness {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("loudness=");
|
||||
query.push_bind(loudness);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(album_id) = &self.album_id {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("album_id=");
|
||||
query.push_bind(album_id);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(album_pos) = &self.album_pos {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("album_pos=");
|
||||
query.push_bind(album_pos);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(ul_artists) = &self.ul_artists {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("ul_artists=");
|
||||
query.push_bind(ul_artists);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(isrc) = &self.isrc {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("isrc=");
|
||||
query.push_bind(isrc);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(downloaded_at) = &self.downloaded_at {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("downloaded_at=");
|
||||
query.push_bind(downloaded_at);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(last_streamed_at) = &self.last_streamed_at {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("last_streamed_at=");
|
||||
query.push_bind(last_streamed_at);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(n_streams) = &self.n_streams {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("n_streams=");
|
||||
query.push_bind(n_streams);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
match id {
|
||||
Id::Db(id) => {
|
||||
query.push("id=");
|
||||
query.push_bind(id);
|
||||
}
|
||||
Id::Src(src_id, srv) => {
|
||||
query.push("src_id=");
|
||||
query.push_bind(src_id);
|
||||
query.push(" and service=");
|
||||
query.push_bind(srv);
|
||||
}
|
||||
}
|
||||
|
||||
query.build().execute(exec).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackSlim {
|
||||
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let row: TrackSlimRow =
|
||||
match id {
|
||||
Id::Db(id) => sqlx::query_as!(
|
||||
TrackSlimRow,
|
||||
r#"select t.src_id, t.service as "service: _", t.name, t.duration, t.album_pos,
|
||||
b.src_id as album_src_id, b.name as album_name, b.service as "album_service: _",
|
||||
b.image_url, b.image_hash, b.release_date, b.album_type as "album_type: _",
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _",
|
||||
t.ul_artists
|
||||
from tracks t
|
||||
left join artists_tracks art on art.track_id = t.id
|
||||
left join artists a on a.id = art.artist_id
|
||||
join albums b on b.id = t.album_id
|
||||
where t.id=$1
|
||||
group by (t.id, b.id)"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await,
|
||||
Id::Src(src_id, srv) => sqlx::query_as!(
|
||||
TrackSlimRow,
|
||||
r#"select t.src_id, t.service as "service: _", t.name, t.duration, t.album_pos,
|
||||
b.src_id as album_src_id, b.name as album_name, b.service as "album_service: _",
|
||||
b.image_url, b.image_hash, b.release_date, b.album_type as "album_type: _",
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _",
|
||||
t.ul_artists
|
||||
from tracks t
|
||||
left join artists_tracks art on art.track_id = t.id
|
||||
left join artists a on a.id = art.artist_id
|
||||
join albums b on b.id = t.album_id
|
||||
where t.src_id=$1 and t.service=$2
|
||||
group by (t.id, b.id)"#,
|
||||
src_id,
|
||||
srv as MusicService
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await,
|
||||
}
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))?;
|
||||
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Option<Self>>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
stream::iter(ids)
|
||||
.map(|id| async move { TrackSlim::get(Id::Db(*id), exec).await.to_optional() })
|
||||
.buffered(DB_CONCURRENCY)
|
||||
.try_collect()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TrackRow> for Track {
|
||||
fn from(value: TrackRow) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
src_id: value.src_id,
|
||||
service: value.service,
|
||||
name: value.name,
|
||||
duration: value.duration,
|
||||
duration_ms: value.duration_ms,
|
||||
artists: map_artists(value.artists, value.ul_artists),
|
||||
size: value.size,
|
||||
loudness: value.loudness,
|
||||
album_id: value.album_id,
|
||||
album_pos: value.album_pos,
|
||||
isrc: value.isrc,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
downloaded_at: value.downloaded_at,
|
||||
last_streamed_at: value.last_streamed_at,
|
||||
n_streams: value.n_streams,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TrackSlimRow> for TrackSlim {
|
||||
fn from(value: TrackSlimRow) -> Self {
|
||||
Self {
|
||||
src_id: value.src_id,
|
||||
service: value.service,
|
||||
name: value.name,
|
||||
duration: value.duration,
|
||||
artists: map_artists(value.artists, value.ul_artists),
|
||||
album: AlbumId {
|
||||
src_id: value.album_src_id,
|
||||
service: value.album_service,
|
||||
name: value.album_name,
|
||||
release_date: value.release_date,
|
||||
album_type: value.album_type,
|
||||
image_url: value.image_url,
|
||||
image_hash: value.image_hash,
|
||||
},
|
||||
album_pos: value.album_pos,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testutil::{self, ids};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn crud() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let track = TrackNew {
|
||||
src_id: "g0iRiJ_ck48".to_owned(),
|
||||
service: MusicService::YouTube,
|
||||
name: "Aulë und Yavanna".to_owned(),
|
||||
duration: Some(216000),
|
||||
duration_ms: false,
|
||||
size: Some(3_439_414),
|
||||
loudness: Some(6.1513805),
|
||||
album_id: 1,
|
||||
album_pos: Some(1),
|
||||
ul_artists: Some(vec!["Other artist".to_owned()]),
|
||||
isrc: Some("DEUM71602459".to_owned()),
|
||||
};
|
||||
let track_artists = [ids::ARTIST_LEA, ids::ARTIST_CYRIL];
|
||||
|
||||
// Create
|
||||
let mut c_tx = pool.begin().await.unwrap();
|
||||
let new_id = track.insert(&mut *c_tx).await.unwrap();
|
||||
let id = Id::Db(new_id);
|
||||
Track::set_artists(new_id, &track_artists, &mut c_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
c_tx.commit().await.unwrap();
|
||||
|
||||
// Request
|
||||
let inserted = Track::get(id, &pool).await.unwrap();
|
||||
assert_eq!(inserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", inserted, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = SrcId(&track.src_id, track.service);
|
||||
assert_eq!(
|
||||
Track::get_src_id(new_id, &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_srcid(),
|
||||
srcid
|
||||
);
|
||||
assert_eq!(Track::get_id(srcid, &pool).await.unwrap().unwrap(), new_id);
|
||||
|
||||
let slim = TrackSlim::get(id, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("slim", slim);
|
||||
|
||||
// Update
|
||||
let mut u_tx = pool.begin().await.unwrap();
|
||||
let clear = TrackUpdate {
|
||||
name: Some("empty".to_owned()),
|
||||
duration: Some(None),
|
||||
duration_ms: Some(false),
|
||||
size: Some(None),
|
||||
loudness: Some(None),
|
||||
album_id: None,
|
||||
album_pos: Some(None),
|
||||
ul_artists: Some(vec![]),
|
||||
isrc: Some(None),
|
||||
downloaded_at: Some(None),
|
||||
last_streamed_at: Some(None),
|
||||
n_streams: Some(0),
|
||||
};
|
||||
clear.update(id, &mut *u_tx).await.unwrap();
|
||||
Track::set_artists(new_id, &[], &mut u_tx).await.unwrap();
|
||||
u_tx.commit().await.unwrap();
|
||||
|
||||
let got_empty = Track::get(id, &pool).await.unwrap();
|
||||
assert_eq!(got_empty.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_empty", got_empty, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
// Upsert
|
||||
let mut us_tx = pool.begin().await.unwrap();
|
||||
let ups_id = track.upsert(&mut *us_tx).await.unwrap();
|
||||
assert_eq!(ups_id, new_id);
|
||||
Track::set_artists(new_id, &track_artists, &mut us_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
us_tx.commit().await.unwrap();
|
||||
|
||||
let upserted = Track::get(id, &pool).await.unwrap();
|
||||
assert_eq!(upserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", upserted, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
assert!(upserted.updated_at > inserted.updated_at);
|
||||
|
||||
// Delete
|
||||
Track::delete(new_id, &pool).await.unwrap();
|
||||
assert!(Track::get_src_id(new_id, &pool).await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
crates/db/src/models/types.rs
Normal file
83
crates/db/src/models/types.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "music_service")]
|
||||
pub enum MusicService {
|
||||
#[serde(rename = "yt")]
|
||||
#[sqlx(rename = "yt")]
|
||||
YouTube,
|
||||
#[serde(rename = "ty")]
|
||||
#[sqlx(rename = "ty")]
|
||||
Tiraya,
|
||||
#[serde(rename = "sp")]
|
||||
#[sqlx(rename = "sp")]
|
||||
Spotify,
|
||||
#[serde(rename = "mx")]
|
||||
#[sqlx(rename = "mx")]
|
||||
Musixmatch,
|
||||
}
|
||||
|
||||
serde_plain::derive_display_from_serialize!(MusicService);
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "album_type", rename_all = "snake_case")]
|
||||
pub enum AlbumType {
|
||||
Album,
|
||||
Single,
|
||||
Ep,
|
||||
Mv,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "date_precision", rename_all = "snake_case")]
|
||||
pub enum DatePrecision {
|
||||
Day,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "playlist_type", rename_all = "snake_case")]
|
||||
pub enum PlaylistType {
|
||||
/// Local playlist created by a Tiraya user
|
||||
Local,
|
||||
/// Remote playlist hosted by another service
|
||||
Remote,
|
||||
/// User's favorite tracks
|
||||
Favorites,
|
||||
/// Artist's tracks
|
||||
ArtistTracks,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "playlist_img_type", rename_all = "snake_case")]
|
||||
pub enum PlaylistImgType {
|
||||
/// Single album cover
|
||||
Album,
|
||||
/// Mosaic of multiple album covers
|
||||
Mosaic,
|
||||
/// Custom image
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncError {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncData {
|
||||
/// Version ID of the synchronized item
|
||||
Version(String),
|
||||
/// Update date of the synchronized item
|
||||
Date(PrimitiveDateTime),
|
||||
/// Error during sync
|
||||
Error(SyncError),
|
||||
}
|
||||
|
|
@ -3,25 +3,48 @@ use std::path::PathBuf;
|
|||
use once_cell::sync::Lazy;
|
||||
use path_macro::path;
|
||||
|
||||
use crate::models::{PlaylistEntry, SrcId};
|
||||
|
||||
pub static TESTDATA: Lazy<PathBuf> = Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "testdata"));
|
||||
|
||||
/// Testdata IDs
|
||||
pub mod ids {
|
||||
use uuid::{uuid, Uuid};
|
||||
|
||||
use crate::models::{Id, MusicService, SrcId};
|
||||
|
||||
pub const ARTIST_ID_LEA: i32 = 1;
|
||||
pub const ARTIST_ID_CYRIL: i32 = 5;
|
||||
pub const ARTIST_SRC_LEA: SrcId = SrcId("UC_MxOdawj_BStPs4CKBYD0Q", MusicService::Youtube);
|
||||
pub const ARTIST_SRC_CYRIL: SrcId = SrcId("UCZEPIcm6KGYyqWUMaP2axSA", MusicService::Youtube);
|
||||
pub const ARTIST_SRC_LEA: SrcId = SrcId("UC_MxOdawj_BStPs4CKBYD0Q", MusicService::YouTube);
|
||||
pub const ARTIST_SRC_CYRIL: SrcId = SrcId("UCZEPIcm6KGYyqWUMaP2axSA", MusicService::YouTube);
|
||||
pub const ARTIST_LEA: Id = Id::Db(ARTIST_ID_LEA);
|
||||
pub const ARTIST_CYRIL: Id = Id::Db(ARTIST_ID_CYRIL);
|
||||
|
||||
pub const ALBUM_ID_VAKUUM: i32 = 1;
|
||||
pub const ALBUM_ID_DSMDW: i32 = 2;
|
||||
pub const ALBUM_SRC_VAKUUM: SrcId = SrcId("MPREb_9VuoAuPU5NR", MusicService::Youtube);
|
||||
pub const ALBUM_SRC_DSMDW: SrcId = SrcId("MPREb_2Kv3bcPa6zp", MusicService::Youtube);
|
||||
pub const ALBUM_ID_IWWUS: i32 = 2;
|
||||
pub const ALBUM_ID_DSMDW: i32 = 3;
|
||||
pub const ALBUM_SRC_VAKUUM: SrcId = SrcId("MPREb_9VuoAuPU5NR", MusicService::YouTube);
|
||||
pub const ALBUM_SRC_IWWUS: SrcId = SrcId("MPREb_2Kv3bcPa6zp", MusicService::YouTube);
|
||||
pub const ALBUM_SRC_DSMDW: SrcId = SrcId("MPREb_uRzxugVdRXQ", MusicService::YouTube);
|
||||
pub const ALBUM_VAKUUM: Id = Id::Db(ALBUM_ID_VAKUUM);
|
||||
pub const ALBUM_DSMDW: Id = Id::Db(ALBUM_ID_DSMDW);
|
||||
pub const ALBUM_IWWUS: Id = Id::Db(ALBUM_ID_IWWUS);
|
||||
pub const ALBUM_DSMDW: Id = Id::Db(ALBUM_ID_IWWUS);
|
||||
|
||||
pub const TRACK_ID_VAKUUM: i32 = 6;
|
||||
pub const TRACK_SRC_VAKUUM: SrcId = SrcId("LeEgBsYfjLU", MusicService::YouTube);
|
||||
pub const TRACK_VAKUUM: Id = Id::Db(TRACK_ID_VAKUUM);
|
||||
|
||||
pub const PLAYLIST_ID_TEST1: i32 = 3;
|
||||
pub const PLAYLIST_SRC_TEST1: SrcId = SrcId("test1", MusicService::Tiraya);
|
||||
pub const PLAYLIST_TEST1: Id = Id::Db(PLAYLIST_ID_TEST1);
|
||||
|
||||
pub const PLAYLIST_CHANGE_ROOT: Uuid = uuid!("d67691dd-6cda-4196-9488-93e65d486fc9");
|
||||
pub const PLAYLIST_CHANGE_HEAD: Uuid = uuid!("99bdde14-c1d5-ef0b-7bdc-218da3b1364b");
|
||||
pub const PLAYLIST_CHANGE_I3: Uuid = uuid!("0d9aab74-deb3-456e-afdc-c4f32413aaea");
|
||||
pub const PLAYLIST_CHANGE_BR1: Uuid = uuid!("dee860f5-7f18-4157-97c4-28cfcbbce8c6");
|
||||
pub const PLAYLIST_CHANGE_BR2: Uuid = uuid!("ff948c8d-06ec-478d-9d22-845331f969df");
|
||||
pub const PLAYLIST_CHANGE_BR1T: Uuid = uuid!("2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec");
|
||||
pub const PLAYLIST_CHANGE_BR2T: Uuid = uuid!("d2f0e2f4-d7a6-568a-19fc-9752ed80d7e4");
|
||||
}
|
||||
|
||||
/// Run a SQL script
|
||||
|
|
@ -33,3 +56,11 @@ where
|
|||
let s = std::fs::read_to_string(p).expect("reading sql");
|
||||
exec.execute(&*s).await.expect("running sql");
|
||||
}
|
||||
|
||||
/// Compare playlist entries
|
||||
pub fn compare_pl_entries(entries: &[PlaylistEntry], ids: &[SrcId]) -> bool {
|
||||
if entries.len() != ids.len() {
|
||||
return false;
|
||||
}
|
||||
entries.iter().zip(ids).all(|(a, b)| a.id == *b)
|
||||
}
|
||||
|
|
|
|||
57
crates/db/src/util.rs
Normal file
57
crates/db/src/util.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use std::hash::Hasher;
|
||||
|
||||
use siphasher::sip128::{Hasher128, SipHasher};
|
||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const DB_CONCURRENCY: usize = 8;
|
||||
|
||||
pub fn primitive_now() -> PrimitiveDateTime {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
PrimitiveDateTime::new(now.date(), now.time())
|
||||
}
|
||||
|
||||
/// Deterministically created UUIDs for merge operations
|
||||
#[derive(Debug)]
|
||||
pub struct MergeIds {
|
||||
pub op1: Uuid,
|
||||
pub op2: Uuid,
|
||||
pub merge: Uuid,
|
||||
}
|
||||
|
||||
const KEY_MERGE_IDS: [u8; 16] = [
|
||||
0x01, 0x4a, 0x29, 0x0a, 0x04, 0x64, 0x35, 0xe9, 0x54, 0xef, 0x88, 0x98, 0xcd, 0x83, 0x64, 0x95,
|
||||
];
|
||||
|
||||
impl MergeIds {
|
||||
pub fn new(id1: Uuid, id2: Uuid) -> Self {
|
||||
let h_change = SipHasher::new_with_key(&KEY_MERGE_IDS);
|
||||
let mut h_merge = SipHasher::new_with_key(&KEY_MERGE_IDS);
|
||||
if id1 > id2 {
|
||||
h_merge.write(id2.as_bytes());
|
||||
h_merge.write(id1.as_bytes());
|
||||
} else {
|
||||
h_merge.write(id1.as_bytes());
|
||||
h_merge.write(id2.as_bytes());
|
||||
}
|
||||
|
||||
Self {
|
||||
op1: Uuid::from_bytes(h_change.hash(id1.as_bytes()).as_bytes()),
|
||||
op2: Uuid::from_bytes(h_change.hash(id2.as_bytes()).as_bytes()),
|
||||
merge: Uuid::from_bytes(h_merge.finish128().as_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::ids;
|
||||
|
||||
#[test]
|
||||
fn merge_ids() {
|
||||
let ids = MergeIds::new(ids::PLAYLIST_CHANGE_BR1, ids::PLAYLIST_CHANGE_BR2);
|
||||
let ids_swap = MergeIds::new(ids::PLAYLIST_CHANGE_BR2, ids::PLAYLIST_CHANGE_BR1);
|
||||
assert_eq!(ids.merge, ids_swap.merge);
|
||||
}
|
||||
}
|
||||
132
crates/db/testdata/base.sql
vendored
132
crates/db/testdata/base.sql
vendored
|
|
@ -1,50 +1,92 @@
|
|||
INSERT INTO public.artists (id,src_id,service,"name",description,image_url,image_hash,header_image_url,header_image_hash,subscribers,wikipedia_url,created_at,updated_at,last_sync_at,last_sync_data,playlist_id,related_artists,related_playlists,top_tracks) VALUES
|
||||
(1,'UC_MxOdawj_BStPs4CKBYD0Q','youtube','LEA','Lea-Marie Becker (born 9 July 1992[1]), known professionally as Lea (stylized as LEA), is a German singer-songwriter and keyboardist.','https://lh3.googleusercontent.com/i9BsBnDNnyyTwGcdjPPebF7buq7cQP1xzre99bAMEc01aPBRyNkFyBXalT7vm_G3xKjiHn9l3IPxuHw=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/i9BsBnDNnyyTwGcdjPPebF7buq7cQP1xzre99bAMEc01aPBRyNkFyBXalT7vm_G3xKjiHn9l3IPxuHw=w1920-h800-p-l90-rj',NULL,607000,'https://en.wikipedia.org/wiki/Lea_(musician)','2023-08-30 19:33:10.624701','2023-08-30 22:54:27.429987',NULL,NULL,NULL,'{2,3,4}','{2}',NULL),
|
||||
(2,'UCpJyCbFbdTrx0M90HCNBHFQ','youtube','Madeline Juno','Madeline Obrigewitsch, better known by her stage name Madeline Juno, is a German singer-songwriter. She released her first single "Error" in November 2013.','https://lh3.googleusercontent.com/HqCLfHryo1bEwL99KJgn909ft_O-YZaJlHgSa3X9p4Yk8RJ3CnxlGgFLPVpdSPbTeqSFnsAyVQ=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/HqCLfHryo1bEwL99KJgn909ft_O-YZaJlHgSa3X9p4Yk8RJ3CnxlGgFLPVpdSPbTeqSFnsAyVQ=w1365-h568-p-l90-rj',NULL,162000,'https://en.wikipedia.org/wiki/Madeline_Juno','2023-08-30 22:08:24.946177','2023-08-30 22:54:27.452299',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
(3,'UCkQRXVZuBMktEdVyptoUgGg','youtube','Mark Forster','Mark Ćwiertnia, known professionally as Mark Forster, is a German-Polish singer, songwriter and television personality.','https://lh3.googleusercontent.com/RIRL6gRqXnOOAXcSh2pXkRPyuCVzL5PcQvvLrJLDpz3L0XhHo9nlj2ewnwKHwNZ82jjgh9JXPcOvO9Y=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/RIRL6gRqXnOOAXcSh2pXkRPyuCVzL5PcQvvLrJLDpz3L0XhHo9nlj2ewnwKHwNZ82jjgh9JXPcOvO9Y=w1920-h800-p-l90-rj',NULL,855000,'https://en.wikipedia.org/wiki/Mark_Forster_(singer)','2023-08-30 22:05:55.350782','2023-08-30 22:54:27.446367',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
(4,'UCA-uIWGyE0n9YvJ-titY8zA','youtube','LOTTE',NULL,'https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w1920-h800-p-l90-rj',NULL,82700,NULL,'2023-08-30 22:06:53.912354','2023-08-30 22:54:27.448493',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
(5,'UCZEPIcm6KGYyqWUMaP2axSA','youtube','Cyril aka Aaron Hilmer',NULL,'https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w1425-h593-p-l90-rj',NULL,4380,NULL,'2023-08-30 22:53:50.360159','2023-08-30 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL);
|
||||
TRUNCATE TABLE tracks, albums, artists, playlists CASCADE;
|
||||
|
||||
INSERT INTO albums (id,src_id,service,"name",release_date,release_date_precision,"album_type",ul_artists,by_va,image_url,image_hash,created_at,updated_at,dirty,hidden) VALUES
|
||||
(1,'MPREb_9VuoAuPU5NR','youtube','Vakuum','2016-04-22','day','album','{}',false,'https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj',NULL,'2023-08-30 22:37:08.895301','2023-08-30 22:38:10.919891',false,false),
|
||||
(2,'MPREb_2Kv3bcPa6zp','youtube','Immer wenn wir uns sehn ("Das schönste Mädchen der Welt", Soundtrack)','2018-08-17','day','single','{}',false,'https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj',NULL,'2023-08-30 22:58:36.61372','2023-08-30 22:58:36.61372',true,false);
|
||||
INSERT INTO playlists (src_id,service,"name",description,owner_name,owner_url,"playlist_type",image_url,image_hash,image_type,created_at,updated_at,last_sync_at,last_sync_data) VALUES
|
||||
('RDCLAK5uy_nCUL5fa0G5mSAxmXU9tu4uGM1SoZ44OPA','yt','Happy German Pop','These German music gems transport you directly to cloud 9.','YouTube Music',NULL,'remote','https://lh3.googleusercontent.com/Mx7jwNSTNbl7WGboxuxwJg-W2Bj059MT0WoYVFN5ml477zUyjyaTXLOca1gMgS1VdvtXAQDncPhRwAk=w544-h544-l90-rj',NULL,'custom','2023-08-30 22:22:32.215282','2023-08-30 22:23:05.596028',NULL,NULL),
|
||||
(NULL,NULL,NULL,NULL,NULL,NULL,'artist_tracks',NULL,NULL,NULL,'2023-08-30 22:23:45.157936','2023-08-30 22:24:09.541205',NULL,NULL),
|
||||
('test1','ty','test1',NULL,NULL,NULL,'local',NULL,NULL,NULL,'2023-08-30 22:22:32.215282','2023-08-30 22:23:05.596028',NULL,NULL);
|
||||
|
||||
INSERT INTO public.artists_albums (artist_id,album_id) VALUES
|
||||
(1,1),
|
||||
(1,2),
|
||||
(5,2);
|
||||
INSERT INTO artists (src_id,service,"name",description,image_url,image_hash,header_image_url,header_image_hash,subscribers,wikipedia_url,created_at,updated_at,last_sync_at,last_sync_data,playlist_id,related_artists,related_playlists,top_tracks) VALUES
|
||||
('UC_MxOdawj_BStPs4CKBYD0Q','yt','LEA','Lea-Marie Becker (born 9 July 1992[1]), known professionally as Lea (stylized as LEA), is a German singer-songwriter and keyboardist.','https://lh3.googleusercontent.com/i9BsBnDNnyyTwGcdjPPebF7buq7cQP1xzre99bAMEc01aPBRyNkFyBXalT7vm_G3xKjiHn9l3IPxuHw=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/i9BsBnDNnyyTwGcdjPPebF7buq7cQP1xzre99bAMEc01aPBRyNkFyBXalT7vm_G3xKjiHn9l3IPxuHw=w1920-h800-p-l90-rj',NULL,607000,'https://en.wikipedia.org/wiki/Lea_(musician)','2023-08-30 19:33:10.624701','2023-08-30 22:54:27.429987',NULL,NULL,2,'{2,3,4}','{1}','{6,9,10,13,1}'),
|
||||
('UCpJyCbFbdTrx0M90HCNBHFQ','yt','Madeline Juno','Madeline Obrigewitsch, better known by her stage name Madeline Juno, is a German singer-songwriter. She released her first single "Error" in November 2013.','https://lh3.googleusercontent.com/HqCLfHryo1bEwL99KJgn909ft_O-YZaJlHgSa3X9p4Yk8RJ3CnxlGgFLPVpdSPbTeqSFnsAyVQ=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/HqCLfHryo1bEwL99KJgn909ft_O-YZaJlHgSa3X9p4Yk8RJ3CnxlGgFLPVpdSPbTeqSFnsAyVQ=w1365-h568-p-l90-rj',NULL,162000,'https://en.wikipedia.org/wiki/Madeline_Juno','2023-08-30 22:08:24.946177','2023-08-30 22:54:27.452299',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCkQRXVZuBMktEdVyptoUgGg','yt','Mark Forster','Mark Ćwiertnia, known professionally as Mark Forster, is a German-Polish singer, songwriter and television personality.','https://lh3.googleusercontent.com/RIRL6gRqXnOOAXcSh2pXkRPyuCVzL5PcQvvLrJLDpz3L0XhHo9nlj2ewnwKHwNZ82jjgh9JXPcOvO9Y=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/RIRL6gRqXnOOAXcSh2pXkRPyuCVzL5PcQvvLrJLDpz3L0XhHo9nlj2ewnwKHwNZ82jjgh9JXPcOvO9Y=w1920-h800-p-l90-rj',NULL,855000,'https://en.wikipedia.org/wiki/Mark_Forster_(singer)','2023-08-30 22:05:55.350782','2023-08-30 22:54:27.446367',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCA-uIWGyE0n9YvJ-titY8zA','yt','LOTTE',NULL,'https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w1920-h800-p-l90-rj',NULL,82700,NULL,'2023-08-30 22:06:53.912354','2023-08-30 22:54:27.448493',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCZEPIcm6KGYyqWUMaP2axSA','yt','Cyril aka Aaron Hilmer',NULL,'https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w1425-h593-p-l90-rj',NULL,4380,NULL,'2023-08-30 22:53:50.360159','2023-08-30 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCEdZAdnnKqbaHOlv8nM6OtA','yt','aespa',NULL,'TODO',NULL,'TODO',NULL,4780000,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCQ6yypykkyPLM5FVhOm4Eog','yt','Dabin',NULL,'TODO',NULL,'TODO',NULL,63200,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCIh4j8fXWf2U0ro0qnGU8Mg','yt','Namika',NULL,'TODO',NULL,'TODO',NULL,753000,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UC-2mb3G26qV676d-iXbOTVQ','yt','LINA',NULL,'TODO',NULL,'TODO',NULL,407000,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL);
|
||||
|
||||
INSERT INTO tracks (id,src_id,service,"name",duration,duration_ms,"size",loudness,album_pos,album_id,ul_artists,isrc,created_at,updated_at,primary_track,downloaded_at,last_streamed_at,n_streams) VALUES
|
||||
(1,'2txScm52-QI','youtube','Die Segel sind gesetzt',186000,false,NULL,NULL,1,1,'{}',NULL,'2023-08-30 22:40:30.15406','2023-08-30 22:49:15.955139',NULL,NULL,NULL,0),
|
||||
(2,'oZKv47vyqQU','youtube','Monster',224000,false,NULL,NULL,2,1,'{}',NULL,'2023-08-30 22:41:22.905632','2023-08-30 22:49:15.957959',NULL,NULL,NULL,0),
|
||||
(3,'7WXlMU9ItnA','youtube','Dach',231000,false,NULL,NULL,3,1,'{}',NULL,'2023-08-30 22:42:00.093845','2023-08-30 22:49:15.959538',NULL,NULL,NULL,0),
|
||||
(4,'ySChj_9rT5Y','youtube','Kennst du das',191000,false,NULL,NULL,4,1,'{}',NULL,'2023-08-30 22:42:57.462304','2023-08-30 22:49:15.961008',NULL,NULL,NULL,0),
|
||||
(5,'revpIT2HiNs','youtube','Wohin willst du',255000,false,NULL,NULL,5,1,'{}',NULL,'2023-08-30 22:43:39.594609','2023-08-30 22:49:15.962497',NULL,NULL,NULL,0),
|
||||
(6,'LeEgBsYfjLU','youtube','Vakuum',220000,false,NULL,NULL,6,1,'{}',NULL,'2023-08-30 22:44:14.749107','2023-08-30 22:49:15.963926',NULL,NULL,NULL,0),
|
||||
(7,'-i5XjMkQN8M','youtube','Melodie',251000,false,NULL,NULL,7,1,'{}',NULL,'2023-08-30 22:44:44.585095','2023-08-30 22:49:15.96526',NULL,NULL,NULL,0),
|
||||
(8,'DhlIZkoPsxg','youtube','Du & Ich',238000,false,NULL,NULL,8,1,'{}',NULL,'2023-08-30 22:45:20.711259','2023-08-30 22:49:15.966532',NULL,NULL,NULL,0),
|
||||
(9,'LCoomBMOkgU','youtube','Schwerelos',198000,false,NULL,NULL,9,1,'{}',NULL,'2023-08-30 22:46:13.885768','2023-08-30 22:49:15.967865',NULL,NULL,NULL,0),
|
||||
(10,'c6Ot-Z3HEBo','youtube','Lichtermeer',164000,false,NULL,NULL,10,1,'{}',NULL,'2023-08-30 22:46:41.10766','2023-08-30 22:49:15.969191',NULL,NULL,NULL,0),
|
||||
(11,'ybm_4hQG0ok','youtube','Nachtzug',290000,false,NULL,NULL,11,1,'{}',NULL,'2023-08-30 22:47:28.033803','2023-08-30 22:49:15.97051',NULL,NULL,NULL,0),
|
||||
(12,'DJKmtK5PmSY','youtube','Rückenwind',263000,false,NULL,NULL,12,1,'{}',NULL,'2023-08-30 22:47:54.822762','2023-08-30 22:49:15.971789',NULL,NULL,NULL,0),
|
||||
(13,'hWFarQmaQAQ','youtube','Immer wenn wir uns sehn ("Das schönste Mädchen der Welt", Soundtrack)',186000,false,NULL,NULL,1,2,'{}',NULL,'2023-08-30 23:00:09.653056','2023-08-30 23:00:09.653056',NULL,NULL,NULL,0);
|
||||
INSERT INTO albums (src_id,service,"name",release_date,release_date_precision,"album_type",ul_artists,by_va,image_url,image_hash,created_at,updated_at,dirty,hidden) VALUES
|
||||
('MPREb_9VuoAuPU5NR','yt','Vakuum','2016-04-22','day','album','{}',false,'https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj',NULL,'2023-08-30 22:37:08.895301','2023-08-30 22:38:10.919891',false,false),
|
||||
('MPREb_2Kv3bcPa6zp','yt','Immer wenn wir uns sehn ("Das schönste Mädchen der Welt", Soundtrack)','2018-08-17','day','single','{}',false,'https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj',NULL,'2023-08-30 22:58:36.61372','2023-08-30 22:58:36.61372',false,false),
|
||||
('MPREb_uRzxugVdRXQ','yt','Das schönste Mädchen der Welt ("Das schönste Mädchen der Welt", Soundtrack)','2018-08-24','day','single','{}',false,'https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w544-h544-l90-rj',NULL,'2023-08-30 22:58:36.61372','2023-08-30 22:58:36.61372',false,false),
|
||||
('ZeerrnuLi5E','yt','Black Mamba','2020-11-17','day','mv','{}',false,'https://i.ytimg.com/vi/ZeerrnuLi5E/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3k7CsaxHObhW1JXPtGyKE1fgSGZ3Q',NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',false,false),
|
||||
('MPREb_VnWDfciJlMK','yt','Between Broken','2021-10-15','day','album','{}',false,'https://lh3.googleusercontent.com/CTbhHfdn9T_dvB2_dk7hTfxmDSSM29buoFfnG6D2QqauOTZsSMrD02p6T1ERtYP5Ut-ko9Zk5Q16D5Ng=w544-h544-l90-rj',NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',false,false),
|
||||
('MPREb_RXHxrUFfrvQ','yt','Lieblingsmensch','2015-07-10','day','single','{}',false,'https://lh3.googleusercontent.com/dwrJ5NnlZU7CBziLRlTm1uizuolakRAX7g34-eKeqEZQGZgwmvhqcs3TiZClfm7v6a-KYHieitdakpPo=w544-h544-l90-rj',NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',false,false),
|
||||
('MPREb_AeGbdGn1wG4','yt','Unverstärkt - EP','2017-11-03','day','ep','{}',false,'https://lh3.googleusercontent.com/8Yg43icfIuszQqGjqJasncE5ifPrmwOpesgV1BnC5aZ6rjX3yFS_JEz8QldPSzmdFilZm3rUOA4O3Uh2=w544-h544-l90-rj',NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',false,false);
|
||||
|
||||
INSERT INTO public.artists_tracks (artist_id,track_id) VALUES
|
||||
(1,1),
|
||||
(1,5),
|
||||
(1,2),
|
||||
(1,3),
|
||||
(1,4),
|
||||
(1,6),
|
||||
(1,7),
|
||||
(1,8),
|
||||
(1,9),
|
||||
(1,10),
|
||||
(1,11),
|
||||
(1,12),
|
||||
(1,13),
|
||||
(5,13);
|
||||
ALTER SEQUENCE albums_id_seq RESTART WITH 8;
|
||||
|
||||
INSERT INTO public.playlists (id,src_id,service,"name",description,owner_name,owner_url,"playlist_type",image_url,image_hash,image_type,created_at,updated_at,last_sync_at,last_sync_data) VALUES
|
||||
(1,'RDCLAK5uy_nCUL5fa0G5mSAxmXU9tu4uGM1SoZ44OPA','youtube','Happy German Pop','These German music gems transport you directly to cloud 9.','YouTube Music',NULL,'remote','https://lh3.googleusercontent.com/Mx7jwNSTNbl7WGboxuxwJg-W2Bj059MT0WoYVFN5ml477zUyjyaTXLOca1gMgS1VdvtXAQDncPhRwAk=w544-h544-l90-rj',NULL,'custom','2023-08-30 22:22:32.215282','2023-08-30 22:23:05.596028',NULL,NULL),
|
||||
(2,NULL,NULL,NULL,NULL,NULL,NULL,'artist_tracks',NULL,NULL,NULL,'2023-08-30 22:23:45.157936','2023-08-30 22:24:09.541205',NULL,NULL);
|
||||
INSERT INTO artists_albums (artist_id,album_id) VALUES
|
||||
(1,1),
|
||||
(1,2),
|
||||
(5,2),
|
||||
(5,3),
|
||||
(6,4),
|
||||
(7,5),
|
||||
(8,6),
|
||||
(9,7);
|
||||
|
||||
INSERT INTO tracks (src_id,service,"name",duration,duration_ms,"size",loudness,album_pos,album_id,ul_artists,isrc,created_at,updated_at,primary_track,downloaded_at,last_streamed_at,n_streams) VALUES
|
||||
('2txScm52-QI','yt','Die Segel sind gesetzt',186000,false,NULL,NULL,1,1,'{}',NULL,'2023-08-30 22:40:30.15406','2023-08-30 22:49:15.955139',NULL,NULL,NULL,0),
|
||||
('oZKv47vyqQU','yt','Monster',224000,false,NULL,NULL,2,1,'{}',NULL,'2023-08-30 22:41:22.905632','2023-08-30 22:49:15.957959',NULL,NULL,NULL,0),
|
||||
('7WXlMU9ItnA','yt','Dach',231000,false,NULL,NULL,3,1,'{}',NULL,'2023-08-30 22:42:00.093845','2023-08-30 22:49:15.959538',NULL,NULL,NULL,0),
|
||||
('ySChj_9rT5Y','yt','Kennst du das',191000,false,NULL,NULL,4,1,'{}',NULL,'2023-08-30 22:42:57.462304','2023-08-30 22:49:15.961008',NULL,NULL,NULL,0),
|
||||
('revpIT2HiNs','yt','Wohin willst du',255000,false,NULL,NULL,5,1,'{}',NULL,'2023-08-30 22:43:39.594609','2023-08-30 22:49:15.962497',NULL,NULL,NULL,0),
|
||||
('LeEgBsYfjLU','yt','Vakuum',220000,false,NULL,NULL,6,1,'{}',NULL,'2023-08-30 22:44:14.749107','2023-08-30 22:49:15.963926',NULL,NULL,NULL,0),
|
||||
('-i5XjMkQN8M','yt','Melodie',251000,false,NULL,NULL,7,1,'{}',NULL,'2023-08-30 22:44:44.585095','2023-08-30 22:49:15.96526',NULL,NULL,NULL,0),
|
||||
('DhlIZkoPsxg','yt','Du & Ich',238000,false,NULL,NULL,8,1,'{}',NULL,'2023-08-30 22:45:20.711259','2023-08-30 22:49:15.966532',NULL,NULL,NULL,0),
|
||||
('LCoomBMOkgU','yt','Schwerelos',198000,false,NULL,NULL,9,1,'{}',NULL,'2023-08-30 22:46:13.885768','2023-08-30 22:49:15.967865',NULL,NULL,NULL,0),
|
||||
('c6Ot-Z3HEBo','yt','Lichtermeer',164000,false,NULL,NULL,10,1,'{}',NULL,'2023-08-30 22:46:41.10766','2023-08-30 22:49:15.969191',NULL,NULL,NULL,0),
|
||||
('ybm_4hQG0ok','yt','Nachtzug',290000,false,NULL,NULL,11,1,'{}',NULL,'2023-08-30 22:47:28.033803','2023-08-30 22:49:15.97051',NULL,NULL,NULL,0),
|
||||
('DJKmtK5PmSY','yt','Rückenwind',263000,false,NULL,NULL,12,1,'{}',NULL,'2023-08-30 22:47:54.822762','2023-08-30 22:49:15.971789',NULL,NULL,NULL,0),
|
||||
('hWFarQmaQAQ','yt','Immer wenn wir uns sehn ("Das schönste Mädchen der Welt", Soundtrack)',186000,false,NULL,NULL,1,2,'{}',NULL,'2023-08-30 23:00:09.653056','2023-08-30 23:00:09.653056',NULL,NULL,NULL,0),
|
||||
('tXb7WTkhE1c','yt','Das schönste Mädchen der Welt ("Das schönste Mädchen der Welt", Soundtrack)',142000,false,NULL,NULL,1,3,'{}',NULL,'2023-08-30 23:00:09.653056','2023-08-30 23:00:09.653056',NULL,NULL,NULL,0),
|
||||
('ZeerrnuLi5E','yt','Black Mamba',229000,false,NULL,NULL,1,4,'{}',NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,0),
|
||||
('OCgE2GSL1Pk','yt','Smoke Signals',197000,false,NULL,NULL,NULL,5,'{}',NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,0),
|
||||
('6485PhOtHzY','yt','Lieblingsmensch',190000,false,NULL,NULL,1,6,'{}',NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,0),
|
||||
('WSBUeFdXiSs','yt','Leicht',206000,false,NULL,NULL,NULL,7,'{}',NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,0);
|
||||
|
||||
INSERT INTO artists_tracks (artist_id,track_id) VALUES
|
||||
(1,1),
|
||||
(1,5),
|
||||
(1,2),
|
||||
(1,3),
|
||||
(1,4),
|
||||
(1,6),
|
||||
(1,7),
|
||||
(1,8),
|
||||
(1,9),
|
||||
(1,10),
|
||||
(1,11),
|
||||
(1,12),
|
||||
(1,13),
|
||||
(5,13),
|
||||
(5,14),
|
||||
(6,15),
|
||||
(7,16),
|
||||
(8,17),
|
||||
(9,18);
|
||||
|
||||
INSERT INTO playlist_changes (id,seq,operations,created_at,playlist_id,parent1_id,parent2_id) VALUES
|
||||
('d67691dd-6cda-4196-9488-93e65d486fc9',1,'[{"pos": 0, "typ": "INS", "val": ["yt:ZeerrnuLi5E"]}]','2023-09-06 21:35:56.296892',3,NULL,NULL),
|
||||
('ee80bafd-6a7d-4d3f-b857-e12b2eb5e1ac',2,'[{"pos": 1, "typ": "INS", "val": ["yt:OCgE2GSL1Pk"]}]','2023-09-06 23:44:14.702707',3,'d67691dd-6cda-4196-9488-93e65d486fc9',NULL),
|
||||
('0d9aab74-deb3-456e-afdc-c4f32413aaea',3,'[{"pos": 2, "typ": "INS", "val": ["yt:6485PhOtHzY"]}]','2023-09-06 23:45:39.611352',3,'ee80bafd-6a7d-4d3f-b857-e12b2eb5e1ac',NULL),
|
||||
('dee860f5-7f18-4157-97c4-28cfcbbce8c6',4,'[{"n": 1, "pos": 0, "typ": "DEL"}]','2023-09-06 23:48:12.399144',3,'0d9aab74-deb3-456e-afdc-c4f32413aaea',NULL),
|
||||
('ff948c8d-06ec-478d-9d22-845331f969df',4,'[{"pos": 0, "typ": "INS", "val": ["yt:WSBUeFdXiSs"]}]','2023-09-06 23:49:18.06833',3,'0d9aab74-deb3-456e-afdc-c4f32413aaea',NULL),
|
||||
('2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec',5,'[{"pos": 0, "typ": "INS", "val": ["yt:WSBUeFdXiSs"]}]','2023-09-06 23:48:12.399144',3,'dee860f5-7f18-4157-97c4-28cfcbbce8c6',NULL),
|
||||
('d2f0e2f4-d7a6-568a-19fc-9752ed80d7e4',5,'[{"n": 1, "pos": 1, "typ": "DEL"}]','2023-09-06 23:49:18.06833',3,'ff948c8d-06ec-478d-9d22-845331f969df',NULL),
|
||||
('99bdde14-c1d5-ef0b-7bdc-218da3b1364b',6,'[]','2023-09-06 23:50:59.397384',3,'2e5a0e58-79b3-3c10-5c03-eb6ed78fdbec','d2f0e2f4-d7a6-568a-19fc-9752ed80d7e4');
|
||||
|
||||
INSERT INTO playlist_heads (playlist_id,playlist_change_id) VALUES
|
||||
(3,'99bdde14-c1d5-ef0b-7bdc-218da3b1364b');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue