From 474045f3ce7bc36b629bedea80380648b9e7f879 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Tue, 13 Aug 2024 08:28:02 +0800 Subject: [PATCH] :sparkles: feat: cache ncm song Signed-off-by: SimonShiki --- src-tauri/LICENSE | 28 +++++++ src-tauri/src/cache_manager.rs | 137 +++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 8 ++ src/jotais/storage.ts | 2 +- src/storages/ncm.ts | 14 ++-- src/utils/cache..ts | 9 +++ src/utils/chunk-transformer.ts | 4 +- src/utils/player.ts | 14 +++- 8 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 src-tauri/LICENSE create mode 100644 src-tauri/src/cache_manager.rs create mode 100644 src/utils/cache..ts diff --git a/src-tauri/LICENSE b/src-tauri/LICENSE new file mode 100644 index 0000000..fc366d3 --- /dev/null +++ b/src-tauri/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Simon Shiki + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src-tauri/src/cache_manager.rs b/src-tauri/src/cache_manager.rs new file mode 100644 index 0000000..3d0f6bd --- /dev/null +++ b/src-tauri/src/cache_manager.rs @@ -0,0 +1,137 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tauri::State; + +pub struct CacheManagerState(pub Arc); + +pub struct CacheManager { + cache_dir: PathBuf, + max_cache_size: u64, + current_cache_size: Mutex, + cache_items: Mutex>, +} + +struct CacheItem { + id: u64, + path: PathBuf, + size: u64, + last_accessed: std::time::SystemTime, +} + +impl CacheManager { + pub fn new(cache_dir: PathBuf, max_cache_size: u64) -> Self { + let cm = CacheManager { + cache_dir, + max_cache_size, + current_cache_size: Mutex::new(0), + cache_items: Mutex::new(HashMap::new()), + }; + cm.init(); + cm + } + + fn init(&self) { + if !self.cache_dir.exists() { + fs::create_dir_all(&self.cache_dir).expect("Failed to create cache directory"); + } + self.load_cache_info(); + } + + fn load_cache_info(&self) { + let mut current_size = 0; + let mut items = HashMap::new(); + + if let Ok(entries) = fs::read_dir(&self.cache_dir) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + let id = entry + .file_name() + .to_str() + .and_then(|s| s.split('.').next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + let item = CacheItem { + id, + path: entry.path(), + size: metadata.len(), + last_accessed: metadata.modified().unwrap_or_else(|_| std::time::SystemTime::now()), + }; + + current_size += item.size; + items.insert(id, item); + } + } + } + } + + *self.current_cache_size.lock().unwrap() = current_size; + *self.cache_items.lock().unwrap() = items; + } + + pub fn get_cached_song(&self, id: u64) -> Option> { + let mut items = self.cache_items.lock().unwrap(); + if let Some(item) = items.get_mut(&id) { + item.last_accessed = std::time::SystemTime::now(); + fs::read(&item.path).ok() + } else { + None + } + } + + pub fn cache_song(&self, id: u64, data: &[u8]) -> Result<(), String> { + let file_path = self.cache_dir.join(format!("{}.mp3", id)); + self.ensure_space_available(data.len() as u64)?; + + fs::write(&file_path, data).map_err(|e| e.to_string())?; + + let mut items = self.cache_items.lock().unwrap(); + let mut current_size = self.current_cache_size.lock().unwrap(); + + items.insert( + id, + CacheItem { + id, + path: file_path, + size: data.len() as u64, + last_accessed: std::time::SystemTime::now(), + }, + ); + *current_size += data.len() as u64; + + Ok(()) + } + + fn ensure_space_available(&self, required_space: u64) -> Result<(), String> { + let mut current_size = self.current_cache_size.lock().unwrap(); + let mut items = self.cache_items.lock().unwrap(); + + while *current_size + required_space > self.max_cache_size && !items.is_empty() { + let oldest_id = items + .iter() + .min_by_key(|(_, item)| item.last_accessed) + .map(|(id, _)| *id) + .ok_or_else(|| "No items to remove".to_string())?; + + if let Some(item) = items.remove(&oldest_id) { + fs::remove_file(&item.path).map_err(|e| e.to_string())?; + *current_size -= item.size; + } + } + + Ok(()) + } +} + +#[tauri::command] +pub fn get_cached_song(id: u64, state: State) -> Option> { + state.0.get_cached_song(id) +} + +#[tauri::command] +pub fn cache_song(id: u64, data: Vec, state: State) -> Result<(), String> { + state.0.cache_song(id, &data) +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c6ab270..6efc3b2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,8 +2,10 @@ mod audio; mod media_control; mod error; mod local_scanner; +mod cache_manager; use audio::AudioState; +use cache_manager::{CacheManager, CacheManagerState}; use media_control::MediaControlState; use rodio::OutputStream; use std::sync::{Arc, Condvar, Mutex, Once}; @@ -38,6 +40,10 @@ pub async fn run() { .manage(audio_state) .manage(media_control_state) .setup(|app| { + let cache_dir = app.path().app_cache_dir().unwrap(); + let cache_manager = Arc::new(CacheManager::new(cache_dir, 1024 * 1024 * 1024)); // 1GB cache limit + app.manage(CacheManagerState(cache_manager)); + let show = MenuItemBuilder::with_id("show", "Show").build(app)?; let pause_resume = MenuItemBuilder::with_id("pause_resume", "Pause/Resume").build(app)?; let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; @@ -107,6 +113,8 @@ pub async fn run() { media_control::update_playback_status, local_scanner::get_song_buffer, local_scanner::scan_folder, + cache_manager::get_cached_song, + cache_manager::cache_song, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/jotais/storage.ts b/src/jotais/storage.ts index 12306f4..80cd1b3 100644 --- a/src/jotais/storage.ts +++ b/src/jotais/storage.ts @@ -19,7 +19,7 @@ export interface Song { export interface AbstractStorage { scan(): Promise; getMusicStream?(id: string | number): AsyncGenerator, void, unknown>; - getMusicBuffer?(id: string | number): Promise; + getMusicBuffer?(id: string | number): Promise; } export interface StorageMeta { diff --git a/src/storages/ncm.ts b/src/storages/ncm.ts index ed54412..289f0cb 100644 --- a/src/storages/ncm.ts +++ b/src/storages/ncm.ts @@ -5,9 +5,10 @@ import { AbstractStorage, Song, storagesJotai } from '../jotais/storage'; import { mergeDeep } from '../utils/merge-deep'; import type { SetStateAction, WritableAtom } from 'jotai'; import { backendStorage } from '../utils/local-utitity'; -import { fetchArraybuffer } from '../utils/chunk-transformer'; +import { fetchArraybuffer, fetchBuffer } from '../utils/chunk-transformer'; import { currentSongJotai } from '../jotais/play'; import { mergeLyrics } from '../utils/lyric-parser'; +import { cacheSong, getCachedSong } from '../utils/cache.'; interface NCMSearchResult { id: number; @@ -393,9 +394,9 @@ export class NCM implements AbstractStorage { private async getMusicURL (id: number, quality = this.config.defaultQuality) { const res = await fetch(`${this.config.api}song/url/v1?id=${id}&level=${quality}${this.config.cookie ? `&cookie=${this.config.cookie}` : ''}`); const { data } = await res.json(); - const { url } = data[0]; - if (!url) throw new Error(`Cannot get url for ${id}`); - return url as string; + const song = data[0]; + if (!song.url) throw new Error(`Cannot get url for ${id}:\n ${JSON.stringify(song)}`); + return song.url as string; } async * getMusicStream (id: number, quality = this.config.defaultQuality) { @@ -413,8 +414,11 @@ export class NCM implements AbstractStorage { } async getMusicBuffer (id: number, quality = this.config.defaultQuality) { + const cached = await getCachedSong(id); + if (cached) return cached; const url = await this.getMusicURL(id, quality); - const buffer = await fetchArraybuffer(url); + const buffer = await fetchBuffer(url); + cacheSong(id, buffer); return buffer; } diff --git a/src/utils/cache..ts b/src/utils/cache..ts new file mode 100644 index 0000000..ddb903b --- /dev/null +++ b/src/utils/cache..ts @@ -0,0 +1,9 @@ +import { invoke } from '@tauri-apps/api/core'; + +export async function getCachedSong (id: number) { + return await invoke('get_cached_song', {id}); +} + +export async function cacheSong (id: number, data: number[]) { + await invoke('cache_song', {id, data}); +} diff --git a/src/utils/chunk-transformer.ts b/src/utils/chunk-transformer.ts index 5345b4b..02e37c7 100644 --- a/src/utils/chunk-transformer.ts +++ b/src/utils/chunk-transformer.ts @@ -8,10 +8,10 @@ export const transformChunk = greenlet( } ); -export const fetchArraybuffer = greenlet( +export const fetchBuffer = greenlet( async (url: string) => { const res = await fetch(url); const buffer = await res.arrayBuffer(); - return buffer; + return Array.from(new Uint8Array(buffer)); } ); diff --git a/src/utils/player.ts b/src/utils/player.ts index 99ac397..9f2bea1 100644 --- a/src/utils/player.ts +++ b/src/utils/player.ts @@ -64,6 +64,7 @@ async function playCurrentSong () { if (currentSong.storage === 'local') { await invoke('play_local_file', { filePath: currentSong.path }); await invoke('set_volume', { volume: volumeToFactor(volume) }); + sharedStore.set(bufferingJotai, false); sharedStore.set(backendPlayingJotai, true); } else { const storages = sharedStore.get(storagesJotai); @@ -117,7 +118,7 @@ async function playCurrentSong () { console.warn('buffer interrupted'); return; } - await invoke('play_arraybuffer', { buffer: await transformChunk(buffer) }); + await invoke('play_arraybuffer', { buffer }); await invoke('set_volume', { volume: volumeToFactor(volume) }); sharedStore.set(backendPlayingJotai, true); sharedStore.set(bufferingJotai, false); @@ -170,13 +171,18 @@ function setupEventListeners () { } }); + let prevProgress: number; sharedStore.sub(progressJotai, () => { const progress = sharedStore.get(progressJotai); const currentSong = sharedStore.get(currentSongJotai); if (!currentSong || !currentSong.duration) return; - webviewWindow.WebviewWindow.getCurrent().setProgressBar({ - progress: Math.ceil(progress / currentSong.duration * 100000) - }); + const percentage = Math.ceil(progress / currentSong.duration * 100000); + if (prevProgress !== percentage) { + prevProgress = percentage; + webviewWindow.WebviewWindow.getCurrent().setProgressBar({ + progress: percentage + }); + } }); sharedStore.sub(playingJotai, async () => {