mirror of
https://github.com/leinelissen/jellyfin-audio-player.git
synced 2026-09-02 21:03:11 +03:00
fix: rearrange entities
This commit is contained in:
@@ -4,9 +4,9 @@ import { useContext } from 'react';
|
||||
import { ColorSchemeName, Platform, StyleSheet, View, useColorScheme } from 'react-native';
|
||||
import { ColorScheme } from '@/store/settings/types';
|
||||
import { useAccessibilitySetting } from 'react-native-accessibility-settings';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { appSettings } from '@/store/db/schema/app-settings';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import appSettings from '@/store/settings/entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const majorPlatformVersion = typeof Platform.Version === 'string' ? parseInt(Platform.Version, 10) : Platform.Version;
|
||||
|
||||
@@ -7,11 +7,11 @@ import useDefaultStyles from './Colors';
|
||||
import Svg, { Circle, CircleProps } from 'react-native-svg';
|
||||
import { Animated, Easing, ViewProps } from 'react-native';
|
||||
import styled from 'styled-components/native';
|
||||
import type { Track } from '@/store/tracks/types';
|
||||
import type { Download } from '@/store/downloads/types';
|
||||
import { useDownload } from '@/store/downloads/hooks';
|
||||
|
||||
interface DownloadIconProps {
|
||||
track: Track;
|
||||
trackId?: string;
|
||||
download?: Download | null;
|
||||
size?: number;
|
||||
fill?: string;
|
||||
@@ -29,10 +29,13 @@ const IconOverlay = styled.View`
|
||||
transform: scale(0.5);
|
||||
`;
|
||||
|
||||
function DownloadIcon({ track, download, size = 16, fill, style }: DownloadIconProps) {
|
||||
function DownloadIcon({ trackId, download: downloadProp, size = 16, fill, style }: DownloadIconProps) {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const iconFill = fill || defaultStyles.textQuarterOpacity.color;
|
||||
|
||||
const { data: downloadData } = useDownload(trackId || '');
|
||||
const download = downloadProp || downloadData || null;
|
||||
|
||||
const isQueued = download && !download.isComplete && !download.isFailed;
|
||||
const radius = useMemo(() => size / 2, [size]);
|
||||
const circumference = useMemo(() => radius * 2 * Math.PI, [radius]);
|
||||
|
||||
@@ -20,6 +20,7 @@ import Trash from '@/assets/icons/trash.svg';
|
||||
import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/queue';
|
||||
import { Header, SubHeader } from '@/components/Typography';
|
||||
import { Text } from '@/components/Typography';
|
||||
import { SafeScrollView, useNavigationOffsets } from '@/components/SafeNavigatorView';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
index: {
|
||||
|
||||
@@ -2,15 +2,15 @@ import React, { useCallback, useEffect } from 'react';
|
||||
import styled from 'styled-components/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@/screens';
|
||||
import { setOnboardingStatus } from '@/store/settings/db';
|
||||
import { setOnboardingStatus } from '@/store/settings/actions';
|
||||
import { t } from '@/localisation';
|
||||
import Button from '@/components/Button';
|
||||
import { Header, Text as BaseText } from '@/components/Typography';
|
||||
import { ShadowWrapper } from '@/components/Shadow';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { sources } from '@/store/db/schema/sources';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import sources from '@/store/sources/entity';
|
||||
|
||||
const Container = styled(SafeAreaView)`
|
||||
flex: 1;
|
||||
|
||||
@@ -11,9 +11,9 @@ import { useDownloads } from '@/store/downloads/hooks';
|
||||
import { useSourceId } from '@/store/db/useSourceId';
|
||||
import { searchAndStore } from '@/store/music/fetchers';
|
||||
import { addSearchQuery, clearSearchHistory, parseSearchQueries } from '@/store/search/db';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { searchQueries } from '@/store/search-queries/search-queries';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import searchQueries from '@/store/search-queries/entity';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
|
||||
@@ -5,11 +5,11 @@ import Button from '@/components/Button';
|
||||
import styled from 'styled-components/native';
|
||||
import { Paragraph } from '@/components/Typography';
|
||||
import Container from '../components/Container';
|
||||
import { db } from '@/store/db';
|
||||
import { albums } from '@/store/albums/albums';
|
||||
import { artists } from '@/store/artists/artists';
|
||||
import { tracks } from '@/store/tracks/tracks';
|
||||
import { playlists } from '@/store/playlists/playlists';
|
||||
import { db } from '@/store';
|
||||
import albums from '@/store/albums/entity';
|
||||
import artists from '@/store/artists/entity';
|
||||
import tracks from '@/store/tracks/entity';
|
||||
import playlists from '@/store/playlists/entity';
|
||||
|
||||
const ClearCache = styled(Button)`
|
||||
margin-top: 16px;
|
||||
|
||||
@@ -4,20 +4,15 @@ import Container from '../components/Container';
|
||||
import { t } from '@/localisation';
|
||||
import { RadioItem, RadioList } from '../components/Radio';
|
||||
import { ColorScheme } from '@/store/settings/types';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { appSettings } from '@/store/db/schema/app-settings';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { setColorScheme as setColorSchemeDb } from '@/store/settings/db';
|
||||
import { updateAppSettings } from '@/store/settings/actions';
|
||||
import { useAppSettings } from '@/store/settings/hooks';
|
||||
|
||||
export default function ColorSchemeSetting() {
|
||||
const { data: settings } = useLiveQuery(
|
||||
db.select().from(appSettings).where(eq(appSettings.id, 1)).limit(1)
|
||||
);
|
||||
const scheme = settings?.[0]?.colorScheme || ColorScheme.System;
|
||||
const { data: settings } = useAppSettings();
|
||||
const scheme = settings?.colorScheme || ColorScheme.System;
|
||||
|
||||
const handlePress = useCallback((value: ColorScheme) => {
|
||||
setColorSchemeDb(value);
|
||||
updateAppSettings({ colorScheme: value });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,9 +7,9 @@ import Button from '@/components/Button';
|
||||
import { Paragraph } from '@/components/Typography';
|
||||
import Container from '../components/Container';
|
||||
import { InputContainer, Input } from '../components/Input';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { sources } from '@/store/db/schema/sources';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import sources from '@/store/sources/entity';
|
||||
|
||||
export default function LibrarySettings() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Paragraph } from '@/components/Typography';
|
||||
import React, { useCallback } from 'react';
|
||||
import { Switch } from 'react-native-gesture-handler';
|
||||
import { t } from '@/localisation';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { appSettings } from '@/store/db/schema/app-settings';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import appSettings from '@/store/settings/entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { setEnablePlaybackReporting } from '@/store/settings/db';
|
||||
import { setEnablePlaybackReporting } from '@/store/settings/actions';
|
||||
import Container from '../components/Container';
|
||||
import { SwitchContainer, SwitchLabel } from '../components/Switch';
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ import useDefaultStyles from '@/components/Colors';
|
||||
import Player from './modals/Player';
|
||||
import { StackParams } from './types';
|
||||
import Lyrics from './modals/Lyrics';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { appSettings } from '@/store/db/schema/app-settings';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import appSettings from '@/store/settings/entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const Stack = createNativeStackNavigator<StackParams>();
|
||||
|
||||
@@ -3,8 +3,9 @@ import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
import styled from 'styled-components/native';
|
||||
import TimerIcon from '@/assets/icons/timer.svg';
|
||||
import { setSleepTimerDate } from '@/store/sleep-timer/db';
|
||||
import { useLiveQueryOne } from '@/store/db/live-queries';
|
||||
import { sleepTimer } from '@/store/sleep-timer/sleep-timer';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import sleepTimer from '@/store/sleep-timer/entity';
|
||||
import { db } from '@/store';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import ticksToDuration from '@/utility/ticksToDuration';
|
||||
import useDefaultStyles from '@/components/Colors';
|
||||
@@ -40,11 +41,10 @@ export default function Timer() {
|
||||
const [showPicker, setShowPicker] = useState<boolean>(false);
|
||||
|
||||
// Retrieve sleep timer from database using live query
|
||||
const timerData = useLiveQueryOne(
|
||||
(db) => db.select().from(sleepTimer).where(eq(sleepTimer.id, 1)).limit(1),
|
||||
['sleep_timer']
|
||||
const { data: timerRows } = useLiveQuery(
|
||||
db.select().from(sleepTimer).where(eq(sleepTimer.id, 1)).limit(1)
|
||||
);
|
||||
const date = timerData?.date ?? null;
|
||||
const date = timerRows?.[0]?.date ?? null;
|
||||
|
||||
// Retrieve styles
|
||||
const defaultStyles = useDefaultStyles();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import React, { useRef, useCallback, useMemo } from 'react';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import { debounce } from 'lodash';
|
||||
import { AppState } from '@/store';
|
||||
|
||||
interface Props {
|
||||
serverUrl: string;
|
||||
onCredentialsRetrieved: (credentials: AppState['settings']['credentials']) => void;
|
||||
onCredentialsRetrieved: (credentials: {
|
||||
uri: string;
|
||||
user_id: string;
|
||||
access_token: string;
|
||||
device_id: string;
|
||||
type: 'emby' | 'jellyfin';
|
||||
}) => void;
|
||||
}
|
||||
|
||||
type CredentialEventData = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useCallback } from 'react';
|
||||
import { Button, View } from 'react-native';
|
||||
import Modal from '@/components/Modal';
|
||||
import Input from '@/components/Input';
|
||||
import { setCredentials } from '@/store/settings/db';
|
||||
import { setCredentials } from '@/store/settings/actions';
|
||||
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||
import CredentialGenerator from './components/CredentialGenerator';
|
||||
import { t } from '@/localisation';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Album-Artists relation table (many-to-many)
|
||||
*/
|
||||
export const albumArtists = sqliteTable('album_artists', {
|
||||
const albumArtists = sqliteTable('album_artists', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
albumId: text('album_id').notNull(),
|
||||
artistId: text('artist_id').notNull(),
|
||||
@@ -13,3 +13,5 @@ export const albumArtists = sqliteTable('album_artists', {
|
||||
pk: primaryKey({ columns: [table.sourceId, table.albumId, table.artistId] }),
|
||||
sourceArtistIdx: index('album_artists_source_artist_idx').on(table.sourceId, table.artistId),
|
||||
}));
|
||||
|
||||
export default albumArtists;
|
||||
@@ -1,13 +1,15 @@
|
||||
import { sqliteTable, text, primaryKey } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Album-Similar relation table (for similar albums)
|
||||
*/
|
||||
export const albumSimilar = sqliteTable('album_similar', {
|
||||
const albumSimilar = sqliteTable('album_similar', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
albumId: text('album_id').notNull(),
|
||||
similarAlbumId: text('similar_album_id').notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.sourceId, table.albumId, table.similarAlbumId] }),
|
||||
}));
|
||||
|
||||
export default albumSimilar;
|
||||
@@ -2,8 +2,8 @@
|
||||
* Database actions for albums
|
||||
*/
|
||||
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { albums } from './albums';
|
||||
import { db, sqliteDb } from '@/store';
|
||||
import albums from './entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { InsertAlbum } from './types';
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from '../db/schema/sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Albums table
|
||||
*/
|
||||
export const albums = sqliteTable('albums', {
|
||||
const albums = sqliteTable('albums', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
@@ -20,3 +20,5 @@ export const albums = sqliteTable('albums', {
|
||||
sourceNameIdx: index('albums_source_name_idx').on(table.sourceId, table.name),
|
||||
sourceYearIdx: index('albums_source_year_idx').on(table.sourceId, table.productionYear),
|
||||
}));
|
||||
|
||||
export default albums;
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { albums } from './albums';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import albums from './entity';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import type { Album } from './types';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { albums } from './albums';
|
||||
import albums from './entity';
|
||||
|
||||
export type Album = InferSelectModel<typeof albums>;
|
||||
export type InsertAlbum = typeof albums.$inferInsert;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Database actions for artists
|
||||
*/
|
||||
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { artists } from './artists';
|
||||
import { db, sqliteDb } from '@/store';
|
||||
import artists from './entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { InsertArtist } from './types';
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from '../db/schema/sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Artists table
|
||||
*/
|
||||
export const artists = sqliteTable('artists', {
|
||||
const artists = sqliteTable('artists', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
@@ -15,3 +15,5 @@ export const artists = sqliteTable('artists', {
|
||||
}, (table) => ({
|
||||
sourceNameIdx: index('artists_source_name_idx').on(table.sourceId, table.name),
|
||||
}));
|
||||
|
||||
export default artists;
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { artists } from './artists';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import artists from './entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Artist } from './types';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { artists } from './artists';
|
||||
import artists from './entity';
|
||||
|
||||
export type Artist = InferSelectModel<typeof artists>;
|
||||
export type InsertArtist = typeof artists.$inferInsert;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/op-sqlite';
|
||||
import { open } from '@op-engineering/op-sqlite';
|
||||
import { migrate } from 'drizzle-orm/op-sqlite/migrator';
|
||||
import migrations from './migrations/migrations.js';
|
||||
|
||||
// Import all schema tables
|
||||
import { sources } from './schema/sources';
|
||||
import { appSettings } from './schema/app-settings';
|
||||
import { sleepTimer } from './schema/sleep-timer';
|
||||
import { artists } from './schema/artists';
|
||||
import { albums } from './schema/albums';
|
||||
import { tracks } from './schema/tracks';
|
||||
import { playlists } from './schema/playlists';
|
||||
import { downloads } from './schema/downloads';
|
||||
import { searchQueries } from './schema/search-queries';
|
||||
import { albumArtists } from './schema/album-artists';
|
||||
import { trackArtists } from './schema/track-artists';
|
||||
import { playlistTracks } from './schema/playlist-tracks';
|
||||
import { albumSimilar } from './schema/album-similar';
|
||||
import { syncCursors } from './schema/sync-cursors';
|
||||
|
||||
// Combined schema for drizzle
|
||||
const schema = {
|
||||
sources,
|
||||
appSettings,
|
||||
sleepTimer,
|
||||
artists,
|
||||
albums,
|
||||
tracks,
|
||||
playlists,
|
||||
downloads,
|
||||
searchQueries,
|
||||
albumArtists,
|
||||
trackArtists,
|
||||
playlistTracks,
|
||||
albumSimilar,
|
||||
syncCursors,
|
||||
};
|
||||
|
||||
// Open the SQLite database
|
||||
export const sqliteDb = open({
|
||||
name: 'fintunes.db',
|
||||
location: '../databases',
|
||||
});
|
||||
|
||||
// Create drizzle instance with schema - exported as singleton
|
||||
export const db = drizzle(sqliteDb, { schema });
|
||||
|
||||
/**
|
||||
* Run database migrations
|
||||
* Migrations should be generated using drizzle-kit
|
||||
*/
|
||||
export async function runMigrations() {
|
||||
try {
|
||||
await migrate(db, migrations);
|
||||
console.log('Database migrations completed');
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database
|
||||
*/
|
||||
export async function initializeDatabase() {
|
||||
await runMigrations();
|
||||
return db;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
|
||||
/**
|
||||
* Albums table
|
||||
*/
|
||||
export const albums = sqliteTable('albums', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
productionYear: integer('production_year'),
|
||||
isFolder: integer('is_folder', { mode: 'boolean' }).notNull(),
|
||||
albumArtist: text('album_artist'),
|
||||
dateCreated: integer('date_created'),
|
||||
lastRefreshed: integer('last_refreshed'),
|
||||
metadataJson: text('metadata_json'), // JSON-encoded additional fields
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
sourceNameIdx: index('albums_source_name_idx').on(table.sourceId, table.name),
|
||||
sourceYearIdx: index('albums_source_year_idx').on(table.sourceId, table.productionYear),
|
||||
}));
|
||||
@@ -1,17 +0,0 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
|
||||
/**
|
||||
* Artists table
|
||||
*/
|
||||
export const artists = sqliteTable('artists', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
isFolder: integer('is_folder', { mode: 'boolean' }).notNull(),
|
||||
metadataJson: text('metadata_json'), // JSON-encoded additional fields
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
sourceNameIdx: index('artists_source_name_idx').on(table.sourceId, table.name),
|
||||
}));
|
||||
@@ -1,18 +0,0 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
|
||||
/**
|
||||
* Search queries table
|
||||
*/
|
||||
export const searchQueries = sqliteTable('search_queries', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
query: text('query').notNull(),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
localPlaybackOnly: integer('local_playback_only', { mode: 'boolean' }).notNull(),
|
||||
metadataJson: text('metadata_json'), // JSON-encoded additional fields
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
sourceTimestampIdx: index('search_queries_source_timestamp_idx').on(table.sourceId, table.timestamp),
|
||||
}));
|
||||
+8
-37
@@ -1,39 +1,10 @@
|
||||
/**
|
||||
* Database Schema Types
|
||||
*
|
||||
* Re-exports types from entity modules for convenience
|
||||
*/
|
||||
|
||||
export type { Album, InsertAlbum } from '../albums/types';
|
||||
export type { Artist, InsertArtist } from '../artists/types';
|
||||
export type { Track, InsertTrack } from '../tracks/types';
|
||||
export type { Playlist, InsertPlaylist } from '../playlists/types';
|
||||
export type { Download, InsertDownload } from '../downloads/types';
|
||||
export type { SearchQuery, InsertSearchQuery } from '../search-queries/types';
|
||||
export type { SleepTimer, InsertSleepTimer } from '../sleep-timer/types';
|
||||
|
||||
// Re-export relationship and other schema types
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { sources } from './schema/sources';
|
||||
import { albumArtists } from './schema/album-artists';
|
||||
import { trackArtists } from './schema/track-artists';
|
||||
import { playlistTracks } from './schema/playlist-tracks';
|
||||
import { albumSimilar } from './schema/album-similar';
|
||||
import { syncCursors } from './schema/sync-cursors';
|
||||
import { appSettings } from './schema/app-settings';
|
||||
import artists from '../artists/entity';
|
||||
import albums from '../albums/entity';
|
||||
import tracks from '../tracks/entity';
|
||||
import playlists from '../playlists/entity';
|
||||
|
||||
export type Source = InferSelectModel<typeof sources>;
|
||||
export type AlbumArtist = InferSelectModel<typeof albumArtists>;
|
||||
export type TrackArtist = InferSelectModel<typeof trackArtists>;
|
||||
export type PlaylistTrack = InferSelectModel<typeof playlistTracks>;
|
||||
export type AlbumSimilar = InferSelectModel<typeof albumSimilar>;
|
||||
export type SyncCursor = InferSelectModel<typeof syncCursors>;
|
||||
export type AppSettings = InferSelectModel<typeof appSettings>;
|
||||
|
||||
export type InsertSource = typeof sources.$inferInsert;
|
||||
export type InsertAlbumArtist = typeof albumArtists.$inferInsert;
|
||||
export type InsertTrackArtist = typeof trackArtists.$inferInsert;
|
||||
export type InsertPlaylistTrack = typeof playlistTracks.$inferInsert;
|
||||
export type InsertAlbumSimilar = typeof albumSimilar.$inferInsert;
|
||||
export type InsertSyncCursor = typeof syncCursors.$inferInsert;
|
||||
export type InsertAppSettings = typeof appSettings.$inferInsert;
|
||||
export type Artist = InferSelectModel<typeof artists>;
|
||||
export type Album = InferSelectModel<typeof albums>;
|
||||
export type Track = InferSelectModel<typeof tracks>;
|
||||
export type Playlist = InferSelectModel<typeof playlists>;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Hook to get the current source ID from the database
|
||||
* Returns the first source's ID or an empty string if no source exists
|
||||
*/
|
||||
import { useLiveQuery } from './live-queries';
|
||||
import { db } from './index';
|
||||
import { sources } from './schema/sources';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function useSourceId(): string {
|
||||
const { data: sourceData } = useLiveQuery(db.select().from(sources).limit(1));
|
||||
|
||||
return useMemo(() => {
|
||||
return (sourceData?.[0] as typeof sources.$inferSelect | undefined)?.id || '';
|
||||
}, [sourceData]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { downloads } from './downloads';
|
||||
import { db, sqliteDb } from '@/store';
|
||||
import downloads from './entity';
|
||||
import type { Download } from './types';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from '../db/schema/sources';
|
||||
|
||||
/**
|
||||
* Downloads table
|
||||
*/
|
||||
export const downloads = sqliteTable('downloads', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
hash: text('hash'),
|
||||
filename: text('filename'),
|
||||
mimetype: text('mimetype'),
|
||||
progress: integer('progress'),
|
||||
isFailed: integer('is_failed', { mode: 'boolean' }).notNull(),
|
||||
isComplete: integer('is_complete', { mode: 'boolean' }).notNull(),
|
||||
metadataJson: text('metadata_json'), // JSON-encoded additional fields
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Downloads table
|
||||
*/
|
||||
export const downloads = sqliteTable('downloads', {
|
||||
const downloads = sqliteTable('downloads', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
hash: text('hash'),
|
||||
@@ -17,3 +17,5 @@ export const downloads = sqliteTable('downloads', {
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export default downloads;
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { downloads } from './downloads';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import downloads from './entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Download } from './types';
|
||||
|
||||
@@ -23,11 +23,14 @@ export function useDownloads(sourceId?: string) {
|
||||
|
||||
(data || []).forEach(download => {
|
||||
const d = download as Download;
|
||||
entities[d.id] = d;
|
||||
ids.push(d.id);
|
||||
const metadata = d.metadataJson ? JSON.parse(d.metadataJson) : {};
|
||||
const normalized = { ...d, ...metadata } as Download;
|
||||
|
||||
entities[normalized.id] = normalized;
|
||||
ids.push(normalized.id);
|
||||
|
||||
if (!d.isComplete && !d.isFailed) {
|
||||
queued.push(d.id);
|
||||
if (!normalized.isComplete && !normalized.isFailed) {
|
||||
queued.push(normalized.id);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,110 +1,113 @@
|
||||
/**
|
||||
* Download queue manager
|
||||
* Manages queueing and executing track downloads
|
||||
*/
|
||||
|
||||
import { DocumentDirectoryPath, downloadFile, unlink, exists } from 'react-native-fs';
|
||||
import { getActiveSource } from '@/store/settings/db';
|
||||
import { db } from '@/store/db';
|
||||
import { tracks } from '@/store/tracks/tracks';
|
||||
import { downloads } from './downloads';
|
||||
import { DocumentDirectoryPath, downloadFile, exists, unlink } from 'react-native-fs';
|
||||
import { db } from '@/store';
|
||||
import sources from '@/store/sources/entity';
|
||||
import downloads from '@/store/downloads/entity';
|
||||
import { JellyfinDriver } from '@/store/sources/drivers/jellyfin/driver';
|
||||
import { EmbyDriver } from '@/store/sources/drivers/emby/driver';
|
||||
import type { Source, SourceDriver, SourceType } from '@/store/sources/types';
|
||||
import { initializeDownload, updateDownloadProgress, completeDownload, failDownload, removeDownload } from './actions';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
|
||||
import { getImage } from '@/utility/JellyfinApi/lib';
|
||||
import { getExtensionForUrl } from '@/utility/mimeType';
|
||||
import {
|
||||
initializeDownload,
|
||||
updateDownloadProgress,
|
||||
completeDownload,
|
||||
failDownload,
|
||||
removeDownload as dbRemoveDownload
|
||||
} from './actions';
|
||||
|
||||
async function getDriver(): Promise<{ driver: SourceDriver; source: Source } | null> {
|
||||
const result = await db.select().from(sources).limit(1);
|
||||
const row = result[0];
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source: Source = {
|
||||
id: row.id,
|
||||
uri: row.uri,
|
||||
userId: row.userId || undefined,
|
||||
accessToken: row.accessToken || undefined,
|
||||
deviceId: row.deviceId || undefined,
|
||||
type: row.type as SourceType,
|
||||
};
|
||||
|
||||
if (source.type.startsWith('jellyfin')) {
|
||||
return { driver: new JellyfinDriver(source), source };
|
||||
}
|
||||
|
||||
if (source.type.startsWith('emby')) {
|
||||
return { driver: new EmbyDriver(source), source };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateDownloadMetadata(id: string, updates: Record<string, unknown>): Promise<void> {
|
||||
const existing = await db.select().from(downloads).where(eq(downloads.id, id)).limit(1);
|
||||
const current = existing[0];
|
||||
const currentMetadata = current?.metadataJson ? JSON.parse(current.metadataJson) : {};
|
||||
|
||||
await db.update(downloads)
|
||||
.set({
|
||||
metadataJson: JSON.stringify({ ...currentMetadata, ...updates }),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(downloads.id, id));
|
||||
}
|
||||
|
||||
export async function queueTrackForDownload(trackId: string): Promise<void> {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
await initializeDownload(source.id, trackId);
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initializeDownload(driverResult.source.id, trackId);
|
||||
}
|
||||
|
||||
export async function downloadTrack(trackId: string): Promise<void> {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver } = driverResult;
|
||||
|
||||
try {
|
||||
const trackData = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, trackId))
|
||||
.limit(1);
|
||||
|
||||
const track = trackData[0];
|
||||
if (!track) {
|
||||
await failDownload(trackId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse metadata if needed for image URL
|
||||
let metadata: any = {};
|
||||
try {
|
||||
metadata = track.metadataJson ? JSON.parse(track.metadataJson as string) : {};
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse track metadata:', error);
|
||||
}
|
||||
|
||||
const trackWithMetadata = { ...track, ...metadata };
|
||||
|
||||
const audioUrl = generateTrackUrl(trackId);
|
||||
const imageUrl = getImage(trackWithMetadata);
|
||||
|
||||
const [audioExt, imageExt] = await Promise.all([
|
||||
getExtensionForUrl(audioUrl),
|
||||
imageUrl ? getExtensionForUrl(imageUrl).catch(() => null) : null
|
||||
]);
|
||||
|
||||
const audioLocation = `${DocumentDirectoryPath}/${trackId}.${audioExt}`;
|
||||
const imageLocation = imageExt ? `${DocumentDirectoryPath}/${trackId}.${imageExt}` : undefined;
|
||||
|
||||
const { promise: audioPromise } = downloadFile({
|
||||
fromUrl: audioUrl,
|
||||
progressInterval: 1000,
|
||||
const info = await driver.getDownloadInfo(trackId);
|
||||
const destination = `${DocumentDirectoryPath}/${info.filename}`;
|
||||
|
||||
await updateDownloadMetadata(trackId, { size: null, error: null });
|
||||
|
||||
const job = downloadFile({
|
||||
fromUrl: info.url,
|
||||
toFile: destination,
|
||||
background: true,
|
||||
begin: () => {
|
||||
updateDownloadProgress(trackId, 0);
|
||||
progress: (event) => {
|
||||
if (!event.contentLength) {
|
||||
return;
|
||||
}
|
||||
const progress = event.bytesWritten / event.contentLength;
|
||||
updateDownloadProgress(trackId, progress);
|
||||
updateDownloadMetadata(trackId, { size: event.contentLength });
|
||||
},
|
||||
progress: (result) => {
|
||||
updateDownloadProgress(trackId, result.bytesWritten / result.contentLength);
|
||||
},
|
||||
toFile: audioLocation,
|
||||
});
|
||||
|
||||
const { promise: imagePromise } = imageExt && imageLocation
|
||||
? downloadFile({
|
||||
fromUrl: imageUrl!,
|
||||
toFile: imageLocation,
|
||||
background: true,
|
||||
})
|
||||
: { promise: Promise.resolve(null) };
|
||||
|
||||
await Promise.all([audioPromise, imagePromise]);
|
||||
await completeDownload(trackId, audioLocation);
|
||||
|
||||
await job.promise;
|
||||
await completeDownload(trackId, destination);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await updateDownloadMetadata(trackId, { error: message });
|
||||
await failDownload(trackId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDownloadedTrack(trackId: string): Promise<void> {
|
||||
const downloadData = await db
|
||||
.select()
|
||||
.from(downloads)
|
||||
.where(eq(downloads.id, trackId))
|
||||
.limit(1);
|
||||
|
||||
const download = downloadData[0];
|
||||
|
||||
if (download?.filename && await exists(download.filename)) {
|
||||
await unlink(download.filename);
|
||||
const downloadRow = await db.select().from(downloads).where(eq(downloads.id, trackId)).limit(1);
|
||||
const entry = downloadRow[0];
|
||||
|
||||
if (entry?.filename) {
|
||||
const filePath = entry.filename.startsWith('/')
|
||||
? entry.filename
|
||||
: `${DocumentDirectoryPath}/${entry.filename}`;
|
||||
|
||||
if (await exists(filePath)) {
|
||||
await unlink(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
await dbRemoveDownload(trackId);
|
||||
|
||||
await removeDownload(trackId);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
*/
|
||||
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { downloads } from './downloads';
|
||||
import downloads from './entity';
|
||||
|
||||
export type Download = InferSelectModel<typeof downloads>;
|
||||
export type Download = InferSelectModel<typeof downloads> & {
|
||||
image?: string | null;
|
||||
location?: string | null;
|
||||
size?: number | null;
|
||||
error?: string | null;
|
||||
};
|
||||
export type InsertDownload = typeof downloads.$inferInsert;
|
||||
|
||||
+61
-48
@@ -1,56 +1,69 @@
|
||||
import { configureStore, combineReducers } from '@reduxjs/toolkit';
|
||||
import { useSelector, TypedUseSelectorHook, useDispatch } from 'react-redux';
|
||||
import { persistStore, persistReducer, PersistConfig, createMigrate, PersistState } from 'redux-persist';
|
||||
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';
|
||||
import { drizzle } from 'drizzle-orm/op-sqlite';
|
||||
import { open } from '@op-engineering/op-sqlite';
|
||||
import { migrate } from 'drizzle-orm/op-sqlite/migrator';
|
||||
import migrations from './db/migrations/migrations.js';
|
||||
|
||||
import settings from './settings';
|
||||
import sleepTimer from './sleep-timer';
|
||||
import search from './search';
|
||||
import { ColorScheme } from './settings/types';
|
||||
import MigratedStorage from '@/utility/MigratedStorage';
|
||||
// Import all schema tables
|
||||
import sources from './sources/entity';
|
||||
import appSettings from './settings/entity.js';
|
||||
import sleepTimer from './sleep-timer/entity';
|
||||
import artists from './artists/entity';
|
||||
import albums from './albums/entity';
|
||||
import tracks from './tracks/entity';
|
||||
import playlists from './playlists/entity';
|
||||
import downloads from './downloads/entity';
|
||||
import searchQueries from './search-queries/entity';
|
||||
import albumArtists from './album-artists/entity';
|
||||
import trackArtists from './track-artists/entity';
|
||||
import playlistTracks from './playlist-tracks/entity';
|
||||
import albumSimilar from './album-similar/entity';
|
||||
import syncCursors from './sync-cursors/entity';
|
||||
|
||||
const persistConfig: PersistConfig<Omit<AppState, '_persist'>> = {
|
||||
key: 'root',
|
||||
storage: MigratedStorage,
|
||||
version: 6,
|
||||
stateReconciler: autoMergeLevel2,
|
||||
migrate: createMigrate({
|
||||
// @ts-expect-error migrations are poorly typed
|
||||
6: (state: AppState & PersistState) => {
|
||||
// Migration v6: Remove music and downloads from Redux
|
||||
// These are now database-backed only. Intentionally discarding
|
||||
// old Redux state as data is persisted in SQLite database.
|
||||
return {
|
||||
settings: state.settings,
|
||||
sleepTimer: state.sleepTimer,
|
||||
search: state.search,
|
||||
};
|
||||
},
|
||||
})
|
||||
// Combined schema for drizzle
|
||||
const schema = {
|
||||
sources,
|
||||
appSettings,
|
||||
sleepTimer,
|
||||
artists,
|
||||
albums,
|
||||
tracks,
|
||||
playlists,
|
||||
downloads,
|
||||
searchQueries,
|
||||
albumArtists,
|
||||
trackArtists,
|
||||
playlistTracks,
|
||||
albumSimilar,
|
||||
syncCursors,
|
||||
};
|
||||
|
||||
const reducers = combineReducers({
|
||||
settings,
|
||||
sleepTimer: sleepTimer.reducer,
|
||||
search: search.reducer,
|
||||
// Open the SQLite database
|
||||
export const sqliteDb = open({
|
||||
name: 'fintunes.db',
|
||||
location: '../databases',
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, reducers);
|
||||
// Create drizzle instance with schema - exported as singleton
|
||||
export const db = drizzle(sqliteDb, { schema });
|
||||
|
||||
const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: (getDefaultMiddleware) => (
|
||||
getDefaultMiddleware({ serializableCheck: false, immutableCheck: false })
|
||||
),
|
||||
});
|
||||
/**
|
||||
* Run database migrations
|
||||
* Migrations should be generated using drizzle-kit
|
||||
*/
|
||||
export async function runMigrations() {
|
||||
try {
|
||||
await migrate(db, migrations);
|
||||
console.log('Database migrations completed');
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type AppState = ReturnType<typeof reducers> & { _persist: PersistState };
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch };
|
||||
export type Store = typeof store;
|
||||
export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
|
||||
export const persistedStore = persistStore(store);
|
||||
|
||||
export default store;
|
||||
/**
|
||||
* Initialize the database
|
||||
*/
|
||||
export async function initializeDatabase() {
|
||||
await runMigrations();
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { sqliteDb } from '@/store/db'
|
||||
import { sqliteDb } from '@/store'
|
||||
import { QueryPromise } from 'drizzle-orm';
|
||||
|
||||
type DrizzleQuery = {
|
||||
type DrizzleQuery<T> = QueryPromise<T> &{
|
||||
toSQL: () => { sql: string; params: unknown[] };
|
||||
then: (onfulfilled?: (value: any) => any) => Promise<any>;
|
||||
};
|
||||
|
||||
type UseLiveQueryResult<T> = {
|
||||
data: T[];
|
||||
data: T | null;
|
||||
error: Error | undefined;
|
||||
};
|
||||
|
||||
@@ -54,9 +54,9 @@ function extractTableNames(sql: string): string[] {
|
||||
* https://github.com/drizzle-team/drizzle-orm/issues/2926
|
||||
*/
|
||||
export function useLiveQuery<T>(
|
||||
query: DrizzleQuery | undefined | null
|
||||
query: DrizzleQuery<T> | undefined | null
|
||||
): UseLiveQueryResult<T> {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -88,7 +88,7 @@ export function useLiveQuery<T>(
|
||||
|
||||
// Initial fetch via drizzle (preserves ORM transformations)
|
||||
query
|
||||
.then((result: T[]) => {
|
||||
.then((result: T) => {
|
||||
setData(result);
|
||||
setError(undefined);
|
||||
})
|
||||
@@ -104,7 +104,7 @@ export function useLiveQuery<T>(
|
||||
fireOn,
|
||||
callback: (response) => {
|
||||
// response.rows contains raw row data from reactive callback
|
||||
setData(response.rows as T[]);
|
||||
setData(response.rows as T);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -1,142 +0,0 @@
|
||||
import { AsyncThunkPayloadCreator, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { Album, AlbumTrack, CodecMetadata, Lyrics, Playlist, MusicArtist } from './types';
|
||||
import type { AsyncThunkAPI } from '..';
|
||||
import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album';
|
||||
import { retrieveAllPlaylists, retrievePlaylistTracks, retrieveInstantMixByTrackId } from '@/utility/JellyfinApi/playlist';
|
||||
import { retrieveAllArtists } from '@/utility/JellyfinApi/artist';
|
||||
import { searchItem, SearchResult } from '@/utility/JellyfinApi/search';
|
||||
import { retrieveTrackLyrics } from '@/utility/JellyfinApi/lyrics';
|
||||
import { retrieveTrackCodecMetadata } from '@/utility/JellyfinApi/track';
|
||||
|
||||
|
||||
export const albumAdapter = createEntityAdapter<Album, string>({
|
||||
selectId: album => album.Id,
|
||||
sortComparer: (a, b) => a.Name.localeCompare(b.Name),
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch lyrics for a given track
|
||||
*/
|
||||
export const fetchLyricsByTrack = createAsyncThunk<Lyrics, string, AsyncThunkAPI>(
|
||||
'/track/lyrics',
|
||||
retrieveTrackLyrics,
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch codec metadata for a given track
|
||||
*/
|
||||
export const fetchCodecMetadataByTrack = createAsyncThunk<CodecMetadata, string, AsyncThunkAPI>(
|
||||
'/track/codecMetadata',
|
||||
retrieveTrackCodecMetadata,
|
||||
);
|
||||
|
||||
/** A generic type for any action that retrieves tracks */
|
||||
type AlbumTrackPayloadCreator = AsyncThunkPayloadCreator<AlbumTrack[], string, AsyncThunkAPI>;
|
||||
|
||||
/**
|
||||
* This is a wrapper that postprocesses any tracks, so that we can also support
|
||||
* lyrics, codec metadata and potential other applications.
|
||||
*/
|
||||
export const postProcessTracks = function(creator: AlbumTrackPayloadCreator): AlbumTrackPayloadCreator {
|
||||
// Return a new payload creator
|
||||
return async (args, thunkAPI) => {
|
||||
// Retrieve the tracks using the original creator
|
||||
const tracks = await creator(args, thunkAPI);
|
||||
|
||||
// GUARD: Check if we've retrieved any tracks
|
||||
if (Array.isArray(tracks)) {
|
||||
// If so, attempt to retrieve lyrics for the tracks that have them
|
||||
tracks.filter((t) => t.HasLyrics)
|
||||
.forEach((t) => thunkAPI.dispatch(fetchLyricsByTrack(t.Id)));
|
||||
|
||||
// Also, retrieve codec metadata
|
||||
tracks.forEach((t) => thunkAPI.dispatch(fetchCodecMetadataByTrack(t.Id)));
|
||||
}
|
||||
|
||||
return tracks;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Fetch all albums available on the jellyfin server
|
||||
*/
|
||||
export const fetchAllAlbums = createAsyncThunk<Album[], undefined, AsyncThunkAPI>(
|
||||
'/albums/all',
|
||||
retrieveAllAlbums,
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieve the most recent albums
|
||||
*/
|
||||
export const fetchRecentAlbums = createAsyncThunk<Album[], number | undefined, AsyncThunkAPI>(
|
||||
'/albums/recent',
|
||||
retrieveRecentAlbums,
|
||||
);
|
||||
|
||||
export const trackAdapter = createEntityAdapter<AlbumTrack, string>({
|
||||
selectId: track => track.Id,
|
||||
sortComparer: (a, b) => a.IndexNumber - b.IndexNumber,
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve all tracks from a particular album
|
||||
*/
|
||||
export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
|
||||
'/tracks/byAlbum',
|
||||
postProcessTracks(retrieveAlbumTracks),
|
||||
);
|
||||
|
||||
export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>(
|
||||
'/albums/single',
|
||||
retrieveAlbum,
|
||||
);
|
||||
|
||||
export const fetchSimilarAlbums = createAsyncThunk<Album[], string, AsyncThunkAPI>(
|
||||
'/albums/similar',
|
||||
retrieveSimilarAlbums,
|
||||
);
|
||||
|
||||
export const searchAndFetch = createAsyncThunk<SearchResult[], { term: string, limit?: number }, AsyncThunkAPI>(
|
||||
'/search',
|
||||
async ({ term, limit = 24 }) => searchItem(term, limit)
|
||||
);
|
||||
|
||||
export const playlistAdapter = createEntityAdapter<Playlist, string>({
|
||||
selectId: (playlist) => playlist.Id,
|
||||
sortComparer: (a, b) => a.Name.localeCompare(b.Name),
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all playlists available
|
||||
*/
|
||||
export const fetchAllPlaylists = createAsyncThunk<Playlist[], undefined, AsyncThunkAPI>(
|
||||
'/playlists/all',
|
||||
retrieveAllPlaylists,
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieve all tracks from a particular playlist
|
||||
*/
|
||||
export const fetchTracksByPlaylist = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
|
||||
'/tracks/byPlaylist',
|
||||
postProcessTracks(retrievePlaylistTracks)
|
||||
);
|
||||
|
||||
export const artistAdapter = createEntityAdapter<MusicArtist, string>({
|
||||
selectId: artist => artist.Id,
|
||||
sortComparer: (a, b) => a.Name.localeCompare(b.Name)
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all albums available on the jellyfin server
|
||||
*/
|
||||
export const fetchAllArtists = createAsyncThunk<MusicArtist[], undefined, AsyncThunkAPI>(
|
||||
'/artists/all',
|
||||
retrieveAllArtists
|
||||
);
|
||||
|
||||
export const fetchInstantMixByTrackId = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
|
||||
'/instantMix/byTrackId',
|
||||
(trackId: string) => retrieveInstantMixByTrackId(trackId)
|
||||
);
|
||||
@@ -1,498 +0,0 @@
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { albums } from '@/store/albums/albums';
|
||||
import { artists } from '@/store/artists/artists';
|
||||
import { tracks } from '@/store/tracks/tracks';
|
||||
import { playlists } from '@/store/playlists/playlists';
|
||||
import { albumArtists } from '@/store/db/schema/album-artists';
|
||||
import { trackArtists } from '@/store/db/schema/track-artists';
|
||||
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
|
||||
import { albumSimilar } from '@/store/db/schema/album-similar';
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import type { Album, AlbumTrack, MusicArtist, Playlist } from './types';
|
||||
|
||||
/**
|
||||
* ALBUMS
|
||||
*/
|
||||
|
||||
export async function getAllAlbums() {
|
||||
const result = await db.select().from(albums);
|
||||
return result.map(album => enrichAlbum(album));
|
||||
}
|
||||
|
||||
export async function getAlbum(id: string) {
|
||||
const result = await db.select().from(albums).where(eq(albums.id, id)).limit(1);
|
||||
if (!result[0]) return undefined;
|
||||
return enrichAlbum(result[0]);
|
||||
}
|
||||
|
||||
export async function getRecentAlbums(limit: number = 50) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(albums)
|
||||
.orderBy(desc(albums.dateCreated))
|
||||
.limit(limit);
|
||||
return result.map(album => enrichAlbum(album));
|
||||
}
|
||||
|
||||
export async function upsertAlbum(sourceId: string, album: Album) {
|
||||
const now = Date.now();
|
||||
const metadata = extractAlbumMetadata(album);
|
||||
|
||||
await db.insert(albums).values({
|
||||
sourceId,
|
||||
id: album.Id,
|
||||
name: album.Name,
|
||||
productionYear: album.ProductionYear || null,
|
||||
isFolder: album.IsFolder,
|
||||
albumArtist: album.AlbumArtist || null,
|
||||
dateCreated: album.DateCreated ? new Date(album.DateCreated).getTime() : now,
|
||||
lastRefreshed: now,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: albums.id,
|
||||
set: {
|
||||
name: album.Name,
|
||||
productionYear: album.ProductionYear || null,
|
||||
albumArtist: album.AlbumArtist || null,
|
||||
lastRefreshed: now,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
// Update album-artist relations
|
||||
await updateAlbumArtists(sourceId, album.Id, album.AlbumArtists);
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
export async function upsertAlbums(sourceId: string, albumList: Album[]) {
|
||||
for (const album of albumList) {
|
||||
await upsertAlbum(sourceId, album);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSimilarAlbums(albumId: string) {
|
||||
const relations = await db
|
||||
.select()
|
||||
.from(albumSimilar)
|
||||
.where(eq(albumSimilar.albumId, albumId));
|
||||
|
||||
if (relations.length === 0) return [];
|
||||
|
||||
const similarIds = relations.map(r => r.similarAlbumId);
|
||||
const result = await db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(inArray(albums.id, similarIds));
|
||||
|
||||
return result.map(album => enrichAlbum(album));
|
||||
}
|
||||
|
||||
export async function setSimilarAlbums(sourceId: string, albumId: string, similarAlbumIds: string[]) {
|
||||
// Delete existing relations
|
||||
await db.delete(albumSimilar).where(eq(albumSimilar.albumId, albumId));
|
||||
|
||||
// Insert new relations
|
||||
if (similarAlbumIds.length > 0) {
|
||||
await db.insert(albumSimilar).values(
|
||||
similarAlbumIds.map(similarId => ({
|
||||
sourceId,
|
||||
albumId,
|
||||
similarAlbumId: similarId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* ARTISTS
|
||||
*/
|
||||
|
||||
export async function getAllArtists() {
|
||||
const result = await db.select().from(artists);
|
||||
return result.map(artist => enrichArtist(artist));
|
||||
}
|
||||
|
||||
export async function upsertArtist(sourceId: string, artist: MusicArtist) {
|
||||
const now = Date.now();
|
||||
const metadata = extractArtistMetadata(artist);
|
||||
|
||||
await db.insert(artists).values({
|
||||
sourceId,
|
||||
id: artist.Id,
|
||||
name: artist.Name,
|
||||
isFolder: artist.IsFolder,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: artists.id,
|
||||
set: {
|
||||
name: artist.Name,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
export async function upsertArtists(sourceId: string, artistList: MusicArtist[]) {
|
||||
for (const artist of artistList) {
|
||||
await upsertArtist(sourceId, artist);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TRACKS
|
||||
*/
|
||||
|
||||
export async function getTracksByAlbum(albumId: string) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.albumId, albumId));
|
||||
return result.map(track => enrichTrack(track));
|
||||
}
|
||||
|
||||
export async function getTracksByPlaylist(playlistId: string) {
|
||||
const relations = await db
|
||||
.select()
|
||||
.from(playlistTracks)
|
||||
.where(eq(playlistTracks.playlistId, playlistId));
|
||||
|
||||
if (relations.length === 0) return [];
|
||||
|
||||
const trackIds = relations.map(r => r.trackId);
|
||||
const result = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(inArray(tracks.id, trackIds));
|
||||
|
||||
// Sort by position in playlist
|
||||
const trackMap = new Map(result.map(t => [t.id, t]));
|
||||
return relations
|
||||
.sort((a, b) => (a.position || 0) - (b.position || 0))
|
||||
.map(r => trackMap.get(r.trackId))
|
||||
.filter(Boolean)
|
||||
.map(track => enrichTrack(track!));
|
||||
}
|
||||
|
||||
export async function getTrack(id: string) {
|
||||
const result = await db.select().from(tracks).where(eq(tracks.id, id)).limit(1);
|
||||
if (!result[0]) return undefined;
|
||||
return enrichTrack(result[0]);
|
||||
}
|
||||
|
||||
export async function upsertTrack(sourceId: string, track: AlbumTrack) {
|
||||
const now = Date.now();
|
||||
const metadata = extractTrackMetadata(track);
|
||||
|
||||
await db.insert(tracks).values({
|
||||
sourceId,
|
||||
id: track.Id,
|
||||
name: track.Name,
|
||||
albumId: track.AlbumId || null,
|
||||
album: track.Album || null,
|
||||
albumArtist: track.AlbumArtist || null,
|
||||
productionYear: track.ProductionYear || null,
|
||||
indexNumber: track.IndexNumber || null,
|
||||
parentIndexNumber: track.ParentIndexNumber || null,
|
||||
hasLyrics: track.HasLyrics || false,
|
||||
runTimeTicks: track.RunTimeTicks || null,
|
||||
lyrics: track.Lyrics ? JSON.stringify(track.Lyrics) : null,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: tracks.id,
|
||||
set: {
|
||||
name: track.Name,
|
||||
album: track.Album || null,
|
||||
albumArtist: track.AlbumArtist || null,
|
||||
hasLyrics: track.HasLyrics || false,
|
||||
lyrics: track.Lyrics ? JSON.stringify(track.Lyrics) : null,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
// Update track-artist relations
|
||||
await updateTrackArtists(sourceId, track.Id, track.ArtistItems);
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
export async function upsertTracks(sourceId: string, trackList: AlbumTrack[]) {
|
||||
for (const track of trackList) {
|
||||
await upsertTrack(sourceId, track);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTrackCodec(trackId: string, codec: any) {
|
||||
const track = await getTrack(trackId);
|
||||
if (!track) return;
|
||||
|
||||
const metadata = track.metadataJson ? JSON.parse(track.metadataJson) : {};
|
||||
metadata.Codec = codec;
|
||||
|
||||
await db.update(tracks)
|
||||
.set({
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(tracks.id, trackId));
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* PLAYLISTS
|
||||
*/
|
||||
|
||||
export async function getAllPlaylists() {
|
||||
const result = await db.select().from(playlists);
|
||||
return result.map(playlist => enrichPlaylist(playlist));
|
||||
}
|
||||
|
||||
export async function getPlaylist(id: string) {
|
||||
const result = await db.select().from(playlists).where(eq(playlists.id, id)).limit(1);
|
||||
if (!result[0]) return undefined;
|
||||
return enrichPlaylist(result[0]);
|
||||
}
|
||||
|
||||
export async function upsertPlaylist(sourceId: string, playlist: Playlist) {
|
||||
const now = Date.now();
|
||||
const metadata = extractPlaylistMetadata(playlist);
|
||||
|
||||
await db.insert(playlists).values({
|
||||
sourceId,
|
||||
id: playlist.Id,
|
||||
name: playlist.Name,
|
||||
canDelete: playlist.CanDelete,
|
||||
childCount: playlist.ChildCount || null,
|
||||
lastRefreshed: now,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: playlists.id,
|
||||
set: {
|
||||
name: playlist.Name,
|
||||
canDelete: playlist.CanDelete,
|
||||
childCount: playlist.ChildCount || null,
|
||||
lastRefreshed: now,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
export async function upsertPlaylists(sourceId: string, playlistList: Playlist[]) {
|
||||
for (const playlist of playlistList) {
|
||||
await upsertPlaylist(sourceId, playlist);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPlaylistTracks(sourceId: string, playlistId: string, trackIds: string[]) {
|
||||
// Delete existing relations
|
||||
await db.delete(playlistTracks).where(
|
||||
and(
|
||||
eq(playlistTracks.sourceId, sourceId),
|
||||
eq(playlistTracks.playlistId, playlistId)
|
||||
)
|
||||
);
|
||||
|
||||
// Insert new relations with positions
|
||||
if (trackIds.length > 0) {
|
||||
await db.insert(playlistTracks).values(
|
||||
trackIds.map((trackId, index) => ({
|
||||
sourceId,
|
||||
playlistId,
|
||||
trackId,
|
||||
position: index,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* HELPER FUNCTIONS
|
||||
*/
|
||||
|
||||
async function updateAlbumArtists(sourceId: string, albumId: string, albumArtistsList: any[]) {
|
||||
// Delete existing relations
|
||||
await db.delete(albumArtists).where(
|
||||
and(
|
||||
eq(albumArtists.sourceId, sourceId),
|
||||
eq(albumArtists.albumId, albumId)
|
||||
)
|
||||
);
|
||||
|
||||
// Insert new relations
|
||||
if (albumArtistsList && albumArtistsList.length > 0) {
|
||||
await db.insert(albumArtists).values(
|
||||
albumArtistsList.map((artist, index) => ({
|
||||
sourceId,
|
||||
albumId,
|
||||
artistId: artist.Id,
|
||||
orderIndex: index,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTrackArtists(sourceId: string, trackId: string, artistItems: any[]) {
|
||||
// Delete existing relations
|
||||
await db.delete(trackArtists).where(
|
||||
and(
|
||||
eq(trackArtists.sourceId, sourceId),
|
||||
eq(trackArtists.trackId, trackId)
|
||||
)
|
||||
);
|
||||
|
||||
// Insert new relations
|
||||
if (artistItems && artistItems.length > 0) {
|
||||
await db.insert(trackArtists).values(
|
||||
artistItems.map((artist, index) => ({
|
||||
sourceId,
|
||||
trackId,
|
||||
artistId: artist.Id,
|
||||
orderIndex: index,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function extractAlbumMetadata(album: Album) {
|
||||
return {
|
||||
ServerId: album.ServerId,
|
||||
SortName: album.SortName,
|
||||
RunTimeTicks: album.RunTimeTicks,
|
||||
Type: album.Type,
|
||||
UserData: album.UserData,
|
||||
PrimaryImageAspectRatio: album.PrimaryImageAspectRatio,
|
||||
Artists: album.Artists,
|
||||
ArtistItems: album.ArtistItems,
|
||||
AlbumArtists: album.AlbumArtists,
|
||||
ImageTags: album.ImageTags,
|
||||
BackdropImageTags: album.BackdropImageTags,
|
||||
LocationType: album.LocationType,
|
||||
Overview: album.Overview,
|
||||
PrimaryImageItemId: album.PrimaryImageItemId,
|
||||
};
|
||||
}
|
||||
|
||||
function enrichAlbum(dbAlbum: any): Album {
|
||||
const metadata = dbAlbum.metadataJson ? JSON.parse(dbAlbum.metadataJson) : {};
|
||||
return {
|
||||
Id: dbAlbum.id,
|
||||
Name: dbAlbum.name,
|
||||
ProductionYear: dbAlbum.productionYear,
|
||||
IsFolder: dbAlbum.isFolder,
|
||||
AlbumArtist: dbAlbum.albumArtist,
|
||||
DateCreated: dbAlbum.dateCreated ? new Date(dbAlbum.dateCreated).toISOString() : new Date().toISOString(),
|
||||
lastRefreshed: dbAlbum.lastRefreshed,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function extractArtistMetadata(artist: MusicArtist) {
|
||||
return {
|
||||
ServerId: artist.ServerId,
|
||||
ChannelId: artist.ChannelId,
|
||||
RunTimeTicks: artist.RunTimeTicks,
|
||||
Type: artist.Type,
|
||||
UserData: artist.UserData,
|
||||
ImageTags: artist.ImageTags,
|
||||
BackdropImageTags: artist.BackdropImageTags,
|
||||
ImageBlurHashes: artist.ImageBlurHashes,
|
||||
LocationType: artist.LocationType,
|
||||
Overview: artist.Overview,
|
||||
};
|
||||
}
|
||||
|
||||
function enrichArtist(dbArtist: any): MusicArtist {
|
||||
const metadata = dbArtist.metadataJson ? JSON.parse(dbArtist.metadataJson) : {};
|
||||
return {
|
||||
Id: dbArtist.id,
|
||||
Name: dbArtist.name,
|
||||
IsFolder: dbArtist.isFolder,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTrackMetadata(track: AlbumTrack) {
|
||||
return {
|
||||
ServerId: track.ServerId,
|
||||
Type: track.Type,
|
||||
UserData: track.UserData,
|
||||
Artists: track.Artists,
|
||||
ArtistItems: track.ArtistItems,
|
||||
AlbumPrimaryImageTag: track.AlbumPrimaryImageTag,
|
||||
AlbumArtists: track.AlbumArtists,
|
||||
ImageTags: track.ImageTags,
|
||||
BackdropImageTags: track.BackdropImageTags,
|
||||
LocationType: track.LocationType,
|
||||
MediaType: track.MediaType,
|
||||
MediaStreams: track.MediaStreams,
|
||||
Codec: track.Codec,
|
||||
};
|
||||
}
|
||||
|
||||
function enrichTrack(dbTrack: any): AlbumTrack {
|
||||
const metadata = dbTrack.metadataJson ? JSON.parse(dbTrack.metadataJson) : {};
|
||||
const lyrics = dbTrack.lyrics ? JSON.parse(dbTrack.lyrics) : undefined;
|
||||
|
||||
return {
|
||||
Id: dbTrack.id,
|
||||
Name: dbTrack.name,
|
||||
AlbumId: dbTrack.albumId,
|
||||
Album: dbTrack.album,
|
||||
AlbumArtist: dbTrack.albumArtist,
|
||||
ProductionYear: dbTrack.productionYear,
|
||||
IndexNumber: dbTrack.indexNumber,
|
||||
ParentIndexNumber: dbTrack.parentIndexNumber,
|
||||
HasLyrics: dbTrack.hasLyrics,
|
||||
RunTimeTicks: dbTrack.runTimeTicks,
|
||||
Lyrics: lyrics,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function extractPlaylistMetadata(playlist: Playlist) {
|
||||
return {
|
||||
ServerId: playlist.ServerId,
|
||||
SortName: playlist.SortName,
|
||||
ChannelId: playlist.ChannelId,
|
||||
RunTimeTicks: playlist.RunTimeTicks,
|
||||
Type: playlist.Type,
|
||||
UserData: playlist.UserData,
|
||||
PrimaryImageAspectRatio: playlist.PrimaryImageAspectRatio,
|
||||
ImageTags: playlist.ImageTags,
|
||||
BackdropImageTags: playlist.BackdropImageTags,
|
||||
LocationType: playlist.LocationType,
|
||||
MediaType: playlist.MediaType,
|
||||
};
|
||||
}
|
||||
|
||||
function enrichPlaylist(dbPlaylist: any): Playlist {
|
||||
const metadata = dbPlaylist.metadataJson ? JSON.parse(dbPlaylist.metadataJson) : {};
|
||||
return {
|
||||
Id: dbPlaylist.id,
|
||||
Name: dbPlaylist.name,
|
||||
CanDelete: dbPlaylist.canDelete,
|
||||
ChildCount: dbPlaylist.childCount,
|
||||
lastRefreshed: dbPlaylist.lastRefreshed,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
+248
-178
@@ -1,192 +1,262 @@
|
||||
/**
|
||||
* Bridge functions to fetch from Jellyfin API and store in database
|
||||
* These replace the Redux thunks with direct database operations
|
||||
*/
|
||||
|
||||
import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album';
|
||||
import { retrieveAllPlaylists, retrievePlaylistTracks, retrieveInstantMixByTrackId } from '@/utility/JellyfinApi/playlist';
|
||||
import { retrieveAllArtists } from '@/utility/JellyfinApi/artist';
|
||||
import { searchItem } from '@/utility/JellyfinApi/search';
|
||||
import { retrieveTrackLyrics } from '@/utility/JellyfinApi/lyrics';
|
||||
import { retrieveTrackCodecMetadata } from '@/utility/JellyfinApi/track';
|
||||
import { getActiveSource } from '@/store/settings/db';
|
||||
import * as musicDb from './db';
|
||||
import { db } from '@/store';
|
||||
import sources from '@/store/sources/entity';
|
||||
import { JellyfinDriver } from '@/store/sources/drivers/jellyfin/driver';
|
||||
import { EmbyDriver } from '@/store/sources/drivers/emby/driver';
|
||||
import type { Source, SourceDriver, SourceType } from '@/store/sources/types';
|
||||
import type { Album, AlbumTrack, MusicArtist, Playlist } from './types';
|
||||
import {
|
||||
upsertAlbum,
|
||||
upsertAlbums,
|
||||
upsertArtist,
|
||||
upsertArtists,
|
||||
upsertPlaylist,
|
||||
upsertPlaylists,
|
||||
upsertTrack,
|
||||
upsertTracks,
|
||||
setPlaylistTracks,
|
||||
setSimilarAlbums,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* Fetch all albums from Jellyfin and store in database
|
||||
*/
|
||||
export async function fetchAndStoreAllAlbums() {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
const albums = await retrieveAllAlbums();
|
||||
await musicDb.upsertAlbums(source.id, albums);
|
||||
return albums;
|
||||
type DriverWithSource = {
|
||||
driver: SourceDriver;
|
||||
source: Source;
|
||||
};
|
||||
|
||||
async function getDriver(): Promise<DriverWithSource | null> {
|
||||
const result = await db.select().from(sources).limit(1);
|
||||
const row = result[0];
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source: Source = {
|
||||
id: row.id,
|
||||
uri: row.uri,
|
||||
userId: row.userId || undefined,
|
||||
accessToken: row.accessToken || undefined,
|
||||
deviceId: row.deviceId || undefined,
|
||||
type: row.type as SourceType,
|
||||
};
|
||||
|
||||
if (source.type.startsWith('jellyfin')) {
|
||||
return { driver: new JellyfinDriver(source), source };
|
||||
}
|
||||
|
||||
if (source.type.startsWith('emby')) {
|
||||
return { driver: new EmbyDriver(source), source };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recent albums from Jellyfin and store in database
|
||||
*/
|
||||
export async function fetchAndStoreRecentAlbums() {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
async function fetchAllPages<T>(
|
||||
fetchPage: (offset: number, limit: number) => Promise<T[]>,
|
||||
limit: number = DEFAULT_LIMIT,
|
||||
): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
let offset = 0;
|
||||
|
||||
const albums = await retrieveRecentAlbums();
|
||||
await musicDb.upsertAlbums(source.id, albums);
|
||||
return albums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single album from Jellyfin and store in database
|
||||
*/
|
||||
export async function fetchAndStoreAlbum(albumId: string) {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
const album = await retrieveAlbum(albumId);
|
||||
await musicDb.upsertAlbum(source.id, album);
|
||||
return album;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch similar albums from Jellyfin and store in database
|
||||
*/
|
||||
export async function fetchAndStoreSimilarAlbums(albumId: string) {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
const similarAlbums = await retrieveSimilarAlbums(albumId);
|
||||
await musicDb.upsertAlbums(source.id, similarAlbums);
|
||||
await musicDb.setSimilarAlbums(source.id, albumId, similarAlbums.map(a => a.Id));
|
||||
return similarAlbums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tracks by album from Jellyfin and store in database
|
||||
*/
|
||||
export async function fetchAndStoreTracksByAlbum(albumId: string) {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
const tracks = await retrieveAlbumTracks(albumId);
|
||||
await musicDb.upsertTracks(source.id, tracks);
|
||||
|
||||
// Fetch codec metadata and lyrics for tracks
|
||||
await Promise.all(tracks.map(async (track) => {
|
||||
if (track.HasLyrics) {
|
||||
try {
|
||||
const lyrics = await retrieveTrackLyrics(track.Id);
|
||||
track.Lyrics = lyrics;
|
||||
await musicDb.upsertTrack(source.id, track);
|
||||
} catch (e) {
|
||||
console.error('Error fetching lyrics for track', track.Id, e);
|
||||
}
|
||||
while (true) {
|
||||
const page = await fetchPage(offset, limit);
|
||||
if (!page.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const codec = await retrieveTrackCodecMetadata(track.Id);
|
||||
await musicDb.updateTrackCodec(track.Id, codec);
|
||||
} catch (e) {
|
||||
console.error('Error fetching codec for track', track.Id, e);
|
||||
results.push(...page);
|
||||
offset += page.length;
|
||||
if (page.length < limit) {
|
||||
break;
|
||||
}
|
||||
}));
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all artists from Jellyfin and store in database
|
||||
*/
|
||||
export async function fetchAndStoreAllArtists() {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
const artists = await retrieveAllArtists();
|
||||
await musicDb.upsertArtists(source.id, artists);
|
||||
return artists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all playlists from Jellyfin and store in database
|
||||
*/
|
||||
export async function fetchAndStoreAllPlaylists() {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
const playlists = await retrieveAllPlaylists();
|
||||
await musicDb.upsertPlaylists(source.id, playlists);
|
||||
return playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tracks by playlist from Jellyfin and store in database
|
||||
*/
|
||||
export async function fetchAndStoreTracksByPlaylist(playlistId: string) {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
const tracks = await retrievePlaylistTracks(playlistId);
|
||||
await musicDb.upsertTracks(source.id, tracks);
|
||||
await musicDb.setPlaylistTracks(source.id, playlistId, tracks.map(t => t.Id));
|
||||
|
||||
// Fetch codec metadata and lyrics for tracks
|
||||
await Promise.all(tracks.map(async (track) => {
|
||||
if (track.HasLyrics) {
|
||||
try {
|
||||
const lyrics = await retrieveTrackLyrics(track.Id);
|
||||
track.Lyrics = lyrics;
|
||||
await musicDb.upsertTrack(source.id, track);
|
||||
} catch (e) {
|
||||
console.error('Error fetching lyrics for track', track.Id, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const codec = await retrieveTrackCodecMetadata(track.Id);
|
||||
await musicDb.updateTrackCodec(track.Id, codec);
|
||||
} catch (e) {
|
||||
console.error('Error fetching codec for track', track.Id, e);
|
||||
}
|
||||
}));
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Jellyfin and store results in database
|
||||
*/
|
||||
export async function searchAndStore(term: string) {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
const results = await searchItem(term);
|
||||
|
||||
// Separate results by type
|
||||
const albums = results.filter(item => item.Type === 'MusicAlbum') as Album[];
|
||||
const tracks = results.filter(item => item.Type === 'Audio') as AlbumTrack[];
|
||||
const artists = results.filter(item => item.Type === 'MusicArtist') as MusicArtist[];
|
||||
const playlists = results.filter(item => item.Type === 'Playlist') as Playlist[];
|
||||
|
||||
// Store in database
|
||||
await Promise.all([
|
||||
musicDb.upsertAlbums(source.id, albums),
|
||||
musicDb.upsertTracks(source.id, tracks),
|
||||
musicDb.upsertArtists(source.id, artists),
|
||||
musicDb.upsertPlaylists(source.id, playlists),
|
||||
]);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch instant mix by track and store in database
|
||||
*/
|
||||
export async function fetchAndStoreInstantMixByTrack(trackId: string) {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
function mapArtist(artist: { id: string; name: string; isFolder?: boolean; metadataJson?: string | null }): MusicArtist {
|
||||
const metadata = artist.metadataJson ? JSON.parse(artist.metadataJson) : {};
|
||||
|
||||
const tracks = await retrieveInstantMixByTrackId(trackId);
|
||||
await musicDb.upsertTracks(source.id, tracks);
|
||||
return tracks;
|
||||
return {
|
||||
Id: artist.id,
|
||||
Name: artist.name,
|
||||
IsFolder: artist.isFolder,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function mapArtistItems(items?: Array<{ id: string; name: string; metadataJson?: string | null }>): Array<{ Id: string; Name?: string; [key: string]: unknown }> {
|
||||
if (!items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map((item) => ({
|
||||
Id: item.id,
|
||||
Name: item.name,
|
||||
...(item.metadataJson ? JSON.parse(item.metadataJson) : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function mapAlbum(album: {
|
||||
id: string;
|
||||
name: string;
|
||||
productionYear?: number | null;
|
||||
isFolder?: boolean;
|
||||
albumArtist?: string | null;
|
||||
dateCreated?: string | null;
|
||||
artistItems?: Array<{ id: string; name: string; metadataJson?: string | null }>;
|
||||
metadataJson?: string | null;
|
||||
}): Album {
|
||||
const metadata = album.metadataJson ? JSON.parse(album.metadataJson) : {};
|
||||
|
||||
return {
|
||||
Id: album.id,
|
||||
Name: album.name,
|
||||
ProductionYear: album.productionYear || undefined,
|
||||
IsFolder: album.isFolder,
|
||||
AlbumArtist: album.albumArtist || undefined,
|
||||
DateCreated: album.dateCreated || undefined,
|
||||
ArtistItems: mapArtistItems(album.artistItems),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTrack(track: {
|
||||
id: string;
|
||||
name: string;
|
||||
albumId?: string | null;
|
||||
album?: string | null;
|
||||
albumArtist?: string | null;
|
||||
productionYear?: number | null;
|
||||
indexNumber?: number | null;
|
||||
parentIndexNumber?: number | null;
|
||||
runTimeTicks?: number | null;
|
||||
artistItems?: Array<{ id: string; name: string; metadataJson?: string | null }>;
|
||||
metadataJson?: string | null;
|
||||
}): AlbumTrack {
|
||||
const metadata = track.metadataJson ? JSON.parse(track.metadataJson) : {};
|
||||
|
||||
return {
|
||||
Id: track.id,
|
||||
Name: track.name,
|
||||
AlbumId: track.albumId || undefined,
|
||||
Album: track.album || undefined,
|
||||
AlbumArtist: track.albumArtist || undefined,
|
||||
ProductionYear: track.productionYear || undefined,
|
||||
IndexNumber: track.indexNumber || undefined,
|
||||
ParentIndexNumber: track.parentIndexNumber || undefined,
|
||||
RunTimeTicks: track.runTimeTicks || undefined,
|
||||
ArtistItems: mapArtistItems(track.artistItems),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPlaylist(playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
canDelete?: boolean;
|
||||
childCount?: number | null;
|
||||
metadataJson?: string | null;
|
||||
}): Playlist {
|
||||
const metadata = playlist.metadataJson ? JSON.parse(playlist.metadataJson) : {};
|
||||
|
||||
return {
|
||||
Id: playlist.id,
|
||||
Name: playlist.name,
|
||||
CanDelete: playlist.canDelete,
|
||||
ChildCount: playlist.childCount || undefined,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAndStoreAllArtists(): Promise<void> {
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver, source } = driverResult;
|
||||
const artists = await fetchAllPages((offset, limit) => driver.getArtists({ offset, limit }));
|
||||
await upsertArtists(source.id, artists.map(mapArtist));
|
||||
}
|
||||
|
||||
export async function fetchAndStoreAllAlbums(): Promise<void> {
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver, source } = driverResult;
|
||||
const albums = await fetchAllPages((offset, limit) => driver.getAlbums({ offset, limit }));
|
||||
await upsertAlbums(source.id, albums.map(mapAlbum));
|
||||
}
|
||||
|
||||
export async function fetchAndStoreRecentAlbums(): Promise<void> {
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver, source } = driverResult;
|
||||
const albums = await driver.getRecentAlbums({ limit: 24 });
|
||||
await upsertAlbums(source.id, albums.map(mapAlbum));
|
||||
}
|
||||
|
||||
export async function fetchAndStoreAllPlaylists(): Promise<void> {
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver, source } = driverResult;
|
||||
const playlists = await fetchAllPages((offset, limit) => driver.getPlaylists({ offset, limit }));
|
||||
await upsertPlaylists(source.id, playlists.map(mapPlaylist));
|
||||
}
|
||||
|
||||
export async function fetchAndStoreAlbum(albumId: string): Promise<void> {
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver, source } = driverResult;
|
||||
const album = await driver.getAlbum(albumId);
|
||||
await upsertAlbum(source.id, mapAlbum(album));
|
||||
}
|
||||
|
||||
export async function fetchAndStoreTracksByAlbum(albumId: string): Promise<void> {
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver, source } = driverResult;
|
||||
const tracks = await fetchAllPages((offset, limit) => driver.getTracksByAlbum(albumId, { offset, limit }));
|
||||
await upsertTracks(source.id, tracks.map(mapTrack));
|
||||
}
|
||||
|
||||
export async function fetchAndStoreTracksByPlaylist(playlistId: string): Promise<void> {
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver, source } = driverResult;
|
||||
const tracks = await fetchAllPages((offset, limit) => driver.getTracksByPlaylist(playlistId, { offset, limit }));
|
||||
const mappedTracks = tracks.map(mapTrack);
|
||||
await upsertTracks(source.id, mappedTracks);
|
||||
await setPlaylistTracks(source.id, playlistId, mappedTracks.map((track) => track.Id));
|
||||
}
|
||||
|
||||
export async function fetchAndStoreSimilarAlbums(albumId: string): Promise<void> {
|
||||
const driverResult = await getDriver();
|
||||
if (!driverResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver, source } = driverResult;
|
||||
const albums = await driver.getSimilarAlbums(albumId, { limit: 20 });
|
||||
const mapped = albums.map(mapAlbum);
|
||||
await upsertAlbums(source.id, mapped);
|
||||
await setSimilarAlbums(source.id, albumId, mapped.map((album) => album.Id));
|
||||
}
|
||||
|
||||
+27
-17
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { albums } from '@/store/albums/albums';
|
||||
import { artists } from '@/store/artists/artists';
|
||||
import { tracks } from '@/store/tracks/tracks';
|
||||
import { playlists } from '@/store/playlists/playlists';
|
||||
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import albums from '@/store/albums/entity';
|
||||
import artists from '@/store/artists/entity';
|
||||
import tracks from '@/store/tracks/entity';
|
||||
import playlists from '@/store/playlists/entity';
|
||||
import playlistTracks from '@/store/playlist-tracks/entity';
|
||||
import { eq, desc, inArray } from 'drizzle-orm';
|
||||
import { ALPHABET_LETTERS } from '@/CONSTANTS';
|
||||
import type { SectionListData } from 'react-native';
|
||||
@@ -19,9 +19,11 @@ import type { Album, AlbumTrack, MusicArtist, Playlist } from './types';
|
||||
/**
|
||||
* Get all albums (from all sources)
|
||||
*/
|
||||
export function useAlbums() {
|
||||
export function useAlbums(sourceId?: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
db.select().from(albums)
|
||||
sourceId
|
||||
? db.select().from(albums).where(eq(albums.sourceId, sourceId))
|
||||
: db.select().from(albums)
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -50,9 +52,11 @@ export function useAlbums() {
|
||||
/**
|
||||
* Get recent albums (sorted by date created, from all sources)
|
||||
*/
|
||||
export function useRecentAlbums(amount: number = 24) {
|
||||
export function useRecentAlbums(amount: number = 24, sourceId?: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
db.select().from(albums).orderBy(desc(albums.dateCreated)).limit(amount)
|
||||
sourceId
|
||||
? db.select().from(albums).where(eq(albums.sourceId, sourceId)).orderBy(desc(albums.dateCreated)).limit(amount)
|
||||
: db.select().from(albums).orderBy(desc(albums.dateCreated)).limit(amount)
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -115,9 +119,11 @@ export function useAlbumsByAlphabet() {
|
||||
/**
|
||||
* Get all artists (from all sources)
|
||||
*/
|
||||
export function useArtists() {
|
||||
export function useArtists(sourceId?: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
db.select().from(artists)
|
||||
sourceId
|
||||
? db.select().from(artists).where(eq(artists.sourceId, sourceId))
|
||||
: db.select().from(artists)
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -167,9 +173,11 @@ export function useArtistsByAlphabet() {
|
||||
/**
|
||||
* Get all playlists (from all sources)
|
||||
*/
|
||||
export function usePlaylists() {
|
||||
export function usePlaylists(sourceId?: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
db.select().from(playlists)
|
||||
sourceId
|
||||
? db.select().from(playlists).where(eq(playlists.sourceId, sourceId))
|
||||
: db.select().from(playlists)
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -258,9 +266,11 @@ export function useTracksByPlaylist(playlistId: string) {
|
||||
/**
|
||||
* Get all tracks (from all sources)
|
||||
*/
|
||||
export function useTracks() {
|
||||
export function useTracks(sourceId?: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
db.select().from(tracks)
|
||||
sourceId
|
||||
? db.select().from(tracks).where(eq(tracks.sourceId, sourceId))
|
||||
: db.select().from(tracks)
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
|
||||
+68
-146
@@ -1,175 +1,97 @@
|
||||
export interface UserData {
|
||||
PlaybackPositionTicks: number;
|
||||
PlayCount: number;
|
||||
IsFavorite: boolean;
|
||||
Played: boolean;
|
||||
Key: string;
|
||||
export interface ArtistItem {
|
||||
Id: string;
|
||||
Name?: string;
|
||||
PrimaryImageItemId?: string;
|
||||
ImageTags?: {
|
||||
Primary?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MediaStream {
|
||||
Codec: string
|
||||
TimeBase: string
|
||||
VideoRange: string
|
||||
VideoRangeType: string
|
||||
AudioSpatialFormat: string
|
||||
DisplayTitle: string
|
||||
IsInterlaced: boolean
|
||||
ChannelLayout: string
|
||||
BitRate: number
|
||||
Channels: number
|
||||
SampleRate: number
|
||||
IsDefault: boolean
|
||||
IsForced: boolean
|
||||
IsHearingImpaired: boolean
|
||||
Type: string
|
||||
Index: number
|
||||
IsExternal: boolean
|
||||
IsTextSubtitleStream: boolean
|
||||
SupportsExternalStream: boolean
|
||||
Level: number
|
||||
}
|
||||
|
||||
export interface ArtistItem {
|
||||
Name: string;
|
||||
Id: string;
|
||||
Type?: string;
|
||||
Codec?: string;
|
||||
BitRate?: number;
|
||||
SampleRate?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AlbumArtist {
|
||||
Name: string;
|
||||
Id: string;
|
||||
export interface TrackLyricsLine {
|
||||
Start: number;
|
||||
Text?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MusicArtist {
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Id: string;
|
||||
ChannelId: string;
|
||||
RunTimeTicks: number;
|
||||
IsFolder: boolean;
|
||||
UserData: UserData;
|
||||
Type: 'MusicArtist';
|
||||
ImageTags: ImageTags;
|
||||
BackdropImageTags: any[];
|
||||
ImageBlurHashes: any;
|
||||
LocationType: string;
|
||||
Overview: string;
|
||||
export interface TrackLyrics {
|
||||
Lyrics: TrackLyricsLine[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ImageTags {
|
||||
Primary: string;
|
||||
export interface CodecInfo {
|
||||
isDirectPlay?: boolean;
|
||||
contentType?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Id: string;
|
||||
SortName: string;
|
||||
RunTimeTicks: number;
|
||||
ProductionYear: number;
|
||||
IsFolder: boolean;
|
||||
Type: 'MusicAlbum';
|
||||
UserData: UserData;
|
||||
PrimaryImageAspectRatio: number;
|
||||
Artists: string[];
|
||||
ArtistItems: ArtistItem[];
|
||||
AlbumArtist?: string;
|
||||
AlbumArtists: AlbumArtist[];
|
||||
ImageTags: ImageTags;
|
||||
BackdropImageTags: any[];
|
||||
LocationType: string;
|
||||
Tracks?: string[];
|
||||
lastRefreshed?: number;
|
||||
DateCreated: string;
|
||||
Name: string;
|
||||
AlbumArtist?: string | null;
|
||||
Artists?: string[];
|
||||
ArtistItems?: ArtistItem[];
|
||||
Overview?: string;
|
||||
Similar?: string[];
|
||||
/** Emby potentially carries different ids for primary images */
|
||||
PrimaryImageItemId?: string;
|
||||
DateCreated?: string;
|
||||
ProductionYear?: number | null;
|
||||
IsFolder?: boolean;
|
||||
lastRefreshed?: number;
|
||||
Similar?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CodecMetadata {
|
||||
contentType?: string;
|
||||
isDirectPlay: boolean;
|
||||
}
|
||||
|
||||
export interface LyricMetadata {
|
||||
Artist: string
|
||||
Album: string
|
||||
Title: string
|
||||
Author: string
|
||||
Length: number
|
||||
By: string
|
||||
Offset: number
|
||||
Creator: string
|
||||
Version: string
|
||||
IsSynced: boolean
|
||||
}
|
||||
|
||||
export interface LyricData {
|
||||
Text: string
|
||||
Start: number
|
||||
}
|
||||
|
||||
export interface Lyrics {
|
||||
Metadata: LyricMetadata;
|
||||
Lyrics: LyricData[]
|
||||
export interface MusicArtist {
|
||||
Id: string;
|
||||
Name: string;
|
||||
IsFolder?: boolean;
|
||||
Overview?: string;
|
||||
PrimaryImageItemId?: string;
|
||||
ImageTags?: {
|
||||
Primary?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AlbumTrack {
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Id: string;
|
||||
RunTimeTicks: number;
|
||||
ProductionYear: number;
|
||||
IndexNumber: number;
|
||||
ParentIndexNumber: number;
|
||||
IsFolder: boolean;
|
||||
Type: 'Audio';
|
||||
UserData: UserData;
|
||||
Artists: string[];
|
||||
ArtistItems: ArtistItem[];
|
||||
Album: string;
|
||||
AlbumId: string;
|
||||
AlbumPrimaryImageTag: string;
|
||||
AlbumArtist: string;
|
||||
AlbumArtists: AlbumArtist[];
|
||||
ImageTags: ImageTags;
|
||||
BackdropImageTags: any[];
|
||||
LocationType: string;
|
||||
MediaType: string;
|
||||
HasLyrics: boolean;
|
||||
Lyrics?: Lyrics;
|
||||
Codec?: CodecMetadata;
|
||||
MediaStreams: MediaStream[];
|
||||
}
|
||||
|
||||
export interface State {
|
||||
albums: {
|
||||
ids: string[];
|
||||
entities: Record<string, Album>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
Name: string;
|
||||
AlbumId?: string | null;
|
||||
Album?: string | null;
|
||||
AlbumArtist?: string | null;
|
||||
Artists?: string[];
|
||||
ArtistItems?: ArtistItem[];
|
||||
ProductionYear?: number | null;
|
||||
IndexNumber?: number | null;
|
||||
ParentIndexNumber?: number | null;
|
||||
RunTimeTicks?: number | null;
|
||||
HasLyrics?: boolean;
|
||||
Lyrics?: TrackLyrics;
|
||||
MediaStreams?: MediaStream[];
|
||||
Codec?: CodecInfo;
|
||||
PrimaryImageItemId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Id: string;
|
||||
CanDelete: boolean;
|
||||
SortName: string;
|
||||
ChannelId?: any;
|
||||
RunTimeTicks: number;
|
||||
IsFolder: boolean;
|
||||
Type: 'Playlist';
|
||||
UserData: UserData;
|
||||
PrimaryImageAspectRatio: number;
|
||||
ImageTags: ImageTags;
|
||||
BackdropImageTags: any[];
|
||||
LocationType: string;
|
||||
MediaType: string;
|
||||
ChildCount?: number;
|
||||
Tracks?: string[];
|
||||
Name: string;
|
||||
CanDelete?: boolean;
|
||||
ChildCount?: number | null;
|
||||
lastRefreshed?: number;
|
||||
Overview?: string;
|
||||
PrimaryImageItemId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Type alias for section list artist items
|
||||
export type SectionArtistItem = MusicArtist;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Playlist-Tracks relation table (many-to-many with position)
|
||||
*/
|
||||
export const playlistTracks = sqliteTable('playlist_tracks', {
|
||||
const playlistTracks = sqliteTable('playlist_tracks', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
playlistId: text('playlist_id').notNull(),
|
||||
trackId: text('track_id').notNull(),
|
||||
@@ -13,3 +13,5 @@ export const playlistTracks = sqliteTable('playlist_tracks', {
|
||||
pk: primaryKey({ columns: [table.sourceId, table.playlistId, table.trackId] }),
|
||||
sourcePlaylistPositionIdx: index('playlist_tracks_source_playlist_position_idx').on(table.sourceId, table.playlistId, table.position),
|
||||
}));
|
||||
|
||||
export default playlistTracks;
|
||||
@@ -2,9 +2,9 @@
|
||||
* Database actions for playlists
|
||||
*/
|
||||
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { playlists } from './playlists';
|
||||
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
|
||||
import { db, sqliteDb } from '@/store';
|
||||
import playlists from './entity';
|
||||
import playlistTracks from '@/store/playlist-tracks/entity';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { InsertPlaylist } from './types';
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Playlists table
|
||||
*/
|
||||
export const playlists = sqliteTable('playlists', {
|
||||
const playlists = sqliteTable('playlists', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
@@ -17,3 +17,5 @@ export const playlists = sqliteTable('playlists', {
|
||||
}, (table) => ({
|
||||
sourceNameIdx: index('playlists_source_name_idx').on(table.sourceId, table.name),
|
||||
}));
|
||||
|
||||
export default playlists;
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { playlists } from './playlists';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import playlists from './entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Playlist } from './types';
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from '../db/schema/sources';
|
||||
|
||||
/**
|
||||
* Playlists table
|
||||
*/
|
||||
export const playlists = sqliteTable('playlists', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
canDelete: integer('can_delete', { mode: 'boolean' }).notNull(),
|
||||
childCount: integer('child_count'),
|
||||
lastRefreshed: integer('last_refreshed'),
|
||||
metadataJson: text('metadata_json'), // JSON-encoded additional fields
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
sourceNameIdx: index('playlists_source_name_idx').on(table.sourceId, table.name),
|
||||
}));
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { playlists } from './playlists';
|
||||
import playlists from './entity';
|
||||
|
||||
export type Playlist = InferSelectModel<typeof playlists>;
|
||||
export type InsertPlaylist = typeof playlists.$inferInsert;
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
*/
|
||||
|
||||
import PQueue from 'p-queue';
|
||||
import { db } from '../db/client';
|
||||
import type { SourceDriver } from '../sources/types';
|
||||
import { syncCursors } from '../db/schema/sync-cursors';
|
||||
import { artists } from '../artists/artists';
|
||||
import { albums } from '../albums/albums';
|
||||
import { playlists } from '../playlists/playlists';
|
||||
import { tracks } from '../tracks/tracks';
|
||||
import { playlistTracks } from '../db/schema/playlist-tracks';
|
||||
import { albumSimilar } from '../db/schema/album-similar';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { invalidateTable } from '../db/live-queries';
|
||||
|
||||
import { db } from '@/store/db';
|
||||
import type { SourceDriver } from '@/store/sources/types';
|
||||
import syncCursors from '@/store/sync-cursors/entity';
|
||||
import artists from '@/store/artists/entity';
|
||||
import albums from '@/store/albums/entity';
|
||||
import playlists from '@/store/playlists/entity';
|
||||
import tracks from '@/store/tracks/entity';
|
||||
import playlistTracks from '@/store/playlist-tracks/entity';
|
||||
import albumSimilar from '@/store/album-similar/entity';
|
||||
|
||||
/**
|
||||
* Entity types that can be prefilled
|
||||
@@ -197,7 +197,6 @@ export class PrefillManager {
|
||||
if (artistsData.length === 0) {
|
||||
this.updateProgress(entityType, { isComplete: true });
|
||||
await this.updateSyncCursor(entityType, offset, true);
|
||||
invalidateTable('artists');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -231,7 +230,6 @@ export class PrefillManager {
|
||||
});
|
||||
|
||||
await this.updateSyncCursor(entityType, newOffset, false);
|
||||
invalidateTable('artists');
|
||||
|
||||
// If we got a full page, recursively spawn the next page fetch
|
||||
if (artistsData.length === this.config.pageSize) {
|
||||
@@ -264,7 +262,6 @@ export class PrefillManager {
|
||||
if (albumsData.length === 0) {
|
||||
this.updateProgress(entityType, { isComplete: true });
|
||||
await this.updateSyncCursor(entityType, offset, true);
|
||||
invalidateTable('albums');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,7 +302,6 @@ export class PrefillManager {
|
||||
});
|
||||
|
||||
await this.updateSyncCursor(entityType, newOffset, false);
|
||||
invalidateTable('albums');
|
||||
|
||||
// If we got a full page, recursively spawn the next page fetch
|
||||
if (albumsData.length === this.config.pageSize) {
|
||||
@@ -338,7 +334,6 @@ export class PrefillManager {
|
||||
if (playlistsData.length === 0) {
|
||||
this.updateProgress(entityType, { isComplete: true });
|
||||
await this.updateSyncCursor(entityType, offset, true);
|
||||
invalidateTable('playlists');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -375,7 +370,6 @@ export class PrefillManager {
|
||||
});
|
||||
|
||||
await this.updateSyncCursor(entityType, newOffset, false);
|
||||
invalidateTable('playlists');
|
||||
|
||||
// If we got a full page, recursively spawn the next page fetch
|
||||
if (playlistsData.length === this.config.pageSize) {
|
||||
@@ -447,7 +441,6 @@ export class PrefillManager {
|
||||
totalInserted: this.progress[entityType].totalInserted + tracksData.length,
|
||||
});
|
||||
|
||||
invalidateTable('tracks');
|
||||
|
||||
// Recursively fetch next page if this was a full page
|
||||
if (tracksData.length === this.config.pageSize) {
|
||||
@@ -528,8 +521,6 @@ export class PrefillManager {
|
||||
totalInserted: this.progress[entityType].totalInserted + tracksData.length,
|
||||
});
|
||||
|
||||
invalidateTable('tracks');
|
||||
invalidateTable('playlist_tracks');
|
||||
|
||||
// Recursively fetch next page if this was a full page
|
||||
if (tracksData.length === this.config.pageSize) {
|
||||
@@ -573,7 +564,6 @@ export class PrefillManager {
|
||||
totalInserted: this.progress[EntityType.SIMILAR_ALBUMS].totalInserted + similarAlbums.length,
|
||||
});
|
||||
|
||||
invalidateTable('album_similar');
|
||||
} catch (error) {
|
||||
// Similar albums are optional, silently fail
|
||||
console.debug(`Could not fetch similar albums for ${albumId}:`, error);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Database actions for search queries
|
||||
*/
|
||||
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { searchQueries } from './search-queries';
|
||||
import { db, sqliteDb } from '@/store';
|
||||
import searchQueries from './entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { InsertSearchQuery } from './types';
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from '../db/schema/sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Search queries table
|
||||
*/
|
||||
export const searchQueries = sqliteTable('search_queries', {
|
||||
const searchQueries = sqliteTable('search_queries', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
query: text('query').notNull(),
|
||||
@@ -16,3 +16,5 @@ export const searchQueries = sqliteTable('search_queries', {
|
||||
}, (table) => ({
|
||||
sourceTimestampIdx: index('search_queries_source_timestamp_idx').on(table.sourceId, table.timestamp),
|
||||
}));
|
||||
|
||||
export default searchQueries;
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { searchQueries } from './search-queries';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import searchQueries from './entity';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import type { SearchQuery } from './types';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { searchQueries } from './search-queries';
|
||||
import searchQueries from './entity';
|
||||
|
||||
export type SearchQuery = InferSelectModel<typeof searchQueries>;
|
||||
export type InsertSearchQuery = typeof searchQueries.$inferInsert;
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { searchQueries } from '@/store/search-queries/search-queries';
|
||||
import type { SearchQuery } from '@/store/db/types';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
type SearchType = 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist';
|
||||
|
||||
export interface SearchQueryDisplay {
|
||||
query: string;
|
||||
filters: SearchType[];
|
||||
localPlaybackOnly: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search history for a source (ordered by timestamp desc, limit 10)
|
||||
*/
|
||||
export async function getSearchHistory(sourceId: string): Promise<SearchQuery[]> {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(searchQueries)
|
||||
.where(eq(searchQueries.sourceId, sourceId))
|
||||
.orderBy(desc(searchQueries.timestamp))
|
||||
.limit(10);
|
||||
|
||||
return result as SearchQuery[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a search query to history
|
||||
*/
|
||||
export async function addSearchQuery(
|
||||
sourceId: string,
|
||||
query: string,
|
||||
filters: SearchType[],
|
||||
localPlaybackOnly: boolean
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
const id = `${sourceId}-${query}-${filters.sort().join(',')}-${localPlaybackOnly}`;
|
||||
const metadata = { filters };
|
||||
|
||||
// Delete existing query with same parameters (to move it to top)
|
||||
await db.delete(searchQueries).where(eq(searchQueries.id, id));
|
||||
|
||||
// Insert new query
|
||||
await db.insert(searchQueries).values({
|
||||
sourceId,
|
||||
id,
|
||||
query,
|
||||
timestamp: now,
|
||||
localPlaybackOnly,
|
||||
metadataJson: JSON.stringify(metadata),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Keep only last 10 queries for this source
|
||||
const allQueries = await getSearchHistory(sourceId);
|
||||
if (allQueries.length > 10) {
|
||||
const idsToDelete = allQueries.slice(10).map(q => q.id);
|
||||
for (const id of idsToDelete) {
|
||||
await db.delete(searchQueries).where(eq(searchQueries.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all search history for a source
|
||||
*/
|
||||
export async function clearSearchHistory(sourceId: string): Promise<void> {
|
||||
await db.delete(searchQueries).where(eq(searchQueries.sourceId, sourceId));
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search queries to display format
|
||||
*/
|
||||
export function parseSearchQueries(queries: SearchQuery[]): SearchQueryDisplay[] {
|
||||
return queries.map(q => {
|
||||
const metadata = q.metadataJson ? JSON.parse(q.metadataJson) : { filters: [] };
|
||||
return {
|
||||
query: q.query,
|
||||
filters: metadata.filters || [],
|
||||
localPlaybackOnly: q.localPlaybackOnly,
|
||||
timestamp: q.timestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
type SearchType = 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist';
|
||||
|
||||
export interface SearchQuery {
|
||||
query: string;
|
||||
filters: SearchType[];
|
||||
localPlaybackOnly: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
queryHistory: SearchQuery[];
|
||||
}
|
||||
|
||||
export const initialState: State = {
|
||||
queryHistory: [],
|
||||
};
|
||||
|
||||
const search = createSlice({
|
||||
name: 'search',
|
||||
initialState,
|
||||
reducers: {
|
||||
addSearchQuery(state, action: PayloadAction<Omit<SearchQuery, 'timestamp'>>) {
|
||||
const newQuery: SearchQuery = {
|
||||
...action.payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Remove duplicate queries (same query and filters)
|
||||
state.queryHistory = state.queryHistory.filter(
|
||||
item => !(item.query === newQuery.query &&
|
||||
JSON.stringify(item.filters.sort()) === JSON.stringify(newQuery.filters.sort()) &&
|
||||
item.localPlaybackOnly === newQuery.localPlaybackOnly)
|
||||
);
|
||||
|
||||
// Add new query to the beginning
|
||||
state.queryHistory.unshift(newQuery);
|
||||
|
||||
// Keep only the last 10 queries
|
||||
if (state.queryHistory.length > 10) {
|
||||
state.queryHistory = state.queryHistory.slice(0, 10);
|
||||
}
|
||||
},
|
||||
clearSearchHistory(state) {
|
||||
state.queryHistory = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addSearchQuery, clearSearchHistory } = search.actions;
|
||||
|
||||
export default search;
|
||||
@@ -1,9 +1,93 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { ColorScheme } from './types';
|
||||
import { db, sqliteDb } from '@/store';
|
||||
import settings from '@/store/settings/entity';
|
||||
import sources from '@/store/sources/entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { AppSettings, ColorScheme } from './types';
|
||||
|
||||
export const setJellyfinCredentials = createAction<{ access_token: string, user_id: string, uri: string, device_id: string; type: 'jellyfin' | 'emby' }>('SET_JELLYFIN_CREDENTIALS');
|
||||
export const setBitrate = createAction<number>('SET_BITRATE');
|
||||
export const setOnboardingStatus = createAction<boolean>('SET_ONBOARDING_STATUS');
|
||||
export const setReceivedErrorReportingAlert = createAction<void>('SET_RECEIVED_ERROR_REPORTING_ALERT');
|
||||
export const setEnablePlaybackReporting = createAction<boolean>('SET_ENABLE_PLAYBACK_REPORTING');
|
||||
export const setColorScheme = createAction<ColorScheme>('SET_COLOR_SCHEME');
|
||||
/**
|
||||
* Get app settings (single row, id=1)
|
||||
*/
|
||||
export async function getAppSettings(): Promise<AppSettings | undefined> {
|
||||
const result = await db.select().from(settings)
|
||||
.where(eq(settings.id, 1))
|
||||
.limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize app settings with defaults if not exists
|
||||
*/
|
||||
export async function initializeAppSettings(): Promise<AppSettings> {
|
||||
const existing = await getAppSettings();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const defaults: typeof settings.$inferInsert = {
|
||||
id: 1,
|
||||
bitrate: 140000000,
|
||||
isOnboardingComplete: false,
|
||||
hasReceivedErrorReportingAlert: false,
|
||||
enablePlaybackReporting: true,
|
||||
colorScheme: ColorScheme.System,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await db.insert(settings).values(defaults);
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
return defaults as AppSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update app settings
|
||||
*/
|
||||
export async function updateAppSettings(updates: Partial<Omit<AppSettings, 'id' | 'createdAt' | 'updatedAt'>>): Promise<void> {
|
||||
await db.update(settings)
|
||||
.set({ ...updates, updatedAt: Date.now() })
|
||||
.where(eq(settings.id, 1));
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Jellyfin/Emby credentials
|
||||
*/
|
||||
export async function setCredentials(credentials: {
|
||||
uri: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId: string;
|
||||
type: 'jellyfin' | 'emby';
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
const sourceType = credentials.type === 'jellyfin' ? 'jellyfin.v1' : 'emby.v1';
|
||||
|
||||
// Use deviceId as the source id for consistency
|
||||
const sourceId = credentials.deviceId;
|
||||
|
||||
await db.insert(sources)
|
||||
.values({
|
||||
id: sourceId,
|
||||
uri: credentials.uri,
|
||||
userId: credentials.userId,
|
||||
accessToken: credentials.accessToken,
|
||||
deviceId: credentials.deviceId,
|
||||
type: sourceType,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sources.id,
|
||||
set: {
|
||||
uri: credentials.uri,
|
||||
userId: credentials.userId,
|
||||
accessToken: credentials.accessToken,
|
||||
deviceId: credentials.deviceId,
|
||||
type: sourceType,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { appSettings } from '@/store/db/schema/app-settings';
|
||||
import { sources } from '@/store/db/schema/sources';
|
||||
import type { AppSettings as AppSettingsType, Source } from '@/store/db/types';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ColorScheme } from './types';
|
||||
|
||||
// Re-export for convenience
|
||||
export type AppSettings = AppSettingsType;
|
||||
export type SourceCredentials = Source;
|
||||
|
||||
/**
|
||||
* Get app settings (single row, id=1)
|
||||
*/
|
||||
export async function getAppSettings(): Promise<AppSettings | undefined> {
|
||||
const result = await db.select().from(appSettings).where(eq(appSettings.id, 1)).limit(1);
|
||||
return result[0] as AppSettings | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize app settings with defaults if not exists
|
||||
*/
|
||||
export async function initializeAppSettings(): Promise<AppSettings> {
|
||||
const existing = await getAppSettings();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const defaults: typeof appSettings.$inferInsert = {
|
||||
id: 1,
|
||||
bitrate: 140000000,
|
||||
isOnboardingComplete: false,
|
||||
hasReceivedErrorReportingAlert: false,
|
||||
enablePlaybackReporting: true,
|
||||
colorScheme: ColorScheme.System,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await db.insert(appSettings).values(defaults);
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
return defaults as AppSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update app settings
|
||||
*/
|
||||
export async function updateAppSettings(updates: Partial<Omit<AppSettings, 'id' | 'createdAt' | 'updatedAt'>>): Promise<void> {
|
||||
await db.update(appSettings)
|
||||
.set({ ...updates, updatedAt: Date.now() })
|
||||
.where(eq(appSettings.id, 1));
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set bitrate
|
||||
*/
|
||||
export async function setBitrate(bitrate: number): Promise<void> {
|
||||
await updateAppSettings({ bitrate });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set onboarding status
|
||||
*/
|
||||
export async function setOnboardingStatus(isOnboardingComplete: boolean): Promise<void> {
|
||||
await updateAppSettings({ isOnboardingComplete });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error reporting alert received
|
||||
*/
|
||||
export async function setReceivedErrorReportingAlert(): Promise<void> {
|
||||
await updateAppSettings({ hasReceivedErrorReportingAlert: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set enable playback reporting
|
||||
*/
|
||||
export async function setEnablePlaybackReporting(enablePlaybackReporting: boolean): Promise<void> {
|
||||
await updateAppSettings({ enablePlaybackReporting });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set color scheme
|
||||
*/
|
||||
export async function setColorScheme(colorScheme: ColorScheme): Promise<void> {
|
||||
await updateAppSettings({ colorScheme });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active source (credentials)
|
||||
*/
|
||||
export async function getActiveSource(): Promise<SourceCredentials | undefined> {
|
||||
const result = await db.select().from(sources).limit(1);
|
||||
return result[0] as SourceCredentials | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Jellyfin/Emby credentials
|
||||
*/
|
||||
export async function setCredentials(credentials: {
|
||||
uri: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId: string;
|
||||
type: 'jellyfin' | 'emby';
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
const sourceType = credentials.type === 'jellyfin' ? 'jellyfin.v1' : 'emby.v1';
|
||||
|
||||
// Use deviceId as the source id for consistency
|
||||
const sourceId = credentials.deviceId;
|
||||
|
||||
await db.insert(sources)
|
||||
.values({
|
||||
id: sourceId,
|
||||
uri: credentials.uri,
|
||||
userId: credentials.userId,
|
||||
accessToken: credentials.accessToken,
|
||||
deviceId: credentials.deviceId,
|
||||
type: sourceType,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sources.id,
|
||||
set: {
|
||||
uri: credentials.uri,
|
||||
userId: credentials.userId,
|
||||
accessToken: credentials.accessToken,
|
||||
deviceId: credentials.deviceId,
|
||||
type: sourceType,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
/**
|
||||
* App settings - global application settings (single row, id=1)
|
||||
*/
|
||||
export const appSettings = sqliteTable('app_settings', {
|
||||
const settings = sqliteTable('app_settings', {
|
||||
id: integer('id').primaryKey().$default(() => 1),
|
||||
bitrate: integer('bitrate').notNull(),
|
||||
isOnboardingComplete: integer('is_onboarding_complete', { mode: 'boolean' }).notNull(),
|
||||
@@ -13,3 +13,5 @@ export const appSettings = sqliteTable('app_settings', {
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export default settings;
|
||||
@@ -2,20 +2,17 @@
|
||||
* Database-backed hooks for app settings
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { appSettings } from '@/store/db/schema/app-settings';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { AppSettings } from '@/store/db/types';
|
||||
import settings from './entity';
|
||||
|
||||
export function useAppSettings() {
|
||||
const { data, error } = useLiveQuery(
|
||||
db.select().from(appSettings).where(eq(appSettings.id, 1)).limit(1)
|
||||
db.select().from(settings)
|
||||
.where(eq(settings.id, 1))
|
||||
.limit(1)
|
||||
);
|
||||
|
||||
return useMemo(() => ({
|
||||
data: data?.[0] as AppSettings | undefined,
|
||||
error,
|
||||
}), [data, error]);
|
||||
|
||||
return { data: data?.[0], error };
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus, setEnablePlaybackReporting, setColorScheme } from './actions';
|
||||
import { ColorScheme } from './types';
|
||||
|
||||
interface State {
|
||||
credentials?: {
|
||||
uri: string;
|
||||
user_id: string;
|
||||
access_token: string;
|
||||
device_id: string;
|
||||
type: 'jellyfin' | 'emby';
|
||||
}
|
||||
bitrate: number;
|
||||
isOnboardingComplete: boolean;
|
||||
hasReceivedErrorReportingAlert: boolean;
|
||||
enablePlaybackReporting: boolean;
|
||||
colorScheme: ColorScheme;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
bitrate: 140000000,
|
||||
isOnboardingComplete: false,
|
||||
hasReceivedErrorReportingAlert: false,
|
||||
enablePlaybackReporting: true,
|
||||
colorScheme: ColorScheme.System,
|
||||
};
|
||||
|
||||
const settings = createReducer(initialState, builder => {
|
||||
builder.addCase(setJellyfinCredentials, (state, action) => ({
|
||||
...state,
|
||||
credentials: action.payload,
|
||||
}));
|
||||
builder.addCase(setBitrate, (state, action) => ({
|
||||
...state,
|
||||
bitrate: action.payload,
|
||||
}));
|
||||
builder.addCase(setOnboardingStatus, (state, action) => ({
|
||||
...state,
|
||||
isOnboardingComplete: action.payload,
|
||||
}));
|
||||
builder.addCase(setReceivedErrorReportingAlert, (state) => ({
|
||||
...state,
|
||||
hasReceivedErrorReportingAlert: true,
|
||||
}));
|
||||
builder.addCase(setEnablePlaybackReporting, (state, action) => ({
|
||||
...state,
|
||||
enablePlaybackReporting: action.payload,
|
||||
}));
|
||||
builder.addCase(setColorScheme, (state, action) => ({
|
||||
...state,
|
||||
colorScheme: action.payload,
|
||||
}));
|
||||
});
|
||||
|
||||
export default settings;
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import settings from './entity';
|
||||
|
||||
export enum ColorScheme {
|
||||
System = 'system',
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
}
|
||||
}
|
||||
|
||||
export type AppSettings = InferSelectModel<typeof settings>;
|
||||
export type InsertAppSettings = typeof settings.$inferInsert;
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
* Database actions for sleep timer
|
||||
*/
|
||||
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { sleepTimer } from './sleep-timer';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, sqliteDb } from '@/store';
|
||||
import sleepTimer from './entity';
|
||||
|
||||
export async function setSleepTimer(date: number | null): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
*/
|
||||
|
||||
import { db } from '../db/client';
|
||||
import { sleepTimer } from '../db/schema/sleep-timer';
|
||||
import sleepTimer from './entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { invalidateTable } from '../db/live-queries';
|
||||
import { invalidateTable } from '../live-queries';
|
||||
|
||||
const SLEEP_TIMER_ID = 1;
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { sqliteTable, integer } from 'drizzle-orm/sqlite-core';
|
||||
/**
|
||||
* Sleep timer - global sleep timer settings (single row, id=1)
|
||||
*/
|
||||
export const sleepTimer = sqliteTable('sleep_timer', {
|
||||
const sleepTimer = sqliteTable('sleep_timer', {
|
||||
id: integer('id').primaryKey().$default(() => 1),
|
||||
date: integer('date'), // nullable - epoch ms
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export default sleepTimer;
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { sleepTimer } from './sleep-timer';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import sleepTimer from './entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { SleepTimer } from './types';
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export interface State {
|
||||
date: number | null;
|
||||
}
|
||||
|
||||
export const initialState: State = {
|
||||
date: null,
|
||||
};
|
||||
|
||||
const sleepTimer = createSlice({
|
||||
name: 'sleep-timer',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTimerDate(state, action: PayloadAction<Date | null>) {
|
||||
state.date = action.payload?.getTime() || null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const { setTimerDate } = sleepTimer.actions;
|
||||
|
||||
export default sleepTimer;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { sqliteTable, integer } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
/**
|
||||
* Sleep timer - global sleep timer settings (single row, id=1)
|
||||
*/
|
||||
export const sleepTimer = sqliteTable('sleep_timer', {
|
||||
id: integer('id').primaryKey(),
|
||||
date: integer('date'), // nullable - epoch ms
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { sleepTimer } from './sleep-timer';
|
||||
import sleepTimer from './entity';
|
||||
|
||||
export type SleepTimer = InferSelectModel<typeof sleepTimer>;
|
||||
export type InsertSleepTimer = typeof sleepTimer.$inferInsert;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
/**
|
||||
* Sources table - stores Jellyfin/Emby server connections
|
||||
*/
|
||||
export const sources = sqliteTable('sources', {
|
||||
const sources = sqliteTable('sources', {
|
||||
id: text('id').primaryKey(),
|
||||
uri: text('uri').notNull(),
|
||||
userId: text('user_id'),
|
||||
@@ -13,3 +13,5 @@ export const sources = sqliteTable('sources', {
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export default sources;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Sync cursors table - tracks prefill progress
|
||||
*/
|
||||
export const syncCursors = sqliteTable('sync_cursors', {
|
||||
const syncCursors = sqliteTable('sync_cursors', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
entityType: text('entity_type').notNull(), // 'artists', 'albums', 'tracks', 'playlists', etc.
|
||||
startIndex: integer('start_index').notNull(),
|
||||
@@ -14,3 +14,5 @@ export const syncCursors = sqliteTable('sync_cursors', {
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.sourceId, table.entityType] }),
|
||||
}));
|
||||
|
||||
export default syncCursors;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Track-Artists relation table (many-to-many)
|
||||
*/
|
||||
export const trackArtists = sqliteTable('track_artists', {
|
||||
const trackArtists = sqliteTable('track_artists', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
trackId: text('track_id').notNull(),
|
||||
artistId: text('artist_id').notNull(),
|
||||
@@ -13,3 +13,5 @@ export const trackArtists = sqliteTable('track_artists', {
|
||||
pk: primaryKey({ columns: [table.sourceId, table.trackId, table.artistId] }),
|
||||
sourceArtistIdx: index('track_artists_source_artist_idx').on(table.sourceId, table.artistId),
|
||||
}));
|
||||
|
||||
export default trackArtists;
|
||||
@@ -2,9 +2,8 @@
|
||||
* Database actions for tracks
|
||||
*/
|
||||
|
||||
import { db, sqliteDb } from '@/store/db';
|
||||
import { tracks } from './tracks';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, sqliteDb } from '@/store';
|
||||
import tracks from './entity';
|
||||
import type { InsertTrack } from './types';
|
||||
|
||||
export async function upsertTrack(track: InsertTrack): Promise<void> {
|
||||
@@ -29,19 +28,4 @@ export async function upsertTracks(trackList: InsertTrack[]): Promise<void> {
|
||||
for (const track of trackList) {
|
||||
await upsertTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTrack(id: string): Promise<void> {
|
||||
await db.delete(tracks).where(eq(tracks.id, id));
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
export async function deleteTracksBySource(sourceId: string): Promise<void> {
|
||||
await db.delete(tracks).where(eq(tracks.sourceId, sourceId));
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
|
||||
export async function deleteTracksByAlbum(albumId: string): Promise<void> {
|
||||
await db.delete(tracks).where(eq(tracks.albumId, albumId));
|
||||
sqliteDb.flushPendingReactiveQueries();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from './sources';
|
||||
import sources from '../sources/entity';
|
||||
|
||||
/**
|
||||
* Tracks table
|
||||
*/
|
||||
export const tracks = sqliteTable('tracks', {
|
||||
const tracks = sqliteTable('tracks', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
@@ -24,3 +24,5 @@ export const tracks = sqliteTable('tracks', {
|
||||
sourceAlbumIdx: index('tracks_source_album_idx').on(table.sourceId, table.albumId),
|
||||
sourceNameIdx: index('tracks_source_name_idx').on(table.sourceId, table.name),
|
||||
}));
|
||||
|
||||
export default tracks;
|
||||
+7
-156
@@ -2,170 +2,21 @@
|
||||
* Database-backed hooks for tracks with download joins
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { tracks } from './tracks';
|
||||
import { downloads } from '@/store/downloads/downloads';
|
||||
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import type { Track } from './types';
|
||||
import type { Download } from '@/store/downloads/types';
|
||||
|
||||
export interface TrackWithDownload {
|
||||
track: Track;
|
||||
download: Download | null;
|
||||
}
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import tracks from './entity';
|
||||
|
||||
export function useTracks(sourceId?: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
return useLiveQuery(
|
||||
sourceId
|
||||
? db.select().from(tracks).where(eq(tracks.sourceId, sourceId))
|
||||
: db.select().from(tracks)
|
||||
);
|
||||
|
||||
return useMemo(() => ({
|
||||
data: (data || []) as Track[],
|
||||
error,
|
||||
}), [data, error]);
|
||||
}
|
||||
|
||||
export function useTrack(id: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
return useLiveQuery(
|
||||
id ? db.select().from(tracks).where(eq(tracks.id, id)).limit(1) : null
|
||||
);
|
||||
|
||||
return useMemo(() => ({
|
||||
data: data?.[0] as Track | undefined,
|
||||
error,
|
||||
}), [data, error]);
|
||||
}
|
||||
|
||||
export function useTrackWithDownload(id: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
id
|
||||
? db.select({
|
||||
track: tracks,
|
||||
download: downloads,
|
||||
})
|
||||
.from(tracks)
|
||||
.leftJoin(downloads, eq(tracks.id, downloads.id))
|
||||
.where(eq(tracks.id, id))
|
||||
.limit(1)
|
||||
: null
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const result = data?.[0];
|
||||
return {
|
||||
data: result ? {
|
||||
track: result.track as Track,
|
||||
download: result.download as Download | null,
|
||||
} : undefined,
|
||||
error,
|
||||
};
|
||||
}, [data, error]);
|
||||
}
|
||||
|
||||
export function useTracksByAlbum(albumId: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
albumId
|
||||
? db.select().from(tracks).where(eq(tracks.albumId, albumId))
|
||||
: null
|
||||
);
|
||||
|
||||
return useMemo(() => ({
|
||||
data: (data || []) as Track[],
|
||||
error,
|
||||
}), [data, error]);
|
||||
}
|
||||
|
||||
export function useTracksWithDownloadsByAlbum(albumId: string) {
|
||||
const { data, error } = useLiveQuery(
|
||||
albumId
|
||||
? db.select({
|
||||
track: tracks,
|
||||
download: downloads,
|
||||
})
|
||||
.from(tracks)
|
||||
.leftJoin(downloads, eq(tracks.id, downloads.id))
|
||||
.where(eq(tracks.albumId, albumId))
|
||||
: null
|
||||
);
|
||||
|
||||
return useMemo(() => ({
|
||||
data: (data || []).map(row => ({
|
||||
track: row.track as Track,
|
||||
download: row.download as Download | null,
|
||||
})),
|
||||
error,
|
||||
}), [data, error]);
|
||||
}
|
||||
|
||||
export function useTracksByPlaylist(playlistId: string) {
|
||||
const { data: relations, error: relError } = useLiveQuery(
|
||||
playlistId
|
||||
? db.select().from(playlistTracks).where(eq(playlistTracks.playlistId, playlistId))
|
||||
: null
|
||||
);
|
||||
|
||||
const trackIds = useMemo(() => (relations || []).map(r => r.trackId), [relations]);
|
||||
|
||||
const { data: tracksData, error: tracksError } = useLiveQuery(
|
||||
trackIds.length > 0
|
||||
? db.select().from(tracks).where(inArray(tracks.id, trackIds))
|
||||
: null
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const tracksMap = new Map((tracksData || []).map(t => [t.id, t as Track]));
|
||||
const sortedTracks = (relations || [])
|
||||
.sort((a, b) => (a.position || 0) - (b.position || 0))
|
||||
.map(r => tracksMap.get(r.trackId))
|
||||
.filter(Boolean) as Track[];
|
||||
|
||||
return {
|
||||
data: sortedTracks,
|
||||
error: relError || tracksError,
|
||||
};
|
||||
}, [relations, tracksData, relError, tracksError]);
|
||||
}
|
||||
|
||||
export function useTracksWithDownloadsByPlaylist(playlistId: string) {
|
||||
const { data: relations, error: relError } = useLiveQuery(
|
||||
playlistId
|
||||
? db.select().from(playlistTracks).where(eq(playlistTracks.playlistId, playlistId))
|
||||
: null
|
||||
);
|
||||
|
||||
const trackIds = useMemo(() => (relations || []).map(r => r.trackId), [relations]);
|
||||
|
||||
const { data: tracksData, error: tracksError } = useLiveQuery(
|
||||
trackIds.length > 0
|
||||
? db.select({
|
||||
track: tracks,
|
||||
download: downloads,
|
||||
})
|
||||
.from(tracks)
|
||||
.leftJoin(downloads, eq(tracks.id, downloads.id))
|
||||
.where(inArray(tracks.id, trackIds))
|
||||
: null
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const tracksMap = new Map((tracksData || []).map(row => [row.track.id, {
|
||||
track: row.track as Track,
|
||||
download: row.download as Download | null,
|
||||
}]));
|
||||
|
||||
const sortedTracks = (relations || [])
|
||||
.sort((a, b) => (a.position || 0) - (b.position || 0))
|
||||
.map(r => tracksMap.get(r.trackId))
|
||||
.filter(Boolean) as TrackWithDownload[];
|
||||
|
||||
return {
|
||||
data: sortedTracks,
|
||||
error: relError || tracksError,
|
||||
};
|
||||
}, [relations, tracksData, relError, tracksError]);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sources } from '../db/schema/sources';
|
||||
|
||||
/**
|
||||
* Tracks table
|
||||
*/
|
||||
export const tracks = sqliteTable('tracks', {
|
||||
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
albumId: text('album_id'),
|
||||
album: text('album'),
|
||||
albumArtist: text('album_artist'),
|
||||
productionYear: integer('production_year'),
|
||||
indexNumber: integer('index_number'),
|
||||
parentIndexNumber: integer('parent_index_number'),
|
||||
hasLyrics: integer('has_lyrics', { mode: 'boolean' }).notNull().default(false),
|
||||
runTimeTicks: integer('run_time_ticks'),
|
||||
lyrics: text('lyrics'),
|
||||
metadataJson: text('metadata_json'), // JSON-encoded additional fields
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
sourceAlbumIdx: index('tracks_source_album_idx').on(table.sourceId, table.albumId),
|
||||
sourceNameIdx: index('tracks_source_name_idx').on(table.sourceId, table.name),
|
||||
}));
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { tracks } from './tracks';
|
||||
import tracks from './entity';
|
||||
|
||||
export type Track = InferSelectModel<typeof tracks>;
|
||||
export type InsertTrack = typeof tracks.$inferInsert;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { t } from '@/localisation';
|
||||
import { setReceivedErrorReportingAlert } from '@/store/settings/db';
|
||||
import { setReceivedErrorReportingAlert } from '@/store/settings/actions';
|
||||
import { setSentryStatus } from './Sentry';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@/screens/types';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store/db';
|
||||
import { appSettings } from '@/store/db/schema/app-settings';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { db } from '@/store';
|
||||
import appSettings from '@/store/settings/entity';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Album, AlbumTrack } from '@/store/music/types';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
const albumOptions = {
|
||||
SortBy: 'AlbumArtist,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Recursive: 'true',
|
||||
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
|
||||
ImageTypeLimit: '1',
|
||||
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
|
||||
};
|
||||
|
||||
const albumParams = new URLSearchParams(albumOptions).toString();
|
||||
|
||||
/**
|
||||
* Retrieve all albums that are available on the Jellyfin server
|
||||
*/
|
||||
export async function retrieveAllAlbums() {
|
||||
return fetchApi<{ Items: Album[] }>(({ user_id }) => `/Users/${user_id}/Items?${albumParams}`)
|
||||
.then((data) => data!.Items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single album
|
||||
*/
|
||||
export async function retrieveAlbum(id: string): Promise<Album> {
|
||||
return fetchApi<Album>(({ user_id }) => `/Users/${user_id}/Items/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve albums that are similar to the provided album
|
||||
*/
|
||||
export async function retrieveSimilarAlbums(id: string): Promise<Album[]> {
|
||||
return fetchApi<{ Items: Album[] }>(({ user_id }) => `/Items/${id}/Similar?userId=${user_id}&limit=12`)
|
||||
.then((albums) => albums!.Items);
|
||||
}
|
||||
|
||||
const latestAlbumsOptions = {
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Fields: 'DateCreated',
|
||||
SortOrder: 'Descending',
|
||||
SortBy: 'DateCreated',
|
||||
Recursive: 'true',
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve the most recently added albums on the Jellyfin server
|
||||
*/
|
||||
export async function retrieveRecentAlbums(numberOfAlbums = 24) {
|
||||
// Generate custom config based on function input
|
||||
const options = {
|
||||
...latestAlbumsOptions,
|
||||
Limit: numberOfAlbums.toString(),
|
||||
};
|
||||
const params = new URLSearchParams(options).toString();
|
||||
|
||||
// Retrieve albums
|
||||
return fetchApi<{ Items: Album[] }>(({ user_id }) => `/Users/${user_id}/Items?${params}`)
|
||||
.then((d) => d.Items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single album from the Emby server
|
||||
*/
|
||||
export async function retrieveAlbumTracks(ItemId: string) {
|
||||
const singleAlbumOptions = {
|
||||
ParentId: ItemId,
|
||||
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
||||
Fields: 'MediaStreams',
|
||||
};
|
||||
const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString();
|
||||
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`)
|
||||
.then((d) => d.Items);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { MusicArtist } from '@/store/music/types';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
const artistOptions = {
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Recursive: 'true',
|
||||
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,Overview',
|
||||
ImageTypeLimit: '1',
|
||||
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
|
||||
};
|
||||
|
||||
const artistParams = new URLSearchParams(artistOptions).toString();
|
||||
|
||||
/**
|
||||
* Retrieve all artists that are available on the Jellyfin server
|
||||
*/
|
||||
export function retrieveAllArtists() {
|
||||
return fetchApi<{ Items: MusicArtist[] }>(() => `/Artists/AlbumArtists?${artistParams}`)
|
||||
.then(response => response.Items);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { version } from '../../../package.json';
|
||||
import { Album, AlbumTrack, ArtistItem, Playlist } from '@/store/music/types';
|
||||
import { db } from '@/store/db';
|
||||
import { sources } from '@/store/db/schema/sources';
|
||||
import { useLiveQuery } from '@/store/db/live-queries';
|
||||
import { db } from '@/store';
|
||||
import sources from '@/store/sources/entity';
|
||||
import { useLiveQuery } from '@/store/live-queries';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type Credentials = {
|
||||
@@ -165,7 +165,7 @@ export function useGetImage() {
|
||||
const { data: sourceData } = useLiveQuery(db.select().from(sources).limit(1));
|
||||
const credentials = sourceData?.[0];
|
||||
|
||||
return useCallback(async (item: Parameters<typeof getImage>[0]) => {
|
||||
return useCallback((item: Parameters<typeof getImage>[0]) => {
|
||||
return getImage(item, credentials);
|
||||
}, [credentials]);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Lyrics } from '@/store/music/types';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
export async function retrieveTrackLyrics(trackId: string): Promise<Lyrics> {
|
||||
return fetchApi<Lyrics>(`/Audio/${trackId}/Lyrics`);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import TrackPlayer, { RepeatMode, State, Track } from 'react-native-track-player';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
/**
|
||||
* This maps the react-native-track-player RepeatMode to a RepeatMode that is
|
||||
* expected by Jellyfin when reporting playback events.
|
||||
*/
|
||||
const RepeatModeMap: Record<RepeatMode, string> = {
|
||||
[RepeatMode.Off]: 'RepeatNone',
|
||||
[RepeatMode.Track]: 'RepeatOne',
|
||||
[RepeatMode.Queue]: 'RepeatAll',
|
||||
};
|
||||
|
||||
/**
|
||||
* This will generate the payload that is required for playback events and send
|
||||
* it to the supplied path.
|
||||
*/
|
||||
export async function sendPlaybackEvent(
|
||||
path: string,
|
||||
track?: Track,
|
||||
lastPosition?: number,
|
||||
) {
|
||||
// Extract all data from react-native-track-player
|
||||
const [
|
||||
activeTrack, { position: currentPosition }, repeatMode, volume, { state },
|
||||
] = await Promise.all([
|
||||
track || TrackPlayer.getActiveTrack(),
|
||||
TrackPlayer.getProgress(),
|
||||
TrackPlayer.getRepeatMode(),
|
||||
TrackPlayer.getVolume(),
|
||||
TrackPlayer.getPlaybackState(),
|
||||
]);
|
||||
|
||||
// GUARD: Ensure that no empty events are sent out
|
||||
if (!activeTrack?.backendId) return;
|
||||
|
||||
// Generate a payload from the gathered data
|
||||
const payload = {
|
||||
VolumeLevel: volume * 100,
|
||||
IsMuted: false,
|
||||
IsPaused: state === State.Paused,
|
||||
RepeatMode: RepeatModeMap[repeatMode],
|
||||
ShuffleMode: 'Sorted',
|
||||
PositionTicks: Math.round((lastPosition || currentPosition) * 10_000_000),
|
||||
PlaybackRate: 1,
|
||||
PlayMethod: 'transcode',
|
||||
MediaSourceId: activeTrack.backendId,
|
||||
ItemId: activeTrack.backendId,
|
||||
CanSeek: true,
|
||||
PlaybackStartTimeTicks: null,
|
||||
PlaySessionId: activeTrack?.backendId || 'fintunes',
|
||||
};
|
||||
|
||||
// Generate a config from the credentials and dispatch the request
|
||||
await fetchApi(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
// Swallow and errors from the request
|
||||
}, false).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { AlbumTrack, Playlist } from '@/store/music/types';
|
||||
import { asyncFetchStore, fetchApi } from './lib';
|
||||
|
||||
const playlistOptions = {
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'Playlist',
|
||||
Recursive: 'true',
|
||||
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,ChildCount',
|
||||
ImageTypeLimit: '1',
|
||||
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb'
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve all albums that are available on the Jellyfin server
|
||||
*/
|
||||
export async function retrieveAllPlaylists() {
|
||||
const playlistParams = new URLSearchParams(playlistOptions).toString();
|
||||
|
||||
return fetchApi<{ Items: Playlist[] }>(({ user_id }) => `/Users/${user_id}/Items?${playlistParams}`)
|
||||
.then((d) => d!.Items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all albums that are available on the Jellyfin server
|
||||
*/
|
||||
export async function retrievePlaylistTracks(ItemId: string) {
|
||||
const credentials = asyncFetchStore().getState().settings.credentials;
|
||||
const singlePlaylistOptions = {
|
||||
SortBy: 'IndexNumber,SortName',
|
||||
UserId: credentials?.user_id || '',
|
||||
};
|
||||
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
|
||||
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`)
|
||||
.then((d) => d.Items);
|
||||
}
|
||||
|
||||
export async function retrieveInstantMixByTrackId(trackId: string, limit = 100) {
|
||||
const credentials = asyncFetchStore().getState().settings.credentials;
|
||||
const instantMixOptions = {
|
||||
UserId: credentials?.user_id ?? '',
|
||||
Limit: limit.toString()
|
||||
};
|
||||
const instantMixParams = new URLSearchParams(instantMixOptions).toString();
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(`/Items/${trackId}/InstantMix?${instantMixParams}`).then(response => response.Items);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Album, AlbumTrack, MusicArtist, Playlist } from '@/store/music/types';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
const searchParams = {
|
||||
IncludeItemTypes: 'Audio,MusicAlbum,Playlist',
|
||||
SortBy: 'SearchScore,Album,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Recursive: 'true',
|
||||
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,Overview',
|
||||
ImageTypeLimit: '1',
|
||||
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb'
|
||||
};
|
||||
|
||||
export type SearchResult = Album | AlbumTrack | MusicArtist | Playlist;
|
||||
|
||||
/**
|
||||
* Remotely search the Jellyfin library for a particular search term
|
||||
*/
|
||||
export function searchItem(
|
||||
term: string, limit = 24
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
...searchParams,
|
||||
SearchTerm: term,
|
||||
Limit: limit.toString(),
|
||||
}).toString();
|
||||
|
||||
return fetchApi<{ Items: SearchResult[]}>(({ user_id }) => `/Users/${user_id}/Items?${params}`)
|
||||
.then(result => result.Items);
|
||||
}
|
||||
@@ -1,96 +1,54 @@
|
||||
import { AlbumTrack, CodecMetadata } from '@/store/music/types';
|
||||
import { Platform } from 'react-native';
|
||||
import { Track } from 'react-native-track-player';
|
||||
import { asyncFetchStore, fetchApi, getImage, generateConfig } from './lib';
|
||||
import type { Track as PlayerTrack } from 'react-native-track-player';
|
||||
import { db } from '@/store';
|
||||
import sources from '@/store/sources/entity';
|
||||
import { JellyfinDriver } from '@/store/sources/drivers/jellyfin/driver';
|
||||
import { EmbyDriver } from '@/store/sources/drivers/emby/driver';
|
||||
import type { Source, SourceDriver, SourceType } from '@/store/sources/types';
|
||||
import type { AlbumTrack } from '@/store/music/types';
|
||||
import { getCredentials, getImage } from './lib';
|
||||
|
||||
const trackOptionsOsOverrides: Record<typeof Platform.OS, Record<string, string>> = {
|
||||
ios: {
|
||||
Container: 'mp3,aac,m4a|aac,m4b|aac,flac,alac,m4a|alac,m4b|alac,wav,m4a,aiff,aif',
|
||||
},
|
||||
android: {
|
||||
Container: 'mp3,aac,flac,wav,ogg,ogg|vorbis,ogg|opus,mka|mp3,mka|opus,mka|mp3',
|
||||
},
|
||||
macos: {},
|
||||
web: {},
|
||||
windows: {},
|
||||
};
|
||||
async function getDriver(): Promise<{ driver: SourceDriver; source: Source } | null> {
|
||||
const result = await db.select().from(sources).limit(1);
|
||||
const row = result[0];
|
||||
|
||||
const baseTrackOptions: Record<string, string> = {
|
||||
TranscodingProtocol: 'http',
|
||||
TranscodingContainer: 'aac',
|
||||
AudioCodec: 'aac',
|
||||
Container: 'mp3,aac',
|
||||
audioBitRate: '320000',
|
||||
...trackOptionsOsOverrides[Platform.OS],
|
||||
} as const;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the track streaming url from the trackId
|
||||
*/
|
||||
export function generateTrackUrl(trackId: string) {
|
||||
const credentials = asyncFetchStore().getState().settings.credentials;
|
||||
const trackOptions = {
|
||||
...baseTrackOptions,
|
||||
UserId: credentials?.user_id || '',
|
||||
api_key: credentials?.access_token || '',
|
||||
DeviceId: credentials?.device_id || '',
|
||||
const source: Source = {
|
||||
id: row.id,
|
||||
uri: row.uri,
|
||||
userId: row.userId || undefined,
|
||||
accessToken: row.accessToken || undefined,
|
||||
deviceId: row.deviceId || undefined,
|
||||
type: row.type as SourceType,
|
||||
};
|
||||
|
||||
const trackParams = new URLSearchParams(trackOptions).toString();
|
||||
const url = encodeURI(`${credentials?.uri}/Audio/${trackId}/universal?`) + trackParams;
|
||||
if (source.type.startsWith('jellyfin')) {
|
||||
return { driver: new JellyfinDriver(source), source };
|
||||
}
|
||||
|
||||
return url;
|
||||
if (source.type.startsWith('emby')) {
|
||||
return { driver: new EmbyDriver(source), source };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a track object from a Jellyfin ItemId so that
|
||||
* react-native-track-player can easily consume it.
|
||||
*/
|
||||
export async function generateTrack(track: AlbumTrack): Promise<Track> {
|
||||
// Also construct the URL for the stream
|
||||
const url = generateTrackUrl(track.Id);
|
||||
|
||||
// Get credentials and generate authentication headers
|
||||
const credentials = asyncFetchStore().getState().settings.credentials;
|
||||
const config = generateConfig(credentials);
|
||||
const headers = config.headers;
|
||||
export async function generateTrack(track: AlbumTrack): Promise<PlayerTrack> {
|
||||
const driverResult = await getDriver();
|
||||
const credentials = await getCredentials();
|
||||
const artwork = credentials ? getImage(track, credentials) : undefined;
|
||||
const url = driverResult ? await driverResult.driver.getStreamUrl(track.Id) : '';
|
||||
|
||||
return {
|
||||
id: track.Id,
|
||||
url,
|
||||
backendId: track.Id,
|
||||
title: track.Name,
|
||||
artist: track.Artists.join(', '),
|
||||
album: track.Album,
|
||||
duration: track.RunTimeTicks,
|
||||
artwork: getImage(track),
|
||||
headers,
|
||||
bitRate: baseTrackOptions.audioBitRate,
|
||||
};
|
||||
artist: track.AlbumArtist || undefined,
|
||||
album: track.Album || undefined,
|
||||
artwork,
|
||||
duration: track.RunTimeTicks ? track.RunTimeTicks / 10_000_000 : undefined,
|
||||
backendId: track.Id,
|
||||
} as PlayerTrack;
|
||||
}
|
||||
|
||||
|
||||
const trackParams = {
|
||||
SortBy: 'AlbumArtist,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Recursive: 'true',
|
||||
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve all possible tracks that can be found in Jellyfin
|
||||
*/
|
||||
export async function retrieveAllTracks() {
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`)
|
||||
.then((d) => d.Items);
|
||||
}
|
||||
|
||||
export async function retrieveTrackCodecMetadata(trackId: string): Promise<CodecMetadata> {
|
||||
const url = generateTrackUrl(trackId);
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
|
||||
return {
|
||||
contentType: response.headers.get('Content-Type') || undefined,
|
||||
isDirectPlay: response.headers.has('Content-Length'),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user