diff --git a/packages/core/types/src/model/track.ts b/packages/core/types/src/model/track.ts index 632b20d..692d0ea 100644 --- a/packages/core/types/src/model/track.ts +++ b/packages/core/types/src/model/track.ts @@ -16,6 +16,7 @@ export const TrackId = Brand.nominal(); * Represents how a resource can be consumed. */ export type StreamingResource = + // TODO: Do not store URIs. They seem to expire after some time. Instead save the track ID and the provider ID. | { type: "file"; provider: FileBasedProviderId; uri: string } | { type: "api"; provider: ApiBasedProviderId }; diff --git a/packages/core/types/src/services/database/database-models.ts b/packages/core/types/src/services/database/database-models.ts index abd28db..d73e060 100644 --- a/packages/core/types/src/services/database/database-models.ts +++ b/packages/core/types/src/services/database/database-models.ts @@ -4,7 +4,9 @@ import type { Album, Artist, Track } from "../../model"; * Represents an album in the database, which is synced with the model but * references other tables by their IDs instead of duplicating the data. */ -export type DatabaseAlbum = Omit & { artistId: Artist["id"] }; +export type DatabaseAlbum = Omit & { + artistId: Artist["id"]; +}; /** * Represents an artist in the database, which is synced with the model. diff --git a/packages/core/types/src/services/database/database.ts b/packages/core/types/src/services/database/database.ts index 9956efa..daedafd 100644 --- a/packages/core/types/src/services/database/database.ts +++ b/packages/core/types/src/services/database/database.ts @@ -72,35 +72,15 @@ export type Table< */ readonly byId: (id: TSchema["id"]) => Effect.Effect>; - /** - * Retrieves a specific record from the table by a specific field. - */ - readonly byField: >( - field: TField, - value: string, - ) => Effect.Effect>; - - /** - * Retrieves a specific record from the table by the given fields. - */ - readonly byFields: >( - fieldWithValues: [TField, string][], - ) => Effect.Effect>; - /** * Retrieves a subset of records from the table that match the given filter * in a stream. */ readonly filtered: (opts: { - /** - * Field or fields by which to filter the records. - */ - fieldOrFields: StringKeyOf | StringKeyOf[]; - /** * Value to filter the records by. */ - filter: string; + filter: { [K in StringKeyOf]?: TSchema[K] }; /** * Maximum number of records to return. If not specified, the default will be diff --git a/packages/core/types/src/services/library.ts b/packages/core/types/src/services/library.ts index 05b3c7d..7ffbfb7 100644 --- a/packages/core/types/src/services/library.ts +++ b/packages/core/types/src/services/library.ts @@ -1,5 +1,5 @@ import { Context, Effect, Stream } from "effect"; -import type { Album, ArtistId } from "../model"; +import type { Album, ArtistId, Track } from "../model"; /** * Error that is thrown when an album references an artist that does not exist. @@ -20,7 +20,7 @@ export type Library = { * Returns a stream of albums that are currently stored in the database. */ readonly observeAlbums: () => Effect.Effect< - Stream.Stream + Stream.Stream<[Album, Track[]], NonExistingArtistReferenced> >; }; diff --git a/packages/infrastructure/dexie-database/src/database.ts b/packages/infrastructure/dexie-database/src/database.ts index 15d12c6..6845315 100644 --- a/packages/infrastructure/dexie-database/src/database.ts +++ b/packages/infrastructure/dexie-database/src/database.ts @@ -5,7 +5,6 @@ import { type DatabaseTrack, type Tables, type Table, - type StringKeyOf, type DatabaseAlbum, } from "@echo/core-types"; import Dexie, { type Table as DexieTable, liveQuery } from "dexie"; @@ -76,49 +75,24 @@ const createTable = < Effect.map(Option.fromNullable), ); }), - byField: (field, value) => + filtered: ({ filter, limit = 100 }) => Effect.gen(function* () { const table = db[tableName]; - const normalizedFilter = normalizeForComparison(value); - - return yield* Effect.tryPromise( - () => - table.get({ - [field]: normalizedFilter, - }) as unknown as PromiseLike, - ).pipe( - Effect.catchAllCause(catchToDefaultAndLog), - Effect.map(Option.fromNullable), - ); - }), - byFields: (fieldWithValues) => - Effect.gen(function* () { - const table = db[tableName]; - const normalizedFilters = fieldWithValues.map(([field, value]) => [ - field, - normalizeForComparison(value), - ]); - - return yield* Effect.tryPromise( - () => table.get(normalizedFilters) as unknown as PromiseLike, - ).pipe( - Effect.catchAllCause(catchToDefaultAndLog), - Effect.map(Option.fromNullable), - ); - }), - filtered: ({ fieldOrFields, filter, limit = 100 }) => - Effect.gen(function* () { - const table = db[tableName]; - const normalizedFilter = normalizeForComparison(filter); return yield* Effect.tryPromise( () => table .filter((tableRow) => - normalizedFieldValues( - tableRow as TSchema, - fieldOrFields, - ).some((value) => value.includes(normalizedFilter)), + Object.keys(filter).some((key) => { + const schemaTable = tableRow as TSchema; + return normalizeForComparison( + schemaTable[key as keyof TSchema] as string, + ).includes( + normalizeForComparison( + filter[key as keyof typeof filter] as string, + ), + ); + }), ) .limit(limit) .toArray() as unknown as PromiseLike, @@ -147,22 +121,6 @@ const createTable = < }), }); -/** - * Returns the normalized values of the given fields in the given table row. - */ -const normalizedFieldValues = < - TSchemaKey extends keyof Tables, - TSchema extends Tables[TSchemaKey], ->( - tableRow: TSchema, - filterFields: StringKeyOf | StringKeyOf[], -): string[] => { - const keys = Array.isArray(filterFields) ? filterFields : [filterFields]; - return keys.map((key) => { - return normalizeForComparison(tableRow[key] as string); - }); -}; - /** * Internal class that interfaces with Dexie. Should NOT be exposed nor used * outside of this package. diff --git a/packages/infrastructure/library/index.ts b/packages/infrastructure/library/index.ts index 87452ab..e7e4c6f 100644 --- a/packages/infrastructure/library/index.ts +++ b/packages/infrastructure/library/index.ts @@ -3,6 +3,7 @@ import { Database, Library, NonExistingArtistReferenced, + type Track, } from "@echo/core-types"; import { Effect, Layer, Option, Stream } from "effect"; @@ -19,12 +20,18 @@ export const LibraryLive = Layer.effect( Effect.gen(function* () { const albumsTable = yield* database.table("albums"); const artistsTable = yield* database.table("artists"); + const tracksTable = yield* database.table("tracks"); const allAlbums = yield* albumsTable.observe(); return allAlbums.pipe( Stream.mapEffect((album) => Effect.gen(function* () { const artist = yield* artistsTable.byId(album.artistId); + const tracks = yield* tracksTable.filtered({ + filter: { + albumId: album.id, + }, + }); if (Option.isNone(artist)) { return yield* Effect.fail( @@ -32,7 +39,21 @@ export const LibraryLive = Layer.effect( ); } - return { ...album, artist: artist.value } as Album; + return [ + { + ...album, + artist: artist.value, + }, + tracks.map((track) => ({ + ...track, + album: { + ...album, + artist: artist.value, + }, + mainArtist: artist.value, + secondaryArtists: [], + })), + ] as [Album, Track[]]; }), ), ); diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index d4b18c1..5374474 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -7,8 +7,9 @@ import { type MediaProvider, type ProviderMetadata, type FolderMetadata, + type Track, } from "@echo/core-types"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useEffectCallback, useEffectTs, @@ -256,19 +257,35 @@ const observeLibrary = Effect.gen(function* () { }).pipe(Effect.provide(MainLive)); const UserLibrary = () => { + const [src, setSrc] = useState(undefined); const [albumStream, matcher] = useStream(observeLibrary); + const playFirstTrack = (tracks: Track[]) => { + const track = tracks[0]; + switch (track.resource.type) { + case "file": + setSrc(track.resource.uri); + break; + case "api": + break; + } + }; + return matcher.pipe( Match.tag("empty", () =>

