feat: add api files caching, update audioplayer ui

This commit is contained in:
Khairul Hidayat 2024-03-25 06:59:40 +07:00
parent 33c91ac0a7
commit 339969baa8
16 changed files with 320 additions and 140 deletions

84
backend/lib/lruCache.ts Normal file
View File

@ -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<T> {
key: string;
value: T;
expiryTime: number;
}
// Create a generic LRU cache class with ttl support
class LRU<T> {
// Define the maximum cache size and the cache data structure
private readonly maxSize: number;
private cache: Map<string, CacheItem<T>>;
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<string, CacheItem<T>>();
this.checkIntervalHandler = setInterval(
this.check.bind(this),
checkInterval * 1000
);
}
destroy() {
this.cache = new Map<string, CacheItem<T>>();
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;

View File

@ -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<string>(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;

View File

@ -27,6 +27,7 @@
"mime": "^4.0.1", "mime": "^4.0.1",
"nanoid": "^5.0.6", "nanoid": "^5.0.6",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"node-id3": "^0.2.6",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"systeminformation": "^5.22.2", "systeminformation": "^5.22.2",
"wol": "^1.0.7", "wol": "^1.0.7",

View File

@ -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!" });
}
};

View File

@ -4,12 +4,18 @@ import { getFilesSchema, ytdlSchema } from "./schema";
import { getFiles } from "./get"; import { getFiles } from "./get";
import { download } from "./download"; import { download } from "./download";
import { getYtdl, ytdl } from "./ytdl"; import { getYtdl, ytdl } from "./ytdl";
import cache from "../../middlewares/cache";
import { getId3Tags, getId3Image } from "./id3Tags";
const cacheFile = cache({ ttl: 86400 });
const route = new Hono() const route = new Hono()
.get("/", zValidator("query", getFilesSchema), getFiles) .get("/", zValidator("query", getFilesSchema), getFiles)
.post("/upload") .post("/upload")
.post("/ytdl", zValidator("json", ytdlSchema), ytdl) .post("/ytdl", zValidator("json", ytdlSchema), ytdl)
.get("/ytdl/:id", getYtdl) .get("/ytdl/:id", getYtdl)
.get("/download/*", download); .get("/download/*", cacheFile, download)
.get("/id3-tags/*", cacheFile, getId3Tags)
.get("/id3-img/*", cacheFile, getId3Image);
export default route; export default route;

View File

@ -260,6 +260,13 @@ hono@^4.1.0:
resolved "https://registry.yarnpkg.com/hono/-/hono-4.1.0.tgz#62cef81df0dbf731643155e1e5c1b9dffb230dc4" resolved "https://registry.yarnpkg.com/hono/-/hono-4.1.0.tgz#62cef81df0dbf731643155e1e5c1b9dffb230dc4"
integrity sha512-9no6DCHb4ijB1tWdFXU6JnrnFgzwVZ1cnIcS1BjAFnMcjbtBTOMsQrDrPH3GXbkNEEEkj8kWqcYBy8Qc0bBkJQ== 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: isexe@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" 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" fetch-blob "^3.1.4"
formdata-polyfill "^4.0.10" 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: node-pty@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.0.0.tgz#7daafc0aca1c4ca3de15c61330373af4af5861fd" 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" resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f"
integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== 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: sax@^1.1.3, sax@^1.2.4:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0" resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0"

View File

@ -26,7 +26,6 @@
"expo-router": "~3.4.8", "expo-router": "~3.4.8",
"expo-status-bar": "~1.11.1", "expo-status-bar": "~1.11.1",
"hono": "^4.1.0", "hono": "^4.1.0",
"jsmediatags": "^3.9.7",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",

View File

@ -1,3 +1,4 @@
import "./styles.css";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Stack, router, usePathname } from "expo-router"; import { Stack, router, usePathname } from "expo-router";
import { QueryClientProvider } from "react-query"; import { QueryClientProvider } from "react-query";

View File

@ -114,6 +114,7 @@ const FilesPage = () => {
const fileType = getFileType(file.path); const fileType = getFileType(file.path);
if (fileType === "audio") { if (fileType === "audio") {
audioPlayer.expand();
return audioPlayer.play(data, idx); return audioPlayer.play(data, idx);
} }

20
src/app/styles.css Normal file
View File

@ -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;
}
}

View File

