xref: /MusicFree/src/core/localMusicSheet.ts (revision adf41771e5c3ca7c27879b461cece7e444d1dc58)
1import {
2    internalSerializeKey,
3    StorageKeys,
4    supportLocalMediaType,
5} from '@/constants/commonConst';
6import mp3Util, {IBasicMeta} from '@/native/mp3Util';
7import {
8    getInternalData,
9    InternalDataType,
10    isSameMediaItem,
11} from '@/utils/mediaItem';
12import StateMapper from '@/utils/stateMapper';
13import {getStorage, setStorage} from '@/utils/storage';
14import {nanoid} from 'nanoid';
15import {useEffect, useState} from 'react';
16import {unlink} from 'react-native-fs';
17import {getInfoAsync, readDirectoryAsync} from 'expo-file-system';
18import {addFileScheme, getFileName} from '@/utils/fileUtils.ts';
19
20let localSheet: IMusic.IMusicItem[] = [];
21const localSheetStateMapper = new StateMapper(() => localSheet);
22
23export async function setup() {
24    const sheet = await getStorage(StorageKeys.LocalMusicSheet);
25    if (sheet) {
26        let validSheet: IMusic.IMusicItem[] = [];
27        for (let musicItem of sheet) {
28            const localPath = getInternalData<string>(
29                musicItem,
30                InternalDataType.LOCALPATH,
31            );
32            if (localPath && (await getInfoAsync(localPath)).exists) {
33                validSheet.push(musicItem);
34            }
35        }
36        if (validSheet.length !== sheet.length) {
37            await setStorage(StorageKeys.LocalMusicSheet, validSheet);
38        }
39        localSheet = validSheet;
40    } else {
41        await setStorage(StorageKeys.LocalMusicSheet, []);
42    }
43    localSheetStateMapper.notify();
44}
45
46export async function addMusic(
47    musicItem: IMusic.IMusicItem | IMusic.IMusicItem[],
48) {
49    if (!Array.isArray(musicItem)) {
50        musicItem = [musicItem];
51    }
52    let newSheet = [...localSheet];
53    musicItem.forEach(mi => {
54        if (localSheet.findIndex(_ => isSameMediaItem(mi, _)) === -1) {
55            newSheet.push(mi);
56        }
57    });
58    await setStorage(StorageKeys.LocalMusicSheet, newSheet);
59    localSheet = newSheet;
60    localSheetStateMapper.notify();
61}
62
63function addMusicDraft(musicItem: IMusic.IMusicItem | IMusic.IMusicItem[]) {
64    if (!Array.isArray(musicItem)) {
65        musicItem = [musicItem];
66    }
67    let newSheet = [...localSheet];
68    musicItem.forEach(mi => {
69        if (localSheet.findIndex(_ => isSameMediaItem(mi, _)) === -1) {
70            newSheet.push(mi);
71        }
72    });
73    localSheet = newSheet;
74    localSheetStateMapper.notify();
75}
76
77async function saveLocalSheet() {
78    await setStorage(StorageKeys.LocalMusicSheet, localSheet);
79}
80
81export async function removeMusic(
82    musicItem: IMusic.IMusicItem,
83    deleteOriginalFile = false,
84) {
85    const idx = localSheet.findIndex(_ => isSameMediaItem(_, musicItem));
86    let newSheet = [...localSheet];
87    if (idx !== -1) {
88        const localMusicItem = localSheet[idx];
89        newSheet.splice(idx, 1);
90        const localPath =
91            musicItem[internalSerializeKey]?.localPath ??
92            localMusicItem[internalSerializeKey]?.localPath;
93        if (deleteOriginalFile && localPath) {
94            try {
95                await unlink(localPath);
96            } catch (e: any) {
97                if (e.message !== 'File does not exist') {
98                    throw e;
99                }
100            }
101        }
102    }
103    localSheet = newSheet;
104    localSheetStateMapper.notify();
105    saveLocalSheet();
106}
107
108function parseFilename(fn: string): Partial<IMusic.IMusicItem> | null {
109    const data = fn.slice(0, fn.lastIndexOf('.')).split('@');
110    const [platform, id, title, artist] = data;
111    if (!platform || !id) {
112        return null;
113    }
114    return {
115        id,
116        platform: platform,
117        title: title ?? '',
118        artist: artist ?? '',
119    };
120}
121
122function localMediaFilter(filename: string) {
123    return supportLocalMediaType.some(ext => filename.endsWith(ext));
124}
125
126let importToken: string | null = null;
127// 获取本地的文件列表
128async function getMusicStats(folderPaths: string[]) {
129    const _importToken = nanoid();
130    importToken = _importToken;
131    const musicList: string[] = [];
132    let peek: string | undefined;
133    let dirFiles: string[] = [];
134    while (folderPaths.length !== 0) {
135        if (importToken !== _importToken) {
136            throw new Error('Import Broken');
137        }
138        peek = folderPaths.shift() as string;
139        try {
140            dirFiles = await readDirectoryAsync(peek);
141        } catch {
142            dirFiles = [];
143        }
144
145        await Promise.all(
146            dirFiles.map(async fileName => {
147                const stat = await getInfoAsync(peek + '/' + fileName);
148                if (!stat.exists) {
149                    return;
150                }
151                if (stat.isDirectory && !folderPaths.includes(stat.uri)) {
152                    folderPaths.push(stat.uri);
153                } else if (localMediaFilter(stat.uri)) {
154                    musicList.push(stat.uri);
155                }
156            }),
157        );
158    }
159    return {musicList, token: _importToken};
160}
161
162function cancelImportLocal() {
163    importToken = null;
164}
165
166// 导入本地音乐
167const groupNum = 25;
168async function importLocal(_folderPaths: string[]) {
169    const folderPaths = [..._folderPaths.map(it => addFileScheme(it))];
170    const {musicList, token} = await getMusicStats(folderPaths);
171    console.log('HI!!!', musicList, folderPaths, _folderPaths);
172    if (token !== importToken) {
173        throw new Error('Import Broken');
174    }
175    // 分组请求,不然序列化可能出问题
176    let metas: IBasicMeta[] = [];
177    const groups = Math.ceil(musicList.length / groupNum);
178    for (let i = 0; i < groups; ++i) {
179        metas = metas.concat(
180            await mp3Util.getMediaMeta(
181                musicList.slice(i * groupNum, (i + 1) * groupNum),
182            ),
183        );
184    }
185    if (token !== importToken) {
186        throw new Error('Import Broken');
187    }
188    const musicItems: IMusic.IMusicItem[] = await Promise.all(
189        musicList.map(async (musicPath, index) => {
190            let {platform, id, title, artist} =
191                parseFilename(getFileName(musicPath, true)) ?? {};
192            const meta = metas[index];
193            if (!platform || !id) {
194                platform = '本地';
195                const fileInfo = await getInfoAsync(musicPath, {
196                    md5: true,
197                });
198                id = fileInfo.exists ? fileInfo.md5 : nanoid();
199            }
200            return {
201                id,
202                platform,
203                title: title ?? meta?.title ?? getFileName(musicPath),
204                artist: artist ?? meta?.artist ?? '未知歌手',
205                duration: parseInt(meta?.duration ?? '0', 10) / 1000,
206                album: meta?.album ?? '未知专辑',
207                artwork: '',
208                [internalSerializeKey]: {
209                    localPath: musicPath,
210                },
211            } as IMusic.IMusicItem;
212        }),
213    );
214    if (token !== importToken) {
215        throw new Error('Import Broken');
216    }
217    addMusic(musicItems);
218}
219
220/** 是否为本地音乐 */
221function isLocalMusic(
222    musicItem: ICommon.IMediaBase | null,
223): IMusic.IMusicItem | undefined {
224    return musicItem
225        ? localSheet.find(_ => isSameMediaItem(_, musicItem))
226        : undefined;
227}
228
229/** 状态-是否为本地音乐 */
230function useIsLocal(musicItem: IMusic.IMusicItem | null) {
231    const localMusicState = localSheetStateMapper.useMappedState();
232    const [isLocal, setIsLocal] = useState<boolean>(!!isLocalMusic(musicItem));
233    useEffect(() => {
234        if (!musicItem) {
235            setIsLocal(false);
236        } else {
237            setIsLocal(!!isLocalMusic(musicItem));
238        }
239    }, [localMusicState, musicItem]);
240    return isLocal;
241}
242
243function getMusicList() {
244    return localSheet;
245}
246
247async function updateMusicList(newSheet: IMusic.IMusicItem[]) {
248    const _localSheet = [...newSheet];
249    try {
250        await setStorage(StorageKeys.LocalMusicSheet, _localSheet);
251        localSheet = _localSheet;
252        localSheetStateMapper.notify();
253    } catch {}
254}
255
256const LocalMusicSheet = {
257    setup,
258    addMusic,
259    removeMusic,
260    addMusicDraft,
261    saveLocalSheet,
262    importLocal,
263    cancelImportLocal,
264    isLocalMusic,
265    useIsLocal,
266    getMusicList,
267    useMusicList: localSheetStateMapper.useMappedState,
268    updateMusicList,
269};
270
271export default LocalMusicSheet;
272