A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz

[scobbler] initialize spotify and musicbrainz clients

+624 -1
+1
Cargo.lock
··· 3885 3885 dependencies = [ 3886 3886 "actix-web", 3887 3887 "anyhow", 3888 + "chrono", 3888 3889 "dotenv", 3889 3890 "hex", 3890 3891 "jsonwebtoken",
+1
crates/scrobbler/.gitignore
··· 1 + .env
+1
crates/scrobbler/Cargo.toml
··· 36 36 "multipart", 37 37 ], default-features = false } 38 38 quick-xml = { version = "0.37.4", features = ["serialize"] } 39 + chrono = { version = "0.4.39", features = ["serde"] }
+41
crates/scrobbler/src/cache.rs
··· 1 + use anyhow::Error; 2 + use redis::Client; 3 + use std::env; 4 + 5 + #[derive(Clone)] 6 + pub struct Cache { 7 + pub client: Client, 8 + } 9 + 10 + impl Cache { 11 + pub fn new() -> Result<Self, Error> { 12 + let client = 13 + redis::Client::open(env::var("REDIS_URL").unwrap_or("redis://127.0.0.1".into()))?; 14 + Ok(Cache { client }) 15 + } 16 + 17 + pub fn get(&self, key: &str) -> Result<Option<String>, Error> { 18 + let mut con = self.client.get_connection()?; 19 + let result: Option<String> = redis::cmd("GET").arg(key).query(&mut con)?; 20 + Ok(result) 21 + } 22 + 23 + pub fn set(&self, key: &str, value: &str) -> Result<(), Error> { 24 + let mut con = self.client.get_connection()?; 25 + redis::cmd("SET") 26 + .arg(key) 27 + .arg(value) 28 + .query::<()>(&mut con)?; 29 + Ok(()) 30 + } 31 + 32 + pub fn setex(&self, key: &str, value: &str, seconds: usize) -> Result<(), Error> { 33 + let mut con = self.client.get_connection()?; 34 + redis::cmd("SETEX") 35 + .arg(key) 36 + .arg(seconds) 37 + .arg(value) 38 + .query::<()>(&mut con)?; 39 + Ok(()) 40 + } 41 + }
+23
crates/scrobbler/src/handlers.rs
··· 1 1 use actix_web::{post, web, HttpResponse, Responder}; 2 2 use serde_json::json; 3 3 use std::collections::BTreeMap; 4 + use crate::musicbrainz::client::MusicbrainzClient; 4 5 use crate::signature::generate_signature; 5 6 use crate::models::Scrobble; 7 + use crate::spotify::client::SpotifyClient; 6 8 7 9 fn parse_batch(form: &BTreeMap<String, String>) -> Vec<Scrobble> { 8 10 let mut result = vec![]; ··· 77 79 } 78 80 79 81 // You can now save these scrobbles to your database here. 82 + let client = MusicbrainzClient::new(); 83 + // let result = client.get_recording("47fb34da-5d07-4772-8cdb-af352699ee67").await; 84 + let result = client.search(r#" 85 + recording:"Don't stay" AND artist:"Linkin Park" 86 + "#).await; 87 + println!("Musicbrainz result: {:#?}", result); 88 + 89 + /* 90 + let client = SpotifyClient::new("BQDhXOoiiSNCmmOKFvtvpuT3gwXJGOdXvjpkfb4JTc8GPLhNOhLrgy_tAVA7i8c3VipA4mbQfwvc9rAGDaNyzpVw26SXtQXFMNw_na2VRAeBXMcHMqrJ-Cfg4XQoLzAvQwX8RkuRAKtaBpMSFtwiHYHeYj2GybiqinRZ8ZDNRzIn3GvoYQjcYqfEKR39iNHtlToDPkdPxO1caZh8DDc2VltMLxOUyNs8po_OLpp6-7WgED3_CmHEbOfdc_DD4-btRcsmvri8O58lOioxBgCgrKx0Ww-xq7oNk0-mdDJcUat805Fuh2PHRIoWK2rOLtbVAtU8PnpfLzUbbiejxBfXubl5J3EST7tB9N_OkVz8ZQs92tp-QQk"); 91 + let results = match client.search("track:one more time artist:daft punk").await { 92 + Ok(res) => res, 93 + Err(e) => { 94 + return HttpResponse::InternalServerError().json(json!({ 95 + "error": 9, 96 + "message": format!("Internal server error: {}", e) 97 + })); 98 + } 99 + }; 100 + 101 + println!("Search results: {:?}", results); 102 + */ 80 103 81 104 let response = json!({ 82 105 "scrobbles": {
+4
crates/scrobbler/src/main.rs
··· 1 1 pub mod handlers; 2 2 pub mod models; 3 3 pub mod signature; 4 + pub mod musicbrainz; 5 + pub mod spotify; 6 + pub mod xata; 7 + pub mod cache; 4 8 5 9 use std::env; 6 10
+86
crates/scrobbler/src/musicbrainz/artist.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Deserialize)] 4 + pub struct Artist { 5 + pub name: String, 6 + #[serde(rename = "sort-name")] 7 + pub sort_name: String, 8 + pub r#type: Option<String>, 9 + #[serde(rename = "type-id")] 10 + pub type_id: Option<String>, 11 + #[serde(rename = "life-span")] 12 + pub life_span: Option<LifeSpan>, 13 + pub isnis: Option<Vec<String>>, 14 + pub ipis: Option<Vec<String>>, 15 + pub id: String, 16 + #[serde(rename = "gender-id")] 17 + pub gender_id: Option<String>, 18 + pub gender: Option<String>, 19 + #[serde(rename = "end_area")] 20 + pub end_area: Option<Area>, 21 + #[serde(rename = "end-area")] 22 + pub end_area_: Option<Area>, 23 + pub disambiguation: Option<String>, 24 + pub country: Option<String>, 25 + pub begin_area: Option<Area>, 26 + #[serde(rename = "begin-area")] 27 + pub begin_area_: Option<Area>, 28 + pub area: Option<Area>, 29 + pub aliases: Option<Vec<Alias>>, 30 + } 31 + 32 + #[derive(Debug, Deserialize)] 33 + pub struct ArtistCredit { 34 + pub joinphrase: Option<String>, 35 + pub name: String, 36 + pub artist: Artist, 37 + } 38 + 39 + #[derive(Debug, Deserialize)] 40 + pub struct Alias { 41 + pub name: String, 42 + #[serde(rename = "sort-name")] 43 + pub sort_name: String, 44 + pub locale: Option<String>, 45 + pub primary: Option<bool>, 46 + pub r#type: Option<String>, 47 + #[serde(rename = "type-id")] 48 + pub type_id: Option<String>, 49 + pub begin: Option<String>, 50 + pub end: Option<String>, 51 + pub ended: Option<bool>, 52 + } 53 + 54 + #[derive(Debug, Deserialize)] 55 + pub struct Area { 56 + pub disambiguation: Option<String>, 57 + pub id: String, 58 + pub name: String, 59 + #[serde(rename = "sort-name")] 60 + pub sort_name: String, 61 + pub r#type: Option<String>, 62 + #[serde(rename = "type-id")] 63 + pub type_id: Option<String>, 64 + #[serde(rename = "iso-3166-1-codes")] 65 + pub iso_3166_1_codes: Option<Vec<String>>, 66 + } 67 + 68 + #[derive(Debug, Deserialize)] 69 + pub struct LifeSpan { 70 + pub begin: Option<String>, 71 + pub end: Option<String>, 72 + pub ended: Option<bool>, 73 + } 74 + 75 + #[derive(Debug, Serialize, Deserialize)] 76 + pub struct Params { 77 + pub inc: Option<String>, 78 + } 79 + 80 + #[derive(Debug, Deserialize)] 81 + pub struct Artists { 82 + pub created: String, 83 + pub count: u32, 84 + pub offset: u32, 85 + pub artists: Vec<Artist>, 86 + }
+56
crates/scrobbler/src/musicbrainz/client.rs
··· 1 + use super::recording::{Recording, Recordings}; 2 + use anyhow::Error; 3 + 4 + pub const BASE_URL: &str = "https://musicbrainz.org/ws/2"; 5 + pub const USER_AGENT: &str = "Rocksky/0.1.0"; 6 + 7 + pub struct MusicbrainzClient {} 8 + 9 + impl MusicbrainzClient { 10 + pub fn new() -> Self { 11 + MusicbrainzClient {} 12 + } 13 + 14 + pub async fn search( 15 + &self, 16 + query: &str, 17 + ) -> Result<Recordings, Error> { 18 + let url = format!("{}/recording", BASE_URL); 19 + let client = reqwest::Client::new(); 20 + let response = client 21 + .get(&url) 22 + .header("Accept", "application/json") 23 + .header("User-Agent", USER_AGENT) 24 + .query( 25 + &[ 26 + ("query", query), 27 + ("inc", "artist-credits+releases"), 28 + ], 29 + ) 30 + .send() 31 + .await?; 32 + 33 + Ok(response.json().await?) 34 + } 35 + 36 + pub async fn get_recording( 37 + &self, 38 + mbid: &str, 39 + ) -> Result<Recording, Error> { 40 + let url = format!("{}/recording/{}", BASE_URL, mbid); 41 + let client = reqwest::Client::new(); 42 + let response = client 43 + .get(&url) 44 + .header("Accept", "application/json") 45 + .header("User-Agent", USER_AGENT) 46 + .query( 47 + &[ 48 + ("inc", "artist-credits+releases"), 49 + ], 50 + ) 51 + .send() 52 + .await?; 53 + 54 + Ok(response.json().await?) 55 + } 56 + }
+30
crates/scrobbler/src/musicbrainz/label.rs
··· 1 + use serde::Deserialize; 2 + 3 + use super::artist::{Area, LifeSpan}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + pub struct Label { 7 + #[serde(rename = "type-id")] 8 + pub type_id: String, 9 + pub disambiguation: String, 10 + #[serde(rename = "label-code")] 11 + pub label_code: u32, 12 + #[serde(rename = "sort-name")] 13 + pub sort_name: String, 14 + pub id: String, 15 + pub name: String, 16 + pub r#type: String, 17 + pub area: Option<Area>, 18 + pub country: Option<String>, 19 + pub isnis: Option<Vec<String>>, 20 + pub ipis: Option<Vec<String>>, 21 + #[serde(rename = "life-span")] 22 + pub life_span: Option<LifeSpan>, 23 + } 24 + 25 + #[derive(Debug, Deserialize)] 26 + pub struct LabelInfo { 27 + #[serde(rename = "catalog-number")] 28 + pub catalog_number: String, 29 + pub label: Label, 30 + }
+5
crates/scrobbler/src/musicbrainz/mod.rs
··· 1 + pub mod artist; 2 + pub mod client; 3 + pub mod label; 4 + pub mod recording; 5 + pub mod release;
+25
crates/scrobbler/src/musicbrainz/recording.rs
··· 1 + use serde::Deserialize; 2 + 3 + use super::{artist::ArtistCredit, release::Release}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + pub struct Recordings { 7 + pub recordings: Vec<Recording>, 8 + pub count: u32, 9 + pub offset: u32, 10 + pub created: String, 11 + } 12 + 13 + #[derive(Debug, Deserialize)] 14 + pub struct Recording { 15 + #[serde(rename = "first-release-date")] 16 + pub first_release_date: Option<String>, 17 + pub title: String, 18 + pub disambiguation: Option<String>, 19 + pub video: Option<bool>, 20 + #[serde(rename = "artist-credit")] 21 + pub artist_credit: Option<Vec<ArtistCredit>>, 22 + pub id: String, 23 + pub length: Option<u32>, 24 + pub releases: Option<Vec<Release>>, 25 + }
+92
crates/scrobbler/src/musicbrainz/release.rs
··· 1 + use serde::Deserialize; 2 + 3 + use super::{ 4 + artist::{Area, ArtistCredit}, 5 + label::LabelInfo, 6 + recording::Recording, 7 + }; 8 + 9 + #[derive(Debug, Deserialize)] 10 + pub struct Release { 11 + #[serde(rename = "release-events")] 12 + pub release_events: Option<Vec<ReleaseEvent>>, 13 + pub quality: Option<String>, 14 + #[serde(rename = "text-representation")] 15 + pub text_representation: Option<TextRepresentation>, 16 + pub status: Option<String>, 17 + pub packaging: Option<String>, 18 + pub barcode: Option<String>, 19 + pub id: String, 20 + #[serde(rename = "packaging-id")] 21 + pub packaging_id: Option<String>, 22 + pub media: Option<Vec<Media>>, 23 + pub disambiguation: Option<String>, 24 + #[serde(rename = "cover-art-archive")] 25 + pub cover_art_archive: Option<CoverArtArchive>, 26 + #[serde(rename = "artist-credit")] 27 + pub artist_credit: Vec<ArtistCredit>, 28 + #[serde(rename = "status-id")] 29 + pub status_id: Option<String>, 30 + #[serde(rename = "label-info")] 31 + pub label_info: Option<Vec<LabelInfo>>, 32 + pub title: String, 33 + pub date: Option<String>, 34 + pub country: Option<String>, 35 + pub asin: Option<String>, 36 + } 37 + 38 + #[derive(Debug, Deserialize)] 39 + pub struct CoverArtArchive { 40 + pub back: bool, 41 + pub artwork: bool, 42 + pub front: bool, 43 + pub count: u32, 44 + pub darkened: bool, 45 + } 46 + 47 + #[derive(Debug, Deserialize)] 48 + pub struct ReleaseEvent { 49 + pub area: Option<Area>, 50 + pub date: String, 51 + } 52 + 53 + #[derive(Debug, Deserialize)] 54 + pub struct TextRepresentation { 55 + pub language: Option<String>, 56 + pub script: Option<String>, 57 + } 58 + 59 + #[derive(Debug, Deserialize)] 60 + pub struct Media { 61 + #[serde(rename = "format-id")] 62 + pub format_id: Option<String>, 63 + pub discs: Option<Vec<Disc>>, 64 + pub position: u32, 65 + pub tracks: Option<Vec<Track>>, 66 + #[serde(rename = "track-offset")] 67 + pub track_offset: u32, 68 + pub title: Option<String>, 69 + #[serde(rename = "track-count")] 70 + pub track_count: u32, 71 + pub format: Option<String>, 72 + } 73 + 74 + #[derive(Debug, Deserialize)] 75 + pub struct Disc { 76 + pub offset: Option<u32>, 77 + pub sectors: u32, 78 + pub id: String, 79 + pub offsets: Option<Vec<u32>>, 80 + } 81 + 82 + #[derive(Debug, Deserialize)] 83 + pub struct Track { 84 + pub length: i64, 85 + pub id: String, 86 + pub position: u32, 87 + pub title: String, 88 + pub recording: Recording, 89 + #[serde(rename = "artist-credit")] 90 + pub artist_credit: Vec<ArtistCredit>, 91 + pub number: String, 92 + }
+30
crates/scrobbler/src/spotify/client.rs
··· 1 + use super::types::SearchResponse; 2 + use anyhow::Error; 3 + 4 + pub const BASE_URL: &str = "https://api.spotify.com/v1"; 5 + 6 + pub struct SpotifyClient { 7 + token: String, 8 + } 9 + 10 + impl SpotifyClient { 11 + pub fn new(token: &str) -> Self { 12 + SpotifyClient { 13 + token: token.to_string(), 14 + } 15 + } 16 + 17 + pub async fn search(&self, query: &str) -> Result<SearchResponse, Error> { 18 + let url = format!("{}/search", BASE_URL); 19 + let client = reqwest::Client::new(); 20 + let response = client.get(&url) 21 + .bearer_auth(&self.token) 22 + .query(&[ 23 + ("type", "track"), 24 + ("q", query), 25 + ]) 26 + .send().await?; 27 + let result = response.json().await?; 28 + Ok(result) 29 + } 30 + }
+2
crates/scrobbler/src/spotify/mod.rs
··· 1 + pub mod client; 2 + pub mod types;
+87
crates/scrobbler/src/spotify/types.rs
··· 1 + use serde::Deserialize; 2 + 3 + #[derive(Debug, Deserialize)] 4 + pub struct SearchResponse { 5 + pub tracks: Tracks, 6 + } 7 + 8 + #[derive(Debug, Deserialize)] 9 + pub struct Tracks { 10 + pub href: String, 11 + pub limit: u32, 12 + pub next: Option<String>, 13 + pub offset: u32, 14 + pub previous: Option<String>, 15 + pub total: u32, 16 + pub items: Vec<Track>, 17 + } 18 + 19 + #[derive(Debug, Deserialize)] 20 + pub struct Track { 21 + pub album: Album, 22 + pub artists: Vec<Artist>, 23 + pub available_markets: Vec<String>, 24 + pub disc_number: u32, 25 + pub duration_ms: u32, 26 + pub explicit: bool, 27 + pub external_ids: ExternalIds, 28 + pub external_urls: ExternalUrls, 29 + pub href: String, 30 + pub id: String, 31 + pub is_local: bool, 32 + pub is_playable: Option<bool>, 33 + pub name: String, 34 + pub popularity: u32, 35 + pub preview_url: Option<String>, 36 + pub track_number: u32, 37 + #[serde(rename = "type")] 38 + pub kind: String, 39 + pub uri: String, 40 + } 41 + 42 + #[derive(Debug, Deserialize)] 43 + pub struct Album { 44 + pub album_type: String, 45 + pub artists: Vec<Artist>, 46 + pub available_markets: Vec<String>, 47 + pub external_urls: ExternalUrls, 48 + pub href: String, 49 + pub id: String, 50 + pub images: Vec<Image>, 51 + pub is_playable: Option<bool>, 52 + pub name: String, 53 + pub release_date: String, 54 + pub release_date_precision: String, 55 + pub total_tracks: u32, 56 + #[serde(rename = "type")] 57 + pub kind: String, 58 + pub uri: String, 59 + } 60 + 61 + #[derive(Debug, Deserialize)] 62 + pub struct Artist { 63 + pub external_urls: ExternalUrls, 64 + pub href: String, 65 + pub id: String, 66 + pub name: String, 67 + #[serde(rename = "type")] 68 + pub kind: String, 69 + pub uri: String, 70 + } 71 + 72 + #[derive(Debug, Deserialize)] 73 + pub struct ExternalUrls { 74 + pub spotify: String, 75 + } 76 + 77 + #[derive(Debug, Deserialize)] 78 + pub struct ExternalIds { 79 + pub isrc: String, 80 + } 81 + 82 + #[derive(Debug, Deserialize)] 83 + pub struct Image { 84 + pub height: u32, 85 + pub width: u32, 86 + pub url: String, 87 + }
+21
crates/scrobbler/src/xata/album.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 + pub struct Album { 6 + pub xata_id: String, 7 + pub title: String, 8 + pub artist: String, 9 + pub release_date: Option<String>, 10 + pub album_art: Option<String>, 11 + pub year: Option<i32>, 12 + pub spotify_link: Option<String>, 13 + pub tidal_link: Option<String>, 14 + pub youtube_link: Option<String>, 15 + pub apple_music_link: Option<String>, 16 + pub sha256: String, 17 + pub uri: Option<String>, 18 + pub artist_uri: Option<String>, 19 + #[serde(with = "chrono::serde::ts_seconds")] 20 + pub xata_createdat: DateTime<Utc>, 21 + }
+23
crates/scrobbler/src/xata/artist.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 + pub struct Artist { 6 + pub xata_id: String, 7 + pub name: String, 8 + pub biography: Option<String>, 9 + #[serde(with = "chrono::serde::ts_seconds_option")] 10 + pub born: Option<DateTime<Utc>>, 11 + pub born_in: Option<String>, 12 + #[serde(with = "chrono::serde::ts_seconds_option")] 13 + pub died: Option<DateTime<Utc>>, 14 + pub picture: Option<String>, 15 + pub sha256: String, 16 + pub spotify_link: Option<String>, 17 + pub tidal_link: Option<String>, 18 + pub youtube_link: Option<String>, 19 + pub apple_music_link: Option<String>, 20 + pub uri: Option<String>, 21 + #[serde(with = "chrono::serde::ts_seconds")] 22 + pub xata_createdat: DateTime<Utc>, 23 + }
+6
crates/scrobbler/src/xata/mod.rs
··· 1 + pub mod album; 2 + pub mod artist; 3 + pub mod spotify_account; 4 + pub mod spotify_token; 5 + pub mod track; 6 + pub mod user;
+15
crates/scrobbler/src/xata/spotify_account.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] 5 + pub struct SpotifyAccount { 6 + pub xata_id: String, 7 + pub xata_version: i32, 8 + #[serde(with = "chrono::serde::ts_seconds")] 9 + pub xata_createdat: DateTime<Utc>, 10 + #[serde(with = "chrono::serde::ts_seconds")] 11 + pub xata_updatedat: DateTime<Utc>, 12 + pub email: String, 13 + pub user_id: String, 14 + pub is_beta_user: bool, 15 + }
+30
crates/scrobbler/src/xata/spotify_token.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] 5 + pub struct SpotifyToken { 6 + pub xata_id: String, 7 + pub xata_version: i32, 8 + #[serde(with = "chrono::serde::ts_seconds")] 9 + pub xata_createdat: DateTime<Utc>, 10 + #[serde(with = "chrono::serde::ts_seconds")] 11 + pub xata_updatedat: DateTime<Utc>, 12 + pub user_id: String, 13 + pub access_token: String, 14 + pub refresh_token: String, 15 + } 16 + 17 + #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] 18 + pub struct SpotifyTokenWithEmail { 19 + pub xata_id: String, 20 + pub xata_version: i32, 21 + #[serde(with = "chrono::serde::ts_seconds")] 22 + pub xata_createdat: DateTime<Utc>, 23 + #[serde(with = "chrono::serde::ts_seconds")] 24 + pub xata_updatedat: DateTime<Utc>, 25 + pub user_id: String, 26 + pub access_token: String, 27 + pub refresh_token: String, 28 + pub email: String, 29 + pub did: String, 30 + }
+31
crates/scrobbler/src/xata/track.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, sqlx::FromRow, Serialize, Deserialize, Clone)] 5 + pub struct Track { 6 + pub xata_id: String, 7 + pub title: String, 8 + pub artist: String, 9 + pub album_artist: String, 10 + pub album_art: Option<String>, 11 + pub album: String, 12 + pub track_number: i32, 13 + pub duration: i32, 14 + pub mb_id: Option<String>, 15 + pub youtube_link: Option<String>, 16 + pub spotify_link: Option<String>, 17 + pub tidal_link: Option<String>, 18 + pub apple_music_link: Option<String>, 19 + pub sha256: String, 20 + pub lyrics: Option<String>, 21 + pub composer: Option<String>, 22 + pub genre: Option<String>, 23 + pub disc_number: i32, 24 + pub copyright_message: Option<String>, 25 + pub label: Option<String>, 26 + pub uri: Option<String>, 27 + pub artist_uri: Option<String>, 28 + pub album_uri: Option<String>, 29 + #[serde(with = "chrono::serde::ts_seconds")] 30 + pub xata_createdat: DateTime<Utc>, 31 + }
+13
crates/scrobbler/src/xata/user.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 + pub struct User { 6 + pub xata_id: String, 7 + pub display_name: String, 8 + pub did: String, 9 + pub handle: String, 10 + pub avatar: String, 11 + #[serde(with = "chrono::serde::ts_seconds")] 12 + pub xata_createdat: DateTime<Utc>, 13 + }
+1 -1
rockskymobile/src/pages/home/feed/Feed.tsx
··· 35 35 // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 36 data.map((song: any) => ( 37 37 <div key={song.id} style={{ marginBottom: 50 }}> 38 - <Link to={`/${song.uri.split("at://")[1]}`}> 38 + <Link to={`/${song.uri?.split("at://")[1]}`}> 39 39 <SongCover 40 40 cover={song.cover} 41 41 artist={song.artist}