@ -1,8 +1,10 @@
import { getFileUrl } from "@/app/apps/lib"; import { getFileUrl } from "@/app/apps/lib";
import { fetchAPI } from "@/lib/api";
import { API_BASEURL } from "@/lib/constants";
import { base64encode } from "@/lib/utils"; import { base64encode } from "@/lib/utils";
import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore"; import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore";
import authStore from "@/stores/authStore";
import { AVPlaybackStatusSuccess, Audio } from "expo-av"; import { AVPlaybackStatusSuccess, Audio } from "expo-av";
import jsmediatags from "jsmediatags/build2/jsmediatags";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useStore } from "zustand"; import { useStore } from "zustand";
@ -48,29 +50,18 @@ const AudioPlayerProvider = () => {
} }
} }
function loadMediaTags() { async function loadMediaTags() {
const tagsReader = new jsmediatags.Reader(uri + "&dl=true");
audioPlayerStore.setState({ mediaTags: null }); audioPlayerStore.setState({ mediaTags: null });
tagsReader.read({ try {
onSuccess: (result: any) => { const url =
const mediaTagsResult = { ...result }; `${API_BASEURL}/files/id3-tags${fileData.path}?token=` +
authStore.getState().token;
if (result?.tags?.picture) { const res = await fetchAPI(url);
const { data, format } = result.tags.picture; const data = await res.json();
let base64String = ""; audioPlayerStore.setState({ mediaTags: data });
for (let i = 0; i < data.length; i++) { } catch (err) {}
base64String += String.fromCharCode(data[i]);
}
mediaTagsResult.picture = `data:${format};base64,${base64encode(
base64String
)}`;
delete data?.tags?.picture;
}
audioPlayerStore.setState({ mediaTags: mediaTagsResult });
},
});
} }
loadMediaTags(); loadMediaTags();

View File

