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
+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
};
+3 -10
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 {
rawQueue.value = rawQueue.value.concat( songs );
queue.value = queue.value.concat( songs );
}
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 ) {