diff --git a/packages/components/library/src/user-library.ts b/packages/components/library/src/user-library.ts index 09e8f91..bfbc4e1 100644 --- a/packages/components/library/src/user-library.ts +++ b/packages/components/library/src/user-library.ts @@ -24,6 +24,15 @@ export class UserLibrary extends LitElement { albums, (album) => html`
+ ${album.base64EmbeddedCover && + html` + Album cover + `}

${album.name}

${album.artist.name}

diff --git a/packages/core/types/src/model/album.ts b/packages/core/types/src/model/album.ts index 6053ed6..874ea45 100644 --- a/packages/core/types/src/model/album.ts +++ b/packages/core/types/src/model/album.ts @@ -1,4 +1,4 @@ -import { Brand, Option } from "effect"; +import { Brand } from "effect"; import type { Artist } from "./artist"; import type { Track } from "./track"; @@ -37,8 +37,8 @@ export type Album = { tracks: Track[]; /** - * URL to an image of the album. This is typically the album's cover art on a - * third-party service. If none is available, this field is omitted. + * Cover art of the album, encoded in base64. If the cover art is not + * available, this field is `undefined`. */ - imageUrl: Option.Option; + base64EmbeddedCover: string | undefined; }; diff --git a/packages/core/types/src/services/metadata-provider.ts b/packages/core/types/src/services/metadata-provider.ts index 6edc6cd..fea4c3b 100644 --- a/packages/core/types/src/services/metadata-provider.ts +++ b/packages/core/types/src/services/metadata-provider.ts @@ -51,13 +51,9 @@ export type TrackMetadata = { genre?: string[] | undefined; /** - * Format and buffer with the embedded cover art. - * TODO: Re-add without dependency on buffer. + * Base64 encoded cover image embedded in the audio file. */ - // embeddedCoverArt?: | undefined{ - // format: string; - // data: Buffer; - // }; + base64EmbeddedCover?: string | undefined; /** * Keywords to reflect the mood of the audio, e.g. 'Romantic' or 'Sad' diff --git a/packages/infrastructure/mmb-metadata-provider/index.ts b/packages/infrastructure/mmb-metadata-provider/index.ts index f5c1c86..5ccb44b 100644 --- a/packages/infrastructure/mmb-metadata-provider/index.ts +++ b/packages/infrastructure/mmb-metadata-provider/index.ts @@ -1,12 +1,8 @@ -import { - MetadataProviderError, - MetadataProvider, - type TrackMetadata, -} from "@echo/core-types"; +import { MetadataProviderError, MetadataProvider } from "@echo/core-types"; import { Effect, Layer } from "effect"; import { Buffer } from "buffer"; import process from "process"; -import { parseWebStream } from "music-metadata"; +import { parseWebStream, type IAudioMetadata } from "music-metadata"; // Polyfills needed for `music-metadata-browser` to work. globalThis.Buffer = Buffer; @@ -30,23 +26,48 @@ const mmbMetadataProvider = MetadataProvider.of({ }), catch: () => MetadataProviderError.MalformedFile, }).pipe( - Effect.map( - (metadata): TrackMetadata => ({ - album: metadata.common.album, - artists: metadata.common.artists, - diskNumber: metadata.common.disk.no ?? undefined, - genre: metadata.common.genre, - mood: metadata.common.mood, - title: metadata.common.title, - totalDisks: metadata.common.disk.of ?? undefined, - totalTracks: metadata.common.track.of ?? undefined, - trackNumber: metadata.common.track.no ?? undefined, - year: metadata.common.year, + Effect.flatMap((metadata) => + Effect.gen(function* () { + const base64EmbeddedCover = yield* tryExtractToBase64(metadata).pipe( + Effect.catchAll(() => + Effect.logError( + `Cover extraction failed for ${file.name}, continuing without cover`, + ).pipe(Effect.map(() => undefined)), + ), + ); + + return { + album: metadata.common.album, + artists: metadata.common.artists, + diskNumber: metadata.common.disk.no ?? undefined, + genre: metadata.common.genre, + mood: metadata.common.mood, + title: metadata.common.title, + totalDisks: metadata.common.disk.of ?? undefined, + totalTracks: metadata.common.track.of ?? undefined, + trackNumber: metadata.common.track.no ?? undefined, + base64EmbeddedCover, + year: metadata.common.year, + }; }), ), ), }); +const tryExtractToBase64 = (parsedMetadata: IAudioMetadata) => + Effect.try(() => { + const firstPicture = parsedMetadata.common.picture?.find( + (picture) => picture.data, + ); + + if (!firstPicture) { + return undefined; + } + + const pictureData = new Uint8Array(firstPicture.data as ArrayBufferLike); + return Buffer.from(pictureData).toString("base64"); + }); + /** * Layer that provides a metadata provider that uses `music-metadata-browser`. */ diff --git a/packages/workers/media-provider/src/sync/file-based-sync.ts b/packages/workers/media-provider/src/sync/file-based-sync.ts index d8c16c1..b4c8234 100644 --- a/packages/workers/media-provider/src/sync/file-based-sync.ts +++ b/packages/workers/media-provider/src/sync/file-based-sync.ts @@ -106,7 +106,7 @@ export const syncFileBasedProvider = ({ const partiallyDownloadFile = (file: FileMetadata) => // TODO: Implement retry with a bigger partial range if metadata is undefined. - partiallyDownloadIntoStream(file, 0, 10000).pipe( + partiallyDownloadIntoStream(file, 0, 500000).pipe( Effect.map((stream) => [stream, file] as const), Effect.retry({ times: 3, @@ -222,6 +222,7 @@ const normalizeData = ( { database, crypto }, artist.id, metadata.album ?? "Unknown Album", + metadata.base64EmbeddedCover, accumulator.albums, ); @@ -287,6 +288,7 @@ const tryRetrieveOrCreateAlbum = ( { database, crypto }: Pick, artistId: DatabaseArtist["id"], albumName: string, + base64Cover: string | undefined, processedAlbums: Map, ): Effect.Effect => Effect.gen(function* () { @@ -306,7 +308,7 @@ const tryRetrieveOrCreateAlbum = ( ); return Option.isNone(existingAlbum) - ? yield* createAlbum({ crypto }, albumName, artistId) + ? yield* createAlbum({ crypto }, albumName, artistId, base64Cover) : existingAlbum.value; }); @@ -353,6 +355,7 @@ const createAlbum = ( { crypto }: Pick, name: string, artistId: DatabaseArtist["id"], + base64Cover: string | undefined, ): Effect.Effect => Effect.gen(function* () { const id = yield* crypto.generateUuid; @@ -361,7 +364,7 @@ const createAlbum = ( id: AlbumId(id), name, artistId, - imageUrl: Option.some("https://example.com/image.jpg"), + base64EmbeddedCover: base64Cover, }; });