@ -13,6 +13,12 @@ import Input from "@ui/Input";
import { useStore } from "zustand"; import { useStore } from "zustand";
import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore"; import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore";
import Modal from "react-native-modal"; 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 AudioPlayer = () => {
const expanded = useStore(audioPlayerStore, (i) => i.expanded); const expanded = useStore(audioPlayerStore, (i) => i.expanded);
@ -41,7 +47,7 @@ const AudioPlayerView = React.memo(({ onClose }: { onClose: () => void }) => {
<Box className="flex-1 items-stretch relative"> <Box className="flex-1 items-stretch relative">
<Box className="absolute -inset-10 -z-[1]"> <Box className="absolute -inset-10 -z-[1]">
<Image <Image
source={mediaTags?.picture ? { uri: mediaTags?.picture } : bgImage} source={mediaTags?.image ? { uri: mediaTags.image } : bgImage}
style={cn("absolute -inset-5 w-full h-full")} style={cn("absolute -inset-5 w-full h-full")}
resizeMode="cover" resizeMode="cover"
blurRadius={10} blurRadius={10}
@ -58,11 +64,11 @@ const AudioPlayerView = React.memo(({ onClose }: { onClose: () => void }) => {
onPress={onClose} onPress={onClose}
/> />
<Box className="flex flex-col items-center justify-center p-8 flex-1 overflow-hidden"> <Box className="flex flex-col items-center justify-center p-8 pt-20 flex-1 overflow-hidden">
{mediaTags?.picture ? ( {mediaTags?.image ? (
<Box className="aspect-square flex-1 max-h-[400px] mb-8"> <Box className="aspect-square flex-1 max-h-[400px] mb-8">
<Image <Image
source={{ uri: mediaTags.picture }} source={{ uri: mediaTags.image }}
style={cn("w-full h-full")} style={cn("w-full h-full")}
resizeMode="cover" resizeMode="cover"
/> />
@ -74,11 +80,11 @@ const AudioPlayerView = React.memo(({ onClose }: { onClose: () => void }) => {
className="text-white text-xl md:text-3xl mt-4" className="text-white text-xl md:text-3xl mt-4"
numberOfLines={1} numberOfLines={1}
> >
{mediaTags?.tags?.title || filename} {mediaTags?.title || filename}
</Text> </Text>
{mediaTags?.tags?.artist ? ( {mediaTags?.artist ? (
<Text className="text-white mt-2" numberOfLines={1}> <Text className="text-white mt-2" numberOfLines={1}>
{mediaTags.tags.artist} {mediaTags.artist}
</Text> </Text>
) : null} ) : 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 playlist = useStore(audioPlayerStore, (i) => i.playlist);
const currentIdx = useStore(audioPlayerStore, (i) => i.currentIdx); const currentIdx = useStore(audioPlayerStore, (i) => i.currentIdx);
const containerRef = useRef<any>(); const containerRef = useRef<any>();
@ -204,9 +210,8 @@ const Playlist = () => {
<FlatList <FlatList
ref={containerRef} ref={containerRef}
contentContainerStyle={cn("px-4")}
data={list} data={list}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
keyExtractor={(i) => i.path} keyExtractor={(i) => i.path}
renderItem={({ item }) => ( renderItem={({ item }) => (
<PlaylistItem <PlaylistItem
@ -221,7 +226,7 @@ const Playlist = () => {
</Box> </Box>
</Box> </Box>
); );
}; });
type PlaylistItemProps = { type PlaylistItemProps = {
file: FileItem; file: FileItem;
@ -230,17 +235,43 @@ type PlaylistItemProps = {
}; };
const PlaylistItem = ({ file, isCurrent, onPress }: PlaylistItemProps) => { const PlaylistItem = ({ file, isCurrent, onPress }: PlaylistItemProps) => {
const url =
`${API_BASEURL}/files/id3-tags${file.path}?token=` +
authStore.getState().token;
const { data: id3Tags } = useQuery<MediaTags>({
queryKey: ["id3-tags", file.path],
queryFn: () => fetchAPI(url).then((i) => i.json()),
});
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
style={cn( style={cn(
"py-4 px-5 border-b border-white/10", "mb-4 flex flex-row items-center gap-4 border border-transparent",
isCurrent && "bg-[#323232]" isCurrent && "bg-white/10 border-white/20 rounded-lg overflow-hidden"
)} )}
> >
<Text className="text-white" numberOfLines={1}> <Box className="bg-gray-800 w-16 h-16 flex items-center justify-center">
{file.name} {id3Tags?.image ? (
</Text> <Image
source={{ uri: id3Tags.image }}
style={cn("w-full h-full")}
resizeMode="cover"
/>
) : (
<Ionicons name="musical-notes" style={cn("text-white text-2xl")} />
)}
</Box>
<Box className="py-2 flex-1">
<Text className="text-white font-bold" numberOfLines={1}>
{id3Tags?.title || file.name}
</Text>
{id3Tags?.artist ? (
<Text className="text-white mt-2" numberOfLines={1}>
{id3Tags?.artist}
</Text>
) : null}
</Box>
</Pressable> </Pressable>
); );
}; };

View File

@ -33,22 +33,14 @@ export class ApiError extends Error {
async function fetchHandler(input: any, init?: RequestInit) { async function fetchHandler(input: any, init?: RequestInit) {
const token = authStore.getState().token; const token = authStore.getState().token;
const config = typeof input === "object" ? input : init || {};
if (init) { config.headers = new Headers(config.headers);
init.headers = new Headers(init.headers); if (token) {
if (token) { config.headers.set("Authorization", `Bearer ${token}`);
init.headers.set("Authorization", `Bearer ${token}`);
}
} }
if (typeof input === "object") { const res = await fetch(input, config);
input.headers = new Headers(init.headers);
if (token) {
input.headers.set("Authorization", `Bearer ${token}`);
}
}
const res = await fetch(input, init);
await checkResponse(res); await checkResponse(res);
return res; return res;
@ -84,5 +76,6 @@ export {
InferResponseType, InferResponseType,
ClientRequestOptions, ClientRequestOptions,
ClientResponse, ClientResponse,
fetchHandler as fetchAPI,
}; };
export default api; export default api;

View File

@ -101,6 +101,10 @@ const seek = async (progress: number) => {
sound.setPositionAsync(pos); sound.setPositionAsync(pos);
}; };
const expand = () => {
audioPlayerStore.setState({ expanded: true });
};
export const audioPlayer = { export const audioPlayer = {
store: audioPlayerStore, store: audioPlayerStore,
play, play,
@ -108,4 +112,5 @@ export const audioPlayer = {
prev, prev,
next, next,
seek, seek,
expand,
}; };

View File

@ -1,83 +1,18 @@
export type MediaTags = { 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; title: string;
artist: string;
album: string; album: string;
year: string;
comment: Comment;
track: string;
genre: string; genre: string;
// picture: Picture; year: string;
TALB: Talb; trackNumber: string;
TPE1: Talb; partOfSet: string;
COMM: Comm; artist: string;
TCON: Talb; performerInfo: string;
TIT2: Talb; composer: string;
TRCK: Talb; userDefinedText: UserDefinedText[];
TYER: Talb; image: string;
TXXX: Txxx;
APIC: APIC;
}; };
export type APIC = { export type UserDefinedText = {
id: string;
size: number;
description: string; description: string;
data: Picture; value: string;
};
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;
}; };

View File

@ -6320,13 +6320,6 @@ jsesc@~0.5.0:
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== 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: json-buffer@3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" 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" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== 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: xml2js@0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.0.tgz#07afc447a97d2bd6507a1f76eeadddb09f7a8282" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.0.tgz#07afc447a97d2bd6507a1f76eeadddb09f7a8282"