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