diff --git a/backend/lib/lruCache.ts b/backend/lib/lruCache.ts new file mode 100644 index 0000000..9fa0457 --- /dev/null +++ b/backend/lib/lruCache.ts @@ -0,0 +1,84 @@ +// Adapted from https://levelup.gitconnected.com/implementing-lru-cache-with-node-js-and-typescript-a7c8d3f6a63 + +// Define an interface for the cache items with key-value pairs and expiry time +interface CacheItem { + key: string; + value: T; + expiryTime: number; +} + +// Create a generic LRU cache class with ttl support +class LRU { + // Define the maximum cache size and the cache data structure + private readonly maxSize: number; + private cache: Map>; + private checkIntervalHandler: NodeJS.Timeout | null = null; + + // Initialize the LRU cache with a specified maximum size and ttl + constructor(maxSize: number, checkInterval: number = 3600) { + this.maxSize = maxSize; + this.cache = new Map>(); + this.checkIntervalHandler = setInterval( + this.check.bind(this), + checkInterval * 1000 + ); + } + + destroy() { + this.cache = new Map>(); + if (this.checkIntervalHandler) { + clearInterval(this.checkIntervalHandler); + } + } + + // Add an item to the cache, evicting the least recently used item if the cache is full + set(key: string, value: T, ttl: number = 3600): void { + const expiryTime = Date.now() + ttl * 1000; + + // find the least recently used item and remove it from the cache + // get the list of keys in the cache and get the first one + if (this.cache.size >= this.maxSize) { + const lruKey = this.cache.keys().next().value; + // remove the least recently used item from the cache + this.cache.delete(lruKey); + } + + this.cache.set(key, { key, value, expiryTime }); + } + + // Retrieve an item from the cache, and update its position as the most recently used item + get(key: string): T | undefined { + const item = this.cache.get(key); + + if (item && item.expiryTime > Date.now()) { + this.cache.delete(key); + this.cache.set(key, item); + return item.value; + } else { + this.cache.delete(key); + return undefined; + } + } + + // Check for expired caches and delete it + check() { + const now = Date.now(); + for (const [key, item] of this.cache) { + if (item.expiryTime < now) { + this.cache.delete(key); + } + } + } + + // Remove an item from the cache by its key + delete(key: string): void { + this.cache.delete(key); + } + + // Clear the cache + clear(): void { + this.cache.clear(); + } +} + +export default LRU; diff --git a/backend/middlewares/cache.ts b/backend/middlewares/cache.ts new file mode 100644 index 0000000..330da74 --- /dev/null +++ b/backend/middlewares/cache.ts @@ -0,0 +1,42 @@ +import type { Context, Next } from "hono"; +import LRU from "../lib/lruCache"; + +type CacheOptions = { + ttl?: number; + noCache?: boolean; + public?: boolean; + store?: boolean; +}; + +const caches = new LRU(50); + +const cache = (options: CacheOptions = {}) => { + return (c: Context, next: Next) => { + const ttl = options.ttl || 3600; + + const cacheControl = !options.noCache + ? `public, max-age=${ttl}` + : "no-cache"; + c.header("Cache-Control", cacheControl); + + return next(); + }; +}; + +cache.key = (...keys: string[]) => { + const key = keys.join("."); + const data = cache.get(key); + const set = (value: any, ttl: number = 3600) => cache.set(key, value, ttl); + return { key, data, set }; +}; + +cache.set = (key: string, value: any, ttl: number = 3600) => { + caches.set(key, JSON.stringify(value), ttl); +}; + +cache.get = (key: string) => { + const data = caches.get(key); + return data ? JSON.parse(data) : null; +}; + +export default cache; diff --git a/backend/package.json b/backend/package.json index 77f6cff..c7f63dd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "mime": "^4.0.1", "nanoid": "^5.0.6", "node-fetch": "^3.3.2", + "node-id3": "^0.2.6", "node-pty": "^1.0.0", "systeminformation": "^5.22.2", "wol": "^1.0.7", diff --git a/backend/routes/files/id3Tags.ts b/backend/routes/files/id3Tags.ts new file mode 100644 index 0000000..d02c5ff --- /dev/null +++ b/backend/routes/files/id3Tags.ts @@ -0,0 +1,64 @@ +import type { Context } from "hono"; +import { getFilePath } from "./utils"; +import { HTTPException } from "hono/http-exception"; +import ID3 from "node-id3"; +import cache from "../../middlewares/cache"; + +export const getId3Tags = async (c: Context) => { + const url = new URL(c.req.url); + const pathname = decodeURI(url.pathname).split("/"); + const uri = pathname.slice(pathname.indexOf("id3-tags") + 1).join("/"); + const { path } = getFilePath("/" + uri); + + const cached = cache.key("id3-tags", path); + if (cached.data != null) { + return c.json(cached.data || {}); + } + + let tags: any = false; + + try { + const id3Tags = await ID3.Promise.read(path, { noRaw: true }); + const data: any = { ...id3Tags }; + + if (data.image) { + const imgUrl = new URL(url); + imgUrl.pathname = imgUrl.pathname.replace("/id3-tags", "/id3-img"); + data.image = imgUrl.toString(); + } + + tags = id3Tags; + } catch (err) {} + + cached.set(tags); + + return c.json(tags || {}); +}; + +export const getId3Image = async (c: Context) => { + const url = new URL(c.req.url); + const pathname = decodeURI(url.pathname).split("/"); + const uri = pathname.slice(pathname.indexOf("id3-img") + 1).join("/"); + const { path } = getFilePath("/" + uri); + + const cached = cache.key("id3-img", path); + if (cached.data) { + c.header("Content-Type", cached.data.mime); + const buffer = Buffer.from(cached.data.imageBuffer); + return c.body(buffer); + } + + try { + const tags = await ID3.Promise.read(path, { noRaw: true }); + const image = tags.image; + if (!image || typeof image === "string") { + throw new Error("No image found!"); + } + cached.set(image); + + c.header("Content-Type", image.mime); + return c.body(image.imageBuffer); + } catch (err) { + throw new HTTPException(400, { message: "cannot get tags!" }); + } +}; diff --git a/backend/routes/files/index.ts b/backend/routes/files/index.ts index 9dd1299..24c2b4a 100644 --- a/backend/routes/files/index.ts +++ b/backend/routes/files/index.ts @@ -4,12 +4,18 @@ import { getFilesSchema, ytdlSchema } from "./schema"; import { getFiles } from "./get"; import { download } from "./download"; import { getYtdl, ytdl } from "./ytdl"; +import cache from "../../middlewares/cache"; +import { getId3Tags, getId3Image } from "./id3Tags"; + +const cacheFile = cache({ ttl: 86400 }); const route = new Hono() .get("/", zValidator("query", getFilesSchema), getFiles) .post("/upload") .post("/ytdl", zValidator("json", ytdlSchema), ytdl) .get("/ytdl/:id", getYtdl) - .get("/download/*", download); + .get("/download/*", cacheFile, download) + .get("/id3-tags/*", cacheFile, getId3Tags) + .get("/id3-img/*", cacheFile, getId3Image); export default route; diff --git a/backend/yarn.lock b/backend/yarn.lock index 315f88b..bd89638 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -260,6 +260,13 @@ hono@^4.1.0: resolved "https://registry.yarnpkg.com/hono/-/hono-4.1.0.tgz#62cef81df0dbf731643155e1e5c1b9dffb230dc4" integrity sha512-9no6DCHb4ijB1tWdFXU6JnrnFgzwVZ1cnIcS1BjAFnMcjbtBTOMsQrDrPH3GXbkNEEEkj8kWqcYBy8Qc0bBkJQ== +iconv-lite@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" + integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -312,6 +319,13 @@ node-fetch@^3.3.2: fetch-blob "^3.1.4" formdata-polyfill "^4.0.10" +node-id3@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/node-id3/-/node-id3-0.2.6.tgz#d149517bc40c7974845d31bee15177762b416ebc" + integrity sha512-w8GuKXLlPpDjTxLowCt/uYMhRQzED3cg2GdSG1i6RSGKeDzPvxlXeLQuQInKljahPZ0aDnmyX7FX8BbJOM7REg== + dependencies: + iconv-lite "0.6.2" + node-pty@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.0.0.tgz#7daafc0aca1c4ca3de15c61330373af4af5861fd" @@ -324,6 +338,11 @@ resolve-pkg-maps@^1.0.0: resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + sax@^1.1.3, sax@^1.2.4: version "1.3.0" resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0" diff --git a/package.json b/package.json index ac53cfb..2e38d3b 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "expo-router": "~3.4.8", "expo-status-bar": "~1.11.1", "hono": "^4.1.0", - "jsmediatags": "^3.9.7", "react": "18.2.0", "react-dom": "18.2.0", "react-helmet": "^6.1.0", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index c299457..4090956 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,3 +1,4 @@ +import "./styles.css"; import React, { useEffect, useState } from "react"; import { Stack, router, usePathname } from "expo-router"; import { QueryClientProvider } from "react-query"; diff --git a/src/app/apps/files.tsx b/src/app/apps/files.tsx index 3907a28..2ca94e5 100644 --- a/src/app/apps/files.tsx +++ b/src/app/apps/files.tsx @@ -114,6 +114,7 @@ const FilesPage = () => { const fileType = getFileType(file.path); if (fileType === "audio") { + audioPlayer.expand(); return audioPlayer.play(data, idx); } diff --git a/src/app/styles.css b/src/app/styles.css new file mode 100644 index 0000000..5f144c0 --- /dev/null +++ b/src/app/styles.css @@ -0,0 +1,20 @@ + +::-webkit-scrollbar { + width: 7px; +} + +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.1); + border-radius: 7px; +} + +::-webkit-scrollbar-thumb { + background: #6366f1; + border-radius: 7px; +} + +@supports not selector(::-webkit-scrollbar) { + * { + scrollbar-color: #6366f1 #232e33; + } +} diff --git a/src/components/containers/AudioPlayerProvider.tsx b/src/components/containers/AudioPlayerProvider.tsx index 7c58820..a00341f 100644 --- a/src/components/containers/AudioPlayerProvider.tsx +++ b/src/components/containers/AudioPlayerProvider.tsx @@ -1,8 +1,10 @@ import { getFileUrl } from "@/app/apps/lib"; +import { fetchAPI } from "@/lib/api"; +import { API_BASEURL } from "@/lib/constants"; import { base64encode } from "@/lib/utils"; import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore"; +import authStore from "@/stores/authStore"; import { AVPlaybackStatusSuccess, Audio } from "expo-av"; -import jsmediatags from "jsmediatags/build2/jsmediatags"; import { useEffect, useRef } from "react"; import { useStore } from "zustand"; @@ -48,29 +50,18 @@ const AudioPlayerProvider = () => { } } - function loadMediaTags() { - const tagsReader = new jsmediatags.Reader(uri + "&dl=true"); + async function loadMediaTags() { audioPlayerStore.setState({ mediaTags: null }); - tagsReader.read({ - onSuccess: (result: any) => { - const mediaTagsResult = { ...result }; + try { + const url = + `${API_BASEURL}/files/id3-tags${fileData.path}?token=` + + authStore.getState().token; - if (result?.tags?.picture) { - const { data, format } = result.tags.picture; - let base64String = ""; - for (let i = 0; i < data.length; i++) { - base64String += String.fromCharCode(data[i]); - } - mediaTagsResult.picture = `data:${format};base64,${base64encode( - base64String - )}`; - delete data?.tags?.picture; - } - - audioPlayerStore.setState({ mediaTags: mediaTagsResult }); - }, - }); + const res = await fetchAPI(url); + const data = await res.json(); + audioPlayerStore.setState({ mediaTags: data }); + } catch (err) {} } loadMediaTags(); diff --git a/src/components/pages/music/AudioPlayer.tsx b/src/components/pages/music/AudioPlayer.tsx index 12e04d8..8b732cc 100644 --- a/src/components/pages/music/AudioPlayer.tsx +++ b/src/components/pages/music/AudioPlayer.tsx @@ -13,6 +13,12 @@ import Input from "@ui/Input"; import { useStore } from "zustand"; import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore"; import Modal from "react-native-modal"; +import { getFileUrl } from "@/app/apps/lib"; +import { useQuery } from "react-query"; +import { API_BASEURL } from "@/lib/constants"; +import authStore from "@/stores/authStore"; +import { fetchAPI } from "@/lib/api"; +import { MediaTags } from "@/types/mediaTags"; const AudioPlayer = () => { const expanded = useStore(audioPlayerStore, (i) => i.expanded); @@ -41,7 +47,7 @@ const AudioPlayerView = React.memo(({ onClose }: { onClose: () => void }) => { void }) => { onPress={onClose} /> - - {mediaTags?.picture ? ( + + {mediaTags?.image ? ( @@ -74,11 +80,11 @@ const AudioPlayerView = React.memo(({ onClose }: { onClose: () => void }) => { className="text-white text-xl md:text-3xl mt-4" numberOfLines={1} > - {mediaTags?.tags?.title || filename} + {mediaTags?.title || filename} - {mediaTags?.tags?.artist ? ( + {mediaTags?.artist ? ( - {mediaTags.tags.artist} + {mediaTags.artist} ) : null} @@ -157,9 +163,9 @@ const AudioPlayerView = React.memo(({ onClose }: { onClose: () => void }) => { ); }); -const PLAYLIST_ITEM_HEIGHT = 49; +const PLAYLIST_ITEM_HEIGHT = 82; -const Playlist = () => { +const Playlist = React.memo(() => { const playlist = useStore(audioPlayerStore, (i) => i.playlist); const currentIdx = useStore(audioPlayerStore, (i) => i.currentIdx); const containerRef = useRef(); @@ -204,9 +210,8 @@ const Playlist = () => { i.path} renderItem={({ item }) => ( { ); -}; +}); type PlaylistItemProps = { file: FileItem; @@ -230,17 +235,43 @@ type PlaylistItemProps = { }; const PlaylistItem = ({ file, isCurrent, onPress }: PlaylistItemProps) => { + const url = + `${API_BASEURL}/files/id3-tags${file.path}?token=` + + authStore.getState().token; + const { data: id3Tags } = useQuery({ + queryKey: ["id3-tags", file.path], + queryFn: () => fetchAPI(url).then((i) => i.json()), + }); + return ( - - {file.name} - + + {id3Tags?.image ? ( + + ) : ( + + )} + + + + {id3Tags?.title || file.name} + + {id3Tags?.artist ? ( + + {id3Tags?.artist} + + ) : null} + ); }; diff --git a/src/lib/api.ts b/src/lib/api.ts index 4986e8f..1e7821b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -33,22 +33,14 @@ export class ApiError extends Error { async function fetchHandler(input: any, init?: RequestInit) { const token = authStore.getState().token; + const config = typeof input === "object" ? input : init || {}; - if (init) { - init.headers = new Headers(init.headers); - if (token) { - init.headers.set("Authorization", `Bearer ${token}`); - } + config.headers = new Headers(config.headers); + if (token) { + config.headers.set("Authorization", `Bearer ${token}`); } - if (typeof input === "object") { - input.headers = new Headers(init.headers); - if (token) { - input.headers.set("Authorization", `Bearer ${token}`); - } - } - - const res = await fetch(input, init); + const res = await fetch(input, config); await checkResponse(res); return res; @@ -84,5 +76,6 @@ export { InferResponseType, ClientRequestOptions, ClientResponse, + fetchHandler as fetchAPI, }; export default api; diff --git a/src/stores/audioPlayerStore.ts b/src/stores/audioPlayerStore.ts index 93f082f..8953fbb 100644 --- a/src/stores/audioPlayerStore.ts +++ b/src/stores/audioPlayerStore.ts @@ -101,6 +101,10 @@ const seek = async (progress: number) => { sound.setPositionAsync(pos); }; +const expand = () => { + audioPlayerStore.setState({ expanded: true }); +}; + export const audioPlayer = { store: audioPlayerStore, play, @@ -108,4 +112,5 @@ export const audioPlayer = { prev, next, seek, + expand, }; diff --git a/src/types/mediaTags.ts b/src/types/mediaTags.ts index a17c32a..ac0e0ab 100644 --- a/src/types/mediaTags.ts +++ b/src/types/mediaTags.ts @@ -1,83 +1,18 @@ export type MediaTags = { - type: string; - version: string; - major: number; - revision: number; - flags: Flags; - size: number; - tags: Tags; - picture?: string; -}; - -export type Flags = { - unsynchronisation: boolean; - extended_header: boolean; - experimental_indicator: boolean; - footer_present: boolean; -}; - -export type Tags = { title: string; - artist: string; album: string; - year: string; - comment: Comment; - track: string; genre: string; - // picture: Picture; - TALB: Talb; - TPE1: Talb; - COMM: Comm; - TCON: Talb; - TIT2: Talb; - TRCK: Talb; - TYER: Talb; - TXXX: Txxx; - APIC: APIC; + year: string; + trackNumber: string; + partOfSet: string; + artist: string; + performerInfo: string; + composer: string; + userDefinedText: UserDefinedText[]; + image: string; }; -export type APIC = { - id: string; - size: number; +export type UserDefinedText = { description: string; - data: Picture; -}; - -export type Picture = { - format: string; - type: string; - description: string; - data: number[]; -}; - -export type Comm = { - id: string; - size: number; - description: string; - data: Comment; -}; - -export type Comment = { - language: string; - short_description: string; - text: string; -}; - -export type Talb = { - id: string; - size: number; - description: string; - data: string; -}; - -export type Txxx = { - id: string; - size: number; - description: string; - data: Data; -}; - -export type Data = { - user_description: string; - data: string; + value: string; }; diff --git a/yarn.lock b/yarn.lock index 7c789f0..76fbbce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6320,13 +6320,6 @@ jsesc@~0.5.0: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== -jsmediatags@^3.9.7: - version "3.9.7" - resolved "https://registry.yarnpkg.com/jsmediatags/-/jsmediatags-3.9.7.tgz#808c6713b5ccb9712a4dc4b2149a0cb253c641a2" - integrity sha512-xCAO8C3li3t5hYkXqn8iv8zQQUB4T1QqRN2aSONHMls21ICdEvXi4xtb6W70/fAFYSDwMHd32hIqvo4YuXoNcQ== - dependencies: - xhr2 "^0.1.4" - json-buffer@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" @@ -10084,11 +10077,6 @@ xdg-basedir@^4.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== -xhr2@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.4.tgz#7f87658847716db5026323812f818cadab387a5f" - integrity sha512-3QGhDryRzTbIDj+waTRvMBe8SyPhW79kz3YnNb+HQt/6LPYQT3zT3Jt0Y8pBofZqQX26x8Ecfv0FXR72uH5VpA== - xml2js@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.0.tgz#07afc447a97d2bd6507a1f76eeadddb09f7a8282"