feat: loading local songs, basic association

association used to load existing playlists with local files
This commit is contained in:
2026-09-02 14:23:16 +02:00
parent aba98d3122
commit 8a3dd7a583
24 changed files with 542 additions and 113 deletions
+1
View File
@@ -16,6 +16,7 @@
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@globalhive/vuejs-tour": "^3.0.1",
"@kyvg/vue3-notification": "^3.4.2",
"music-metadata": "^11.15.0",
"nprogress": "^0.2.0",
"pinia": "^4.0.2",
"vue": "^3.5.40",
+5 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import DiskLoader from '@/composables/DiskLoader.vue';
import ImportTypePicker from '@/composables/ImportTypePicker.vue';
import PopupElement from '@/components/PopupElement.vue';
import player from '@/ts/player';
@@ -20,6 +21,7 @@
<template>
<div>
<ImportTypePicker />
<DiskLoader />
<PopupElement v-model="showPopup" show-close>
<div class="title">
<h1>Add Song(s)</h1>
@@ -60,9 +62,9 @@
overflow-x: hidden;
>div {
width: 32%;
width: 45%;
margin: 0.5%;
height: 50%;
height: 60%;
background-color: var(--accent-background);
border-radius: 20px;
display: flex;
@@ -84,6 +86,7 @@
>.not-auth-notice {
font-size: 0.6rem;
margin: 0;
width: 70%;
}
}
}
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import {
associationOpts,
associationResults,
isAnalyzing,
isShowingAssociationManager,
needsFiles,
saveAssociations
} from './associationManager';
import {
onMounted,
useTemplateRef
} from 'vue';
import PopupElement from '@/components/PopupElement.vue';
import player from '@/ts/player';
import {
queue
} from '@/ts/player/state';
const fileinput = useTemplateRef( 'fileinput' );
onMounted( () => {
fileinput.value?.addEventListener( 'change', async () => {
if ( fileinput.value && fileinput.value.files ) {
associationResults.value.concat( await associationOpts.value?.get( fileinput.value.files ) ?? [] );
}
} );
} );
const deleteSong = ( idx: number ) => {
const id = associationResults.value[ idx ]!.song.identifier;
for ( let i = 0; i < queue.value.length; i++ ) {
const song = queue.value[ i ]!;
if ( song.identifier === id ) {
player.removeSong( i );
return;
}
}
associationResults.value.splice( idx, 1 );
};
</script>
<template>
<div>
<PopupElement v-model="isShowingAssociationManager">
<h2>Load from disk</h2>
<div v-if="needsFiles">
<p>Some songs in this playlist require local files.</p>
<!-- TODO: probably need to list songs here somehow -->
<input
ref="fileinput"
type="file"
multiple
:accept="associationOpts?.mime ?? '*'"
>
</div>
<div v-else-if="!needsFiles && !isAnalyzing && associationResults.length === 0">
All files have been associated correctly
</div>
<div v-else-if="!needsFiles && !isAnalyzing && associationResults.length > 0">
<button @click="saveAssociations">
Save
</button>
<div v-for="(result, index) in associationResults" :key="index">
<p>
{{ result.song.name }} by {{ result.song.artist }}
</p>
<select v-if="result.match === 'multiple'">
<option v-for="(file, idx) in result.possibleFiles" :key="idx" :value="idx">
{{ file.name }}
</option>
</select>
<div v-else-if="result.match === 'none'">
<p>
No match
</p>
<i class="fa-solid fa-trash" @click="() => deleteSong( index )"></i>
</div>
</div>
</div>
<div v-else>
An error occurred. Please try again
<!-- FIXME: Button to restore -->
</div>
</PopupElement>
</div>
</template>
+38 -2
View File
@@ -1,14 +1,50 @@
<script setup lang="ts">
import PopupElement from '@/components/PopupElement.vue';
import {
diskLoaderOpts,
isShowingDiskLoader
} from './diskLoader';
import {
onMounted,
ref,
useTemplateRef
} from 'vue';
import PopupElement from '@/components/PopupElement.vue';
const fileinput = useTemplateRef( 'fileinput' );
const progress = ref( -1 );
onMounted( () => {
fileinput.value?.addEventListener( 'change', async () => {
if ( fileinput.value && fileinput.value.files ) {
progress.value = 0;
await diskLoaderOpts.value?.process( fileinput.value.files, ( curr: number ) => {
progress.value = curr;
} );
isShowingDiskLoader.value = false;
setTimeout( () => {
progress.value = -1;
}, 1000 );
}
} );
} );
</script>
<template>
<div>
<PopupElement v-model="isShowingDiskLoader" show-close>
<h3>Load from disk</h3>
<h2>Load from disk</h2>
<input
ref="fileinput"
type="file"
multiple
:accept="diskLoaderOpts?.mime ?? '*'"
>
<div v-if="progress >= 0">
<p style="margin-bottom: 0;">
Analyzing, please wait
</p>
<progress :value="progress" max="1"></progress>
</div>
</PopupElement>
</div>
</template>
+9 -11
View File
@@ -1,19 +1,15 @@
<script setup lang="ts">
import {
type Ref,
ref
} from 'vue';
import {
isShowingSearchView,
query,
results,
searchOpts
} from './searchManager';
import PopupElement from '@/components/PopupElement.vue';
import type {
Song
} from '@/ts/dtype/playlist';
import {
ref
} from 'vue';
const results: Ref<Song[]> = ref( [] );
const query = ref( '' );
const isSearching = ref( false );
const addedIndex = ref( -1 );
@@ -29,7 +25,7 @@
} catch { /* empty */ }
doSearch();
} else if ( query.value.length > 2 ) {
} else if ( query.value.length > ( searchOpts.value?.minChars ?? 0 ) - 1 ) {
try {
clearTimeout( timeout );
} catch { /* empty */ }
@@ -43,7 +39,7 @@
isSearching.value = true;
searchedForQuery = query.value;
results.value = await searchOpts.value?.search( query.value ) ?? [];
results.value = await searchOpts.value?.search( query.value, 0 ) ?? [];
isSearching.value = false;
};
@@ -73,12 +69,14 @@
<div v-if="!isSearching && results.length > 0" class="search-results-wrapper">
<div v-for="(result, index) in results" :key="index" @click="() => add(index)">
<img v-if="result.artwork" :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist">
<i v-else class="fa-solid fa-music img-placeholder"></i>
<div>
<h4>{{ result.name }}</h4>
<p>{{ result.artist }}</p>
</div>
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
</div>
<!-- TODO: load more if close to bottom of scroll view -->
</div>
<div v-else-if="!isSearching && results.length === 0" class="search-results-wrapper placeholder">
No results found
+48
View File
@@ -0,0 +1,48 @@
import {
type Ref,
ref
} from 'vue';
import type {
AssociationResult
} from '@/ts/player/plugins/interface';
import {
queue
} from '@/ts/player/state';
import {
updateIdentifiers
} from '@/ts/player/plugins/local/association';
export const isShowingAssociationManager = ref( false );
export const needsFiles = ref( true );
export const isAnalyzing = ref( false );
export const associationResults: Ref<AssociationResult[]> = ref( [] );
export const associationOpts: Ref<{
'get': ( files: FileList ) => Promise<AssociationResult[]>,
'mime': string;
} | null> = ref( null );
export const openAssociationManager = ( cb: ( files: FileList ) => Promise<AssociationResult[]>, mime: string ) => {
isShowingAssociationManager.value = true;
associationResults.value = [];
needsFiles.value = true;
isAnalyzing.value = false;
associationOpts.value = {
'get': cb,
'mime': mime
};
};
export const saveAssociations = () => {
for ( const result of associationResults.value ) {
for ( const song of queue.value ) {
if ( result.song.identifier === song.identifier ) {
updateIdentifiers( song, result.possibleFiles[ result.selectedIdx ?? 0 ]! );
break;
}
}
}
};
+4 -4
View File
@@ -7,18 +7,18 @@ import type {
} from '@/ts/dtype/playlist';
export interface CloudImport {
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs
'name': string;
'type': 'cloud';
'search': ( term: string ) => Promise<Song[]>;
'search': ( term: string, offset: number ) => Promise<Song[]>;
'addSelected': ( index: number ) => void;
'minChars'?: number;
}
// TODO: Need to add a way to access Apple Music API everywhere (maybe configure MusicKitJS globally and consume it in its plugin later?)
export interface FileImport {
'name': string;
'type': 'file';
'process': ( files: File[] ) => Promise<Song[]>;
'mime': string;
'process': ( files: FileList, cb: ( progress: number ) => void ) => Promise<void>;
}
export type ImportTypes = CloudImport | FileImport;
+9
View File
@@ -5,12 +5,21 @@ import {
import type {
CloudImport
} from './importTypePicker';
import type {
Song
} from '@/ts/dtype/playlist';
export const isShowingSearchView = ref( false );
export const searchOpts: Ref<CloudImport | null> = ref( null );
export const results: Ref<Song[]> = ref( [] );
export const query = ref( '' );
export const openSearchInterface = ( options: CloudImport ) => {
searchOpts.value = options;
isShowingSearchView.value = true;
results.value = [];
query.value = '';
};
+9 -6
View File
@@ -30,15 +30,18 @@
}
}
>img {
height: 7rem;
width: 7rem;
margin-right: 20px;
}
>.fa-solid {
margin-left: auto;
font-size: 2rem;
}
>img,
.img-placeholder {
height: 7rem;
width: 7rem;
margin-right: 20px;
font-size: 6rem;
margin-left: 0;
}
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ export interface Song {
'identifier': string;
/**
* Any additional identifiers (such as filename, etc) to associate
* Any additional identifiers (such as filename, etc) to associate.
* Even if this is not used, set this value if local files are needed
*/
'additional-identifier'?: string;
+6 -2
View File
@@ -26,6 +26,9 @@ import type {
import type {
Song
} from '../dtype/playlist';
import {
load
} from './playlists/loader';
import {
playIndex
} from './playlists';
@@ -137,7 +140,6 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
if ( sources[source]!.authorized.value ) {
sources[source]!.addSongsFromThisSource( addToSongList );
} else {
// TODO: Make this interact with user
sources[source]!.login!();
return false;
@@ -146,6 +148,7 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
return true;
};
export default {
play,
pause,
@@ -167,5 +170,6 @@ export default {
playbackPercentage,
repeat,
shuffle,
isPlaying
isPlaying,
'loadPlaylist': load
};
+1 -8
View File
@@ -15,16 +15,9 @@ import type {
Song
} from '@/ts/dtype/playlist';
export const addSongList = ( songs: Song[], first: boolean = false ) => {
if ( first ) {
// FIXME: Needs to insert at current index in queue, but where for rawQueue?
// Probably at end if shuffled, else insert into queue, then copy
rawQueue.value = songs.concat( rawQueue.value );
queue.value = songs.concat( queue.value );
} else {
export const addSongList = ( songs: Song[] ) => {
rawQueue.value = rawQueue.value.concat( songs );
queue.value = queue.value.concat( songs );
}
};
export const clearQueue = () => {
+47 -2
View File
@@ -1,2 +1,47 @@
// TODO: If local files contained, prompt user to select files, then associate based on file names
export const load = () => {};
import {
queue,
rawQueue,
sources
} from '../state';
import type {
AssociationResult
} from '../plugins/interface';
import type {
Playlist
} from '@/ts/dtype/playlist';
import {
openAssociationManager
} from '@/composables/associationManager';
export const load = ( playlist: Playlist ) => {
queue.value = playlist;
rawQueue.value = playlist;
let needToLoadLocalSongs = false;
for ( const song of playlist ) {
if ( song['additional-identifier'] ) {
needToLoadLocalSongs = true;
break;
}
}
const fileLoader = async ( files: FileList ) => {
const associationResults: AssociationResult[] = [];
for ( const song of playlist ) {
const source = sources[song.source]!;
if ( source.loading.requiresLocalFiles === true ) {
associationResults.push( await source.loading.association( files as FileList, song ) );
}
}
return associationResults.filter( val => val.match !== 'exact' );
};
if ( needToLoadLocalSongs ) {
// FIXME: Combine mime types
openAssociationManager( fileLoader, '' );
}
};
+23 -8
View File
@@ -5,9 +5,15 @@ import type {
Song
} from '@/ts/dtype/playlist';
// TODO: Add search interface elements to plugin's args (such as a search interface)
export type PlayerSourcePluginInitializer = () => Promise<PlayerSourcePlugin>;
export interface AssociationResult {
'song': Song,
'match': 'exact' | 'none' | 'multiple';
'possibleFiles': File[],
'selectedIdx'?: number;
}
export interface PlayerSourcePlugin {
/**
* The name of the source that is displayed in the UI
@@ -33,17 +39,26 @@ export interface PlayerSourcePlugin {
*/
'requiresLocalFiles': true;
/**
* The allowed MIME types for loading associated files
*/
'MIMETypes': string;
/**
* Function used to associate the file to the songs of the playlist
* @param files - The files that were picked by the user
* @returns A promise resolving to an array of song identifiers (corresponding to Song.identifier, for association)
* @param song - The song to update
* @returns A promise resolving to the (possibly) updated song and metadata
*/
'association': ( files: File[] ) => Promise<string[]>;
'association': ( files: FileList, song: Song ) => Promise<AssociationResult>;
/**
* Function used to update the additional identifier of the song if the file has changed
* @param song - The song that needs updating
* @param file - The file that it should be updated to
* @returns The updated song
*/
'updateIdentifiers': ( song: Song, file: File ) => Promise<Song>;
/**
* The mime types that this source's files may have
*/
'mime': string;
} | {
/**
* Set to true if the user needs to load files
@@ -0,0 +1,57 @@
import type {
AssociationResult
} from '../interface';
import type {
Song
} from '@/ts/dtype/playlist';
export const createFileAssociationString = ( file: File ) => {
return file.name + '__' + file.size;
};
export const associate = async ( files: FileList, song: Song ): Promise<AssociationResult> => {
const candidates: File[] = [];
if ( !song['additional-identifier'] ) {
return {
'match': 'none',
'song': song,
'possibleFiles': []
};
}
for ( const file of files ) {
if ( createFileAssociationString( file ) === song['additional-identifier'] ) {
candidates.push( file );
}
}
if ( candidates.length === 0 ) {
return {
'match': 'none',
'song': song,
'possibleFiles': []
};
} else if ( candidates.length === 1 ) {
song.identifier = URL.createObjectURL( candidates[ 0 ]! );
return {
'match': 'exact',
'song': song,
'possibleFiles': []
};
} else {
return {
'match': 'multiple',
'song': song,
'possibleFiles': candidates
};
}
};
export const updateIdentifiers = async ( song: Song, file: File ): Promise<Song> => {
song.identifier = URL.createObjectURL( file );
song['additional-identifier'] = createFileAssociationString( file );
return song;
};
+15 -9
View File
@@ -2,12 +2,19 @@ import type {
PlayerSourcePlugin,
PlayerSourcePluginInitializer
} from '../interface';
import {
associate,
updateIdentifiers
} from './association';
import {
load
} from './loader';
import {
ref
} from 'vue';
export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => {
const player = document.createElement( 'audio' );
const player = new Audio();
const playSong = async ( id: string ) => {
player.src = id;
@@ -18,20 +25,19 @@ export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<P
'authorized': ref( true ),
'id': 'local',
'name': 'Local Disk',
'play': player.play,
'play': () => player.play(),
'getPlaybackPos': () => player.currentTime / player.duration,
'getDuration': () => player.duration,
playSong,
'seekTo': pos => player.currentTime = pos,
'pause': player.pause,
'seekTo': pos => player.currentTime = pos * player.duration,
'pause': () => player.pause(),
'stop': () => player.src = '',
// TODO: Implement
'addSongsFromThisSource': async () => [],
'addSongsFromThisSource': load,
'loading': {
'requiresLocalFiles': true,
'MIMETypes': 'audio/mp3,audio/wav',
// TODO: Implement
'association': async () => []
'association': associate,
'updateIdentifiers': updateIdentifiers,
'mime': 'audio/aac,audio/mpeg,audio/wav,audio/mp4,audio/ogg'
}
};
};
+77
View File
@@ -0,0 +1,77 @@
import type {
Song
} from '@/ts/dtype/playlist';
import {
createFileAssociationString
} from './association';
import {
initAppleMusicApiSearch
} from '@/ts/util/search';
import {
openDiskLoaderInterface
} from '@/composables/diskLoader';
import {
parseBlob
} from 'music-metadata';
export const load = ( cb: ( songs: Song[] ) => void ) => {
const search = initAppleMusicApiSearch();
const process = async ( files: FileList, status: ( progress: number ) => void ) => {
const songs: Song[] = [];
for ( let i = 0; i < files.length; i++ ) {
const file = files[i]!;
try {
songs.push( await generateSongObject( file ) );
} catch ( error ) {
console.error( '[LOADER] Failed generating song info for file', file, 'with error', error );
}
status( ( i + 1 ) / files.length );
}
cb( songs );
};
openDiskLoaderInterface( {
'name': 'From Disk',
'process': process,
'type': 'file',
'mime': 'audio/aac,audio/mpeg,audio/wav,audio/mp4,audio/ogg'
} );
const generateSongObject = async ( file: File ): Promise<Song> => {
console.log( 'Analyzing file', file );
const url = URL.createObjectURL( file );
const blob = await ( await fetch( url ) ).blob();
// Load audio file and parse its metadata
const data = await parseBlob( blob );
const searchTerm = data.common.title
? data.common.title + ( data.common.artist ? ' ' + data.common.artist : '' )
: file.name.split( '.' )[ 0 ]!.replace( '_', ' ' );
const result = ( await search( searchTerm, 1 ) )[ 0 ];
if ( result ) {
result.source = 'local';
result['additional-identifier'] = createFileAssociationString( file );
result.identifier = url;
return result;
} else {
console.warn( '[LOADER] No results found for', searchTerm );
return {
'additional-identifier': file.name,
'source': 'local',
'identifier': url,
'artist': data.common.artist ?? 'Unknown Artist',
'name': data.common.title ?? 'Unknown title',
'artwork': '',
'additional-info': '',
'duration': -1
};
}
};
};
+1 -1
View File
@@ -54,7 +54,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
'name': 'Apple Music',
'seekTo': controls.seekTo,
'login': login,
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ),
'addSongsFromThisSource': addFromAppleMusic,
'loading': {
'requiresLocalFiles': false
}
+7
View File
@@ -32,5 +32,12 @@ export interface AppleMusicPlaylistData {
'attributes': {
'artwork': Artwork;
'name': string;
},
'relationships'?: {
'tracks': {
'href': string,
'next': string,
'data': AppleMusicSongData
}[]
}
}
@@ -1,6 +1,3 @@
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
@@ -14,16 +11,17 @@ import {
searchSongs
} from './songs';
export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise<void> => {
const songs = await searchSongs( instance, cb );
const playlists = await searchPlaylists( instance, cb );
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void ): Promise<void> => {
const songs = await searchSongs( cb );
const playlists = await searchPlaylists( cb );
openImportTypePicker( [
{
'name': 'Songs',
'type': 'cloud',
'addSelected': songs.addSelected,
'search': songs.search
'search': songs.search,
'minChars': 3
},
{
'name': 'Playlists',
@@ -1,21 +1,22 @@
import type {
AppleMusicPlaylistData
AppleMusicPlaylistData,
AppleMusicSongData
} from './dtype';
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
export const searchPlaylists = async ( cb: ( songs: Song[] ) => void ) => {
let playlists: Song[] = [];
let filtered: Song[] = [];
const search = async ( term: string ): Promise<Song[]> => {
const instance = window.MusicKit.getInstance();
const search = async ( term: string, offset: number = 0 ): Promise<Song[]> => {
if ( playlists.length === 0 ) {
playlists = ( ( await instance.api.music( '/v1/me/library/playlists', {
'limit': 100
'limit': 100,
'offset': offset
} ) ).data as {
'data': AppleMusicPlaylistData[]
} ).data.map( val => {
@@ -31,16 +32,36 @@ export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs:
} );
}
term = term.toLocaleLowerCase();
filtered = playlists.filter( val => {
return val.name.includes( term );
return val.name.toLocaleLowerCase().includes( term );
} );
return filtered;
};
const addSelected = async ( idx: number ) => {
// TODO: Implement
cb( [] );
try {
const results = ( await instance.api.music( '/v1/me/library/playlists/' + filtered[idx]!.identifier + '/tracks' ) ).data as {
'data': AppleMusicSongData[]
};
const songs: Song[] = results.data
.map( val => {
return {
'identifier': val.id,
'additional-info': '',
'artist': val.attributes.artistName,
'name': val.attributes.name,
'artwork': window.MusicKit.formatArtworkURL( val.attributes.artwork, 1000, 1000 ),
'duration': val.attributes.durationInMillis * 1000,
'source': 'applemusic'
};
} );
cb( songs );
} catch ( error ) {
console.error( '[ADD PLAYLIST] Failed to add playlist due to error', error );
}
};
return {
@@ -1,46 +1,17 @@
import type {
AppleMusicApiSearchResult,
AppleMusicSongData
} from './dtype';
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
import {
initAppleMusicApiSearch
} from '@/ts/util/search';
export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
export const searchSongs = async ( cb: ( songs: Song[] ) => void ) => {
let songs: Song[] = [];
const search = async ( term: string ): Promise<Song[]> => {
const params = {
'term': term,
'types': [ 'songs' ]
};
const searchFunc = initAppleMusicApiSearch();
let results: AppleMusicSongData[] = [];
try {
results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
} catch {
console.debug( 'Failed results: got', results );
return [];
}
songs = [];
for ( const result of results ) {
songs.push( {
'duration': Math.round( result.attributes.durationInMillis / 1000 ),
'name': result.attributes.name,
'additional-info': '',
'artist': result.attributes.artistName,
'artwork': window.MusicKit.formatArtworkURL( result.attributes.artwork, 1000, 1000 ),
'identifier': result.id,
'source': 'applemusic'
} );
}
const search = async ( term: string, offset: number = 0 ) => {
songs = await searchFunc( term, 15, offset );
return songs;
};
+2 -2
View File
@@ -14,9 +14,9 @@ import {
// Tracking of time and player status
let interval = -1;
export const playbackPercentage = ref( 0 );
export const playbackPercentage = ref( 1 );
export const duration = ref( 0 );
export const duration = ref( -1 );
export const startTracking = () => {
if ( interval === -1 ) {
+45
View File
@@ -0,0 +1,45 @@
import type {
AppleMusicApiSearchResult, AppleMusicSongData
} from '../player/plugins/musickit/search/dtype';
import type {
Song
} from '../dtype/playlist';
export const initAppleMusicApiSearch = () => {
const instance = window.MusicKit.getInstance();
return async ( term: string, limit: number = 20, offset: number = 0 ): Promise<Song[]> => {
const params = {
'term': term,
'types': [ 'songs' ],
'offset': offset ?? 20,
'limit': limit ?? 0
};
let results: AppleMusicSongData[] = [];
try {
results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
} catch {
console.debug( 'Failed results: got', results );
return [];
}
const songs: Song[] = [];
for ( const result of results ) {
songs.push( {
'duration': Math.round( result.attributes.durationInMillis / 1000 ),
'name': result.attributes.name,
'additional-info': '',
'artist': result.attributes.artistName,
'artwork': window.MusicKit.formatArtworkURL( result.attributes.artwork, 1000, 1000 ),
'identifier': result.id,
'source': 'applemusic'
} );
}
return songs;
};
};