import * as odd from '@oddjs/odd'; import { getRecoil, setRecoil } from 'recoil-nexus'; import type PublicFile from '@oddjs/odd/fs/v1/PublicFile'; import type PrivateFile from '@oddjs/odd/fs/v1/PrivateFile'; import { isFile } from '@oddjs/odd/fs/types/check'; import { filesystemStore } from '../../../stores'; import { collectionStore, AREAS } from '../stores'; import { addNotification } from '../../../lib/notifications'; import { fileToUint8Array } from '../../../lib/utils'; export type PlaylistSourceType = | 'apple' | 'spotify' | 'youtube' | 'tidal' | 'deezer' | 'bandcamp' | 'soundcloud' | 'other'; export type PlaylistSource = { type: PlaylistSourceType; url: string; }; export type Playlist = { cid: string; ctime: number; name: string; private: boolean; size: number; src: string; cover?: string; sources?: PlaylistSource[]; }; export type COLLECTION = { publicPlaylists: Playlist[]; privatePlaylists: Playlist[]; selectedArea: AREAS; loading: boolean; }; type Link = { size: number; }; export const COLLECTION_DIRS = { [AREAS.PUBLIC]: odd.path.directory('public', 'collection'), [AREAS.PRIVATE]: odd.path.directory('private', 'collection'), }; const FILE_SIZE_LIMIT = 20; /** * Get playlists from the user's WNFS and construct the `src` value for the playlists */ export const getPlaylistsFromWNFS: () => Promise<void> = async () => { const collection = getRecoil(collectionStore); const fs = getRecoil(filesystemStore); if (!fs) return; try { // Set loading: true on the collectionStore setRecoil(collectionStore, { ...collection, loading: true }); const { selectedArea } = collection; const isPrivate = selectedArea === AREAS.PRIVATE; // Set path to either private or public gallery dir const path = COLLECTION_DIRS[selectedArea]; // Get list of links for files in the gallery dir const links = await fs.ls(path); let playlists = await Promise.all( Object.entries(links).map(async ([name]) => { const file = await fs.get( odd.path.combine( COLLECTION_DIRS[selectedArea], odd.path.file(`${name}`) ) ); if (!isFile(file)) return null; // The CID for private files is currently located in `file.header.content`, // whereas the CID for public files is located in `file.cid` const cid = isPrivate ? (file as PrivateFile).header.content.toString() : (file as PublicFile).cid.toString(); // Create a blob to use as the playlist `src` const blob = new Blob([file.content]); const src = URL.createObjectURL(blob); const ctime = isPrivate ? (file as PrivateFile).header.metadata.unixMeta.ctime : (file as PublicFile).header.metadata.unixMeta.ctime; return { cid, ctime, name, private: isPrivate, size: (links[name] as Link).size, src, }; }) ); // Sort playlists by ctime(created at date) // NOTE: this will eventually be controlled via the UI playlists = playlists.filter((a) => !!a); playlists.sort((a, b) => b.ctime - a.ctime); // Push playlists to the collectionStore setRecoil(collectionStore, { ...collection, ...(isPrivate ? { privatePlaylists: playlists, } : { publicPlaylists: playlists, }), loading: false, }); } catch (error) { setRecoil(collectionStore, { ...collection, loading: false, }); } }; /** * Upload a playlist to the user's private or public WNFS * @param playlist */ export const uploadPlaylistToWNFS: (playlist: File) => Promise<void> = async ( playlist ) => { const collection = getRecoil(collectionStore); const fs = getRecoil(filesystemStore); if (!fs) return; try { const { selectedArea } = collection; // Reject files over 20MB const playlistSizeInMB = playlist.size / (1024 * 1024); if (playlistSizeInMB > FILE_SIZE_LIMIT) { throw new Error('Playlist can be no larger than 20MB'); } // Reject the upload if the playlist already exists in the directory const playlistExists = await fs.exists( odd.path.combine( COLLECTION_DIRS[selectedArea], odd.path.file(playlist.name) ) ); if (playlistExists) { throw new Error(`${playlist.name} playlist already exists`); } // Create a sub directory and add some content await fs.write( odd.path.combine( COLLECTION_DIRS[selectedArea], odd.path.file(playlist.name) ), await fileToUint8Array(playlist) ); // Announce the changes to the server await fs.publish(); addNotification({ msg: `${playlist.name} playlist has been published`, type: 'success', }); } catch (error) { addNotification({ msg: (error as Error).message, type: 'error' }); console.error(error); } }; /** * Delete a playlist from the user's private or public WNFS * @param name */ export const deletePlaylistFromWNFS: (name: string) => Promise<void> = async ( name ) => { const collection = getRecoil(collectionStore); const fs = getRecoil(filesystemStore); if (!fs) return; try { const { selectedArea } = collection; const playlistExists = await fs.exists( odd.path.combine(COLLECTION_DIRS[selectedArea], odd.path.file(name)) ); if (playlistExists) { // Remove playlists from server await fs.rm( odd.path.combine(COLLECTION_DIRS[selectedArea], odd.path.file(name)) ); // Announce the changes to the server await fs.publish(); addNotification({ msg: `${name} playlist has been deleted`, type: 'success', }); // Refetch playlists and update collectionStore await getPlaylistsFromWNFS(); } else { throw new Error(`${name} playlist has already been deleted`); } } catch (error) { addNotification({ msg: (error as Error).message, type: 'error' }); console.error(error); } }; /** * Handle uploads made by interacting with the file input directly */ export const handleFileInput: ( files: FileList | null ) => Promise<void> = async (files) => { if (!files) return; await Promise.all( Array.from(files).map(async (file) => { await uploadPlaylistToWNFS(file); }) ); // Refetch playlists and update collectionStore await getPlaylistsFromWNFS(); };