Nothing in your library

), - Match.tag("items", ({ items }) => - items.map((album) => ( -
-

{album.name}

-

{album.artist.name}

-
-
- )), - ), + Match.tag("items", ({ items }) => ( +
+
+ )), Match.tag("failure", () =>

Failed to load library

), Match.exhaustive, )(albumStream); 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 009c69b..d5b95ca 100644 --- a/packages/workers/media-provider/src/sync/file-based-sync.ts +++ b/packages/workers/media-provider/src/sync/file-based-sync.ts @@ -21,6 +21,7 @@ import { FileBasedProviderId, } from "@echo/core-types"; import { Effect, Match, Option, Schedule, Stream } from "effect"; +import { head } from "effect/Array"; import { isSupportedAudioFile } from "@echo/core-files"; import { DownloadError, @@ -264,8 +265,12 @@ const tryRetrieveOrCreateArtist = ( const artistTable = yield* database.table("artists"); const existingArtist = yield* artistTable - .byField("name", artistName) + .filtered({ + filter: { name: artistName }, + limit: 1, + }) .pipe( + Effect.map(head), Effect.map( Option.orElse(() => Option.fromNullable(processedArtists.get(artistName)), @@ -287,11 +292,12 @@ const tryRetrieveOrCreateAlbum = ( Effect.gen(function* () { const albumTable = yield* database.table("albums"); const existingAlbum = yield* albumTable - .byFields([ - ["name", albumName], - ["artistId", artistId], - ]) + .filtered({ + filter: { name: albumName, artistId }, + limit: 1, + }) .pipe( + Effect.map(head), Effect.map( Option.orElse(() => Option.fromNullable(processedAlbums.get(albumName)), @@ -313,11 +319,16 @@ const tryRetrieveOrCreateTrack = ( ): Effect.Effect => Effect.gen(function* () { const trackTable = yield* database.table("tracks"); - const existingTrack = yield* trackTable.byFields([ - ["name", metadata?.title || "Unknown Title"], - ["mainArtistId", artistId], - ["albumId", albumId], - ]); + const existingTrack = yield* trackTable + .filtered({ + filter: { + name: metadata.title ?? "Unknown Title", + mainArtistId: artistId, + albumId, + }, + limit: 1, + }) + .pipe(Effect.map(head)); return Option.isNone(existingTrack) ? yield* createTrack({ crypto }, artistId, albumId, metadata, file)