diff --git a/README.md b/README.md index fd563aa2..7fef22f0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,15 @@ # Jellify -A music player for Jellyfin powered by React Native +A music player for Jellyfin powered by React Native. Designed to be lightweight, fast, and support ***extremely*** large music libraries (i.e., > 100K songs) +While other music players provide a WYSIWYG music player experience, *Jellify* caters to those who want a music player experience similar to what's provided by music streaming services. *Jellify* provides: +- Quick access to previously played and favorite tracks +- An infinitlyTM customizable home screen + - Select from the provided quick filters or build a custom filter +- Support for Jellyfin mixes +- Quick access to similar artists and items for discovering music in your library + +## Built with: ### Frontend [React Native Reanimated](https://github.com/software-mansion/react-native-reanimated) - [Marquee](https://github.com/animate-react-native/marquee) @@ -11,10 +19,15 @@ A music player for Jellyfin powered by React Native [React Native InApp Browser](https://github.com/proyecto26/react-native-inappbrowser) ### Backend -[Jellyfin SDK](https://typescript-sdk.jellyfin.org/) -[Tanstack Query](https://tanstack.com/query/latest/docs/framework/react/react-native) -[React Native Track Player](https://github.com/doublesymmetry/react-native-track-player) -[React Native Keychain](https://github.com/oblador/react-native-keychain) +[Jellyfin SDK](https://typescript-sdk.jellyfin.org/)\ +[Tanstack Query](https://tanstack.com/query/latest/docs/framework/react/react-native)\ +[React Native Track Player](https://github.com/doublesymmetry/react-native-track-player)\ +[React Native Keychain](https://github.com/oblador/react-native-keychain)\ +[React Native Async Storage](https://github.com/react-native-async-storage/async-storage) ### Logging -[GlitchTip](https://glitchtip.com/) captures anonymous logging if and only if the user opts into it. This can be toggled at anytime \ No newline at end of file +[GlitchTip](https://glitchtip.com/) +- Captures anonymous logging if and only if the user opts into it. This can be toggled at anytime + +### Passion +Love from [me](https://github.com/anultravioletaurora)! I've learned a lot from working on this project (and the many failed attempts before it) and I hope you enjoy using it! \ No newline at end of file diff --git a/api/enums/async-storage-keys.ts b/api/enums/async-storage-keys.ts new file mode 100644 index 00000000..3c7751f3 --- /dev/null +++ b/api/enums/async-storage-keys.ts @@ -0,0 +1,3 @@ +export enum AsyncStorageKeys { + ServerUrl = "SERVER_URL" +} \ No newline at end of file diff --git a/api/enums/mutation-keys.ts b/api/enums/mutation-keys.ts new file mode 100644 index 00000000..94b5834e --- /dev/null +++ b/api/enums/mutation-keys.ts @@ -0,0 +1,5 @@ +export enum MutationKeys { + AccessToken = "ACCESS_TOKEN", + Credentials = "CREDENTIALS", + ServerUrl = "SERVER_URL" +} \ No newline at end of file diff --git a/api/enums/query-keys.ts b/api/enums/query-keys.ts new file mode 100644 index 00000000..91e89562 --- /dev/null +++ b/api/enums/query-keys.ts @@ -0,0 +1,6 @@ +export enum QueryKeys { + Api = "API", + ArtistById = "ARTIST_BY_ID", + Credentials = "CREDENTIALS", + ServerUrl = "SERVER_URL" +} \ No newline at end of file diff --git a/api/mutators/storage.ts b/api/mutators/storage.ts new file mode 100644 index 00000000..727ff36a --- /dev/null +++ b/api/mutators/storage.ts @@ -0,0 +1,36 @@ +import { useMutation } from "@tanstack/react-query"; +import { MutationKeys } from "../enums/mutation-keys"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { AsyncStorageKeys } from "../enums/async-storage-keys"; + +import * as Keychain from "react-native-keychain" +import { useServerUrl } from "../queries/storage"; +import { Jellyfin } from "@jellyfin/sdk"; +import { client } from "../queries"; +import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api" +import { JellyfinCredentials } from "../types/jellyfin-credentials"; + +export const serverUrl = useMutation({ + mutationKey: [MutationKeys.ServerUrl], + mutationFn: (serverUrl: string) => { + let jellyfin = new Jellyfin(client); + + let api = jellyfin.createApi(serverUrl); + + return getSystemApi(api).getPublicSystemInfo() + }, + onSuccess: (publicSystemInfoResponse, serverUrl, context) => { + + if (!!!publicSystemInfoResponse.data.Version) + throw new Error("Unable to connect to Jellyfin Server"); + + return AsyncStorage.setItem(AsyncStorageKeys.ServerUrl, serverUrl); + } +}); + +export const credentials = useMutation({ + mutationKey: [MutationKeys.Credentials], + mutationFn: (credentials: JellyfinCredentials) => { + return Keychain.setInternetCredentials(useServerUrl.data!, credentials.username, credentials.accessToken); + }, +}); \ No newline at end of file diff --git a/api/queries.ts b/api/queries.ts new file mode 100644 index 00000000..14c48be8 --- /dev/null +++ b/api/queries.ts @@ -0,0 +1,29 @@ +import { Jellyfin } from "@jellyfin/sdk" +import { useQuery } from "@tanstack/react-query"; +import { getDeviceNameSync, getUniqueIdSync } from "react-native-device-info" +import { QueryKeys } from "./enums/query-keys"; +import { useServerUrl } from "./queries/storage"; +import { useCredentials } from "./queries/keychain"; +import { SharedWebCredentials } from "react-native-keychain"; + +let clientName : string = require('root-require')('./package.json').name +let clientVersion : string = require('root-require')('./package.json').version + +export const client : Jellyfin = new Jellyfin({ + clientInfo: { + name: clientName, + version: clientVersion + }, + deviceInfo: { + name: getDeviceNameSync(), + id: getUniqueIdSync() + } +}); + +export const useApi = useQuery({ + queryKey: [QueryKeys.Api], + queryFn: () => { + let credentials = useCredentials.data! + return client.createApi(credentials.server, credentials.password); + } +}) \ No newline at end of file diff --git a/api/queries/artist-queries.ts b/api/queries/artist-queries.ts deleted file mode 100644 index 5fa7c360..00000000 --- a/api/queries/artist-queries.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto' -import { useQuery, UseQueryResult } from '@tanstack/react-query' -import { JellyfinService } from '../services/jellyfin-service' -import { ArtistModel } from '../../models/ArtistModel' - -namespace ArtistQueries { - - export function fetchArtistById(artistJellyfinId: string) : UseQueryResult { - return useQuery({ - queryKey: ['artists', artistJellyfinId], - queryFn: ({ queryKey }) => JellyfinService.instance.getArtistById(queryKey[1]) - }) - } -} diff --git a/api/queries/artists.ts b/api/queries/artists.ts new file mode 100644 index 00000000..12a64ab5 --- /dev/null +++ b/api/queries/artists.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { QueryKeys } from "../enums/query-keys"; +import { Api } from "@jellyfin/sdk"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api" +import { useApi } from "../queries"; + + +export const useArtistById = (artistId: string) => useQuery({ + queryKey: [QueryKeys.ArtistById, artistId], + queryFn: (({ queryKey }) => { + + return getItemsApi(useApi.data!).getItems({ ids: [queryKey[1]]}); + }) +}) \ No newline at end of file diff --git a/api/queries/image-queries.ts b/api/queries/image-queries.ts deleted file mode 100644 index 4bff0b25..00000000 --- a/api/queries/image-queries.ts +++ /dev/null @@ -1,4 +0,0 @@ - -namespace ImageQueries { - -} \ No newline at end of file diff --git a/api/queries/keychain.ts b/api/queries/keychain.ts new file mode 100644 index 00000000..6fadf155 --- /dev/null +++ b/api/queries/keychain.ts @@ -0,0 +1,21 @@ +import { UseQueryResult, useQuery } from "@tanstack/react-query" +import * as Keychain from "react-native-keychain" +import { ArtistModel } from "../../models/ArtistModel" +import { Api } from "@jellyfin/sdk" +import AsyncStorage from "@react-native-async-storage/async-storage" +import { AsyncStorageKeys } from "../enums/async-storage-keys" +import { QueryKeys } from "../enums/query-keys" +import { useServerUrl } from "./storage" + +export const useCredentials = useQuery({ + queryKey: [QueryKeys.Credentials], + queryFn: () => { + return Keychain.getInternetCredentials(useServerUrl.data!) + .then((keychain) => { + if (!keychain) + throw new Error("Jellyfin server credentials not stored in keychain"); + + return keychain as Keychain.SharedWebCredentials + }); + } +}); \ No newline at end of file diff --git a/api/queries/storage.ts b/api/queries/storage.ts new file mode 100644 index 00000000..b775149b --- /dev/null +++ b/api/queries/storage.ts @@ -0,0 +1,12 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { UseQueryResult, useQuery } from "@tanstack/react-query"; +import { AsyncStorageKeys } from "../enums/async-storage-keys"; +import { QueryKeys } from "../enums/query-keys"; + + +export const useServerUrl: UseQueryResult = useQuery({ + queryKey: [QueryKeys.ServerUrl], + queryFn: (() => { + return AsyncStorage.getItem(AsyncStorageKeys.ServerUrl); + }) +}); diff --git a/api/services/jellyfin-service.ts b/api/services/jellyfin-service.ts deleted file mode 100644 index d5908479..00000000 --- a/api/services/jellyfin-service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Api, Jellyfin } from "@jellyfin/sdk"; -import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api" -import { getDeviceNameSync, getUniqueIdSync } from "react-native-device-info"; -import { ArtistModel } from "../../models/ArtistModel"; -import * as Keychain from "react-native-keychain" - -let clientName : string = require('root-require')('./package.json').name -let clientVersion : string = require('root-require')('./package.json').version - -export class JellyfinService { - - private static _instance: JellyfinService; - - private api?: Api; - - // TODO: This should read in auth details when constructed? - private constructor() {}; - - private client : Jellyfin = new Jellyfin({ - clientInfo: { - name: clientName, - version: clientVersion - }, - deviceInfo: { - name: getDeviceNameSync(), - id: getUniqueIdSync() - } - }) - - public static get instance() { - - // TODO: Determine this makes singleton correctly - return this._instance || (this._instance = new this()) - } - - public static get api() : Api { - return this.instance.api! - } - - public initConnection(serverUrl: string) : void { - JellyfinService.instance.api = JellyfinService.instance.client.createApi(serverUrl); - } - - public storeAuthDetails(serverUrl: string, username: string, sessionToken: string) : Promise { - // Set sessionToken in API - JellyfinService.instance.api = JellyfinService.instance.client.createApi(serverUrl, sessionToken); - - // Store in encrypted local storage - return Keychain.setInternetCredentials(serverUrl, username, sessionToken) - - // TODO: Refresh on reopen? - } - - public async getArtistById(artistJellyfinId: string) : Promise { - - return getItemsApi(JellyfinService.instance.api!) - .getItems({ ids: [artistJellyfinId]}) - - // TODO: Error handling here - .then((response) => response.data.Items!.at(0)!) - .then(item => new ArtistModel(item)) - } -} \ No newline at end of file diff --git a/api/types/jellyfin-credentials.ts b/api/types/jellyfin-credentials.ts new file mode 100644 index 00000000..ab817afd --- /dev/null +++ b/api/types/jellyfin-credentials.ts @@ -0,0 +1,10 @@ + +export class JellyfinCredentials { + username: string; + accessToken: string; + + constructor(username: string, accessToken: string) { + this.username = username; + this.accessToken = accessToken; + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d2711f66..a7a1fe3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.1", "dependencies": { "@animatereactnative/marquee": "^0.2.0", + "@gcores/react-native-carplay": "^1.1.12", "@jellyfin/sdk": "^0.10.0", + "@react-native-async-storage/async-storage": "^2.0.0", "@sentry/react-native": "^5.30.0", "@tanstack/react-query": "^5.52.1", "react": "18.3.1", @@ -2244,6 +2246,16 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@gcores/react-native-carplay": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@gcores/react-native-carplay/-/react-native-carplay-1.1.12.tgz", + "integrity": "sha512-JavYywgXAA/XOtuW/P5fGuw+PwJC4qDvMYVRMHiIF8pJWbxl3zihautfQZzsU94ApFMoVdOPNhjHzhNXn5a8hg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -3173,6 +3185,18 @@ "node": ">= 8" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.0.0.tgz", + "integrity": "sha512-af6H9JjfL6G/PktBfUivvexoiFKQTJGQCtSWxMdivLzNIY94mu9DdiY0JqCSg/LyPCLGKhHPUlRQhNvpu3/KVA==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native-community/cli": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-14.0.0.tgz", @@ -9477,6 +9501,15 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -12082,6 +12115,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index 2d89ec80..07531373 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ }, "dependencies": { "@animatereactnative/marquee": "^0.2.0", + "@gcores/react-native-carplay": "^1.1.12", "@jellyfin/sdk": "^0.10.0", + "@react-native-async-storage/async-storage": "^2.0.0", "@sentry/react-native": "^5.30.0", "@tanstack/react-query": "^5.52.1", "react": "18.3.1",