Skip to content

Commit

Permalink
Simple streaming player
Browse files Browse the repository at this point in the history
  • Loading branch information
sleepyfran committed Jul 25, 2024
1 parent 7fd9346 commit 9214b79
Show file tree
Hide file tree
Showing 8 changed files with 88 additions and 98 deletions.
1 change: 1 addition & 0 deletions packages/core/types/src/model/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const TrackId = Brand.nominal<TrackId>();
* 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 };

Expand Down
4 changes: 3 additions & 1 deletion packages/core/types/src/services/database/database-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Album, "artist"> & { artistId: Artist["id"] };
export type DatabaseAlbum = Omit<Album, "artist"> & {
artistId: Artist["id"];
};

/**
* Represents an artist in the database, which is synced with the model.
Expand Down
22 changes: 1 addition & 21 deletions packages/core/types/src/services/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,35 +72,15 @@ export type Table<
*/
readonly byId: (id: TSchema["id"]) => Effect.Effect<Option.Option<TSchema>>;

/**
* Retrieves a specific record from the table by a specific field.
*/
readonly byField: <TField extends StringKeyOf<TSchema>>(
field: TField,
value: string,
) => Effect.Effect<Option.Option<TSchema>>;

/**
* Retrieves a specific record from the table by the given fields.
*/
readonly byFields: <TField extends StringKeyOf<TSchema>>(
fieldWithValues: [TField, string][],
) => Effect.Effect<Option.Option<TSchema>>;

/**
* 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<TSchema> | StringKeyOf<TSchema>[];

/**
* Value to filter the records by.
*/
filter: string;
filter: { [K in StringKeyOf<TSchema>]?: TSchema[K] };

/**
* Maximum number of records to return. If not specified, the default will be
Expand Down
4 changes: 2 additions & 2 deletions packages/core/types/src/services/library.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<Album, NonExistingArtistReferenced>
Stream.Stream<[Album, Track[]], NonExistingArtistReferenced>
>;
};

Expand Down
64 changes: 11 additions & 53 deletions packages/infrastructure/dexie-database/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<TSchema>(
() =>
table.get({
[field]: normalizedFilter,
}) as unknown as PromiseLike<TSchema>,
).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<TSchema>(
() => table.get(normalizedFilters) as unknown as PromiseLike<TSchema>,
).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<TSchema[]>(
() =>
table
.filter((tableRow) =>
normalizedFieldValues<TSchemaKey, TSchema>(
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<TSchema[]>,
Expand Down Expand Up @@ -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<TSchema> | StringKeyOf<TSchema>[],
): 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.
Expand Down
23 changes: 22 additions & 1 deletion packages/infrastructure/library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Database,
Library,
NonExistingArtistReferenced,
type Track,
} from "@echo/core-types";
import { Effect, Layer, Option, Stream } from "effect";

Expand All @@ -19,20 +20,40 @@ 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(
new NonExistingArtistReferenced(album.name, album.artistId),
);
}

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[]];
}),
),
);
Expand Down
37 changes: 27 additions & 10 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -256,19 +257,35 @@ const observeLibrary = Effect.gen(function* () {
}).pipe(Effect.provide(MainLive));

const UserLibrary = () => {
const [src, setSrc] = useState<string | undefined>(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", () => <h1>Nothing in your library</h1>),
Match.tag("items", ({ items }) =>
items.map((album) => (
<div key={album.id}>
<h1>{album.name}</h1>
<p>{album.artist.name}</p>
<hr />
</div>
)),
),
Match.tag("items", ({ items }) => (
<div>
<audio src={src} autoPlay controls />
{items.map(([album, tracks]) => (
<div key={album.id}>
<h1>{album.name}</h1>
<p>{album.artist.name}</p>
<button onClick={() => playFirstTrack(tracks)}>Play</button>
<hr />
</div>
))}
</div>
)),
Match.tag("failure", () => <h1>Failed to load library</h1>),
Match.exhaustive,
)(albumStream);
Expand Down
31 changes: 21 additions & 10 deletions packages/workers/media-provider/src/sync/file-based-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)),
Expand All @@ -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)),
Expand All @@ -313,11 +319,16 @@ const tryRetrieveOrCreateTrack = (
): Effect.Effect<DatabaseTrack> =>
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)
Expand Down

0 comments on commit 9214b79

Please sign in to comment.