Skip to content

Commit f2fb65e

Browse files
committed
Account Export & Import, Onboarding Migration Option
1 parent 974275b commit f2fb65e

9 files changed

Lines changed: 819 additions & 63 deletions

File tree

src-tauri/src/account.rs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::collections::HashMap;
3+
use std::fs;
4+
5+
use crate::playlists::{self, Playlist};
6+
use crate::scan::scan_dir;
7+
8+
#[derive(Serialize, Deserialize, Clone)]
9+
pub struct ExportTrack {
10+
pub title: String,
11+
pub artist: String,
12+
pub album: String,
13+
pub duration: f64,
14+
}
15+
16+
#[derive(Serialize, Deserialize, Clone)]
17+
pub struct ExportPlaylist {
18+
pub name: String,
19+
pub tracks: Vec<ExportTrack>,
20+
}
21+
22+
#[derive(Serialize, Deserialize)]
23+
pub struct AccountExport {
24+
pub version: u32,
25+
pub tracks: Vec<ExportTrack>,
26+
pub playlists: Vec<ExportPlaylist>,
27+
pub favorites: Vec<ExportTrack>,
28+
}
29+
30+
#[derive(Serialize)]
31+
pub struct ImportResult {
32+
pub playlists_created: usize,
33+
pub favorites_restored: usize,
34+
pub tracks_already_present: usize,
35+
pub tracks_to_download: Vec<ExportTrack>,
36+
}
37+
38+
fn norm_key(title: &str, artist: &str) -> String {
39+
format!(
40+
"{}::{}",
41+
title.trim().to_lowercase(),
42+
artist.trim().to_lowercase()
43+
)
44+
}
45+
46+
#[tauri::command]
47+
pub async fn export_account(dir: String) -> Result<String, String> {
48+
let lib = scan_dir(dir);
49+
let path_to_meta: HashMap<String, ExportTrack> = lib
50+
.iter()
51+
.map(|t| {
52+
(
53+
t.path.clone(),
54+
ExportTrack {
55+
title: t.title.clone(),
56+
artist: t.artist.clone(),
57+
album: t.album.clone(),
58+
duration: t.duration,
59+
},
60+
)
61+
})
62+
.collect();
63+
64+
let tracks: Vec<ExportTrack> = lib
65+
.iter()
66+
.map(|t| ExportTrack {
67+
title: t.title.clone(),
68+
artist: t.artist.clone(),
69+
album: t.album.clone(),
70+
duration: t.duration,
71+
})
72+
.collect();
73+
74+
let pl_dir = playlists::pl_dir();
75+
let mut pls = Vec::new();
76+
let entries = fs::read_dir(&pl_dir).map_err(|e| format!("readdir: {e}"))?;
77+
for entry in entries.flatten() {
78+
let path = entry.path();
79+
if path.extension().map_or(false, |e| e == "json") {
80+
if let Ok(data) = fs::read_to_string(&path) {
81+
if let Ok(pl) = serde_json::from_str::<Playlist>(&data) {
82+
if pl.id != playlists::FAVS_ID {
83+
let pl_tracks: Vec<ExportTrack> = pl
84+
.tracks
85+
.iter()
86+
.filter_map(|p| path_to_meta.get(p).cloned())
87+
.collect();
88+
pls.push(ExportPlaylist {
89+
name: pl.name,
90+
tracks: pl_tracks,
91+
});
92+
}
93+
}
94+
}
95+
}
96+
}
97+
98+
playlists::ensure_favs();
99+
let favs_pl = playlists::read_pl(playlists::FAVS_ID)?;
100+
let favorites: Vec<ExportTrack> = favs_pl
101+
.tracks
102+
.iter()
103+
.filter_map(|p| path_to_meta.get(p).cloned())
104+
.collect();
105+
106+
let export = AccountExport {
107+
version: 1,
108+
tracks,
109+
playlists: pls,
110+
favorites,
111+
};
112+
113+
serde_json::to_string_pretty(&export).map_err(|e| format!("json: {e}"))
114+
}
115+
116+
#[tauri::command]
117+
pub async fn import_account(data: String, dir: String) -> Result<ImportResult, String> {
118+
let export: AccountExport =
119+
serde_json::from_str(&data).map_err(|e| format!("parse: {e}"))?;
120+
121+
let lib = scan_dir(dir.clone());
122+
let lib_keys: HashMap<String, String> = lib
123+
.iter()
124+
.map(|t| (norm_key(&t.title, &t.artist), t.path.clone()))
125+
.collect();
126+
127+
let mut present = 0usize;
128+
let mut missing: Vec<ExportTrack> = Vec::new();
129+
130+
for t in &export.tracks {
131+
let key = norm_key(&t.title, &t.artist);
132+
if lib_keys.contains_key(&key) {
133+
present += 1;
134+
} else {
135+
missing.push(t.clone());
136+
}
137+
}
138+
139+
let mut pl_count = 0usize;
140+
for ep in &export.playlists {
141+
let id = format!(
142+
"pl_{}",
143+
std::time::SystemTime::now()
144+
.duration_since(std::time::UNIX_EPOCH)
145+
.unwrap_or_default()
146+
.as_millis()
147+
);
148+
std::thread::sleep(std::time::Duration::from_millis(1));
149+
150+
let track_paths: Vec<String> = ep
151+
.tracks
152+
.iter()
153+
.filter_map(|t| lib_keys.get(&norm_key(&t.title, &t.artist)).cloned())
154+
.collect();
155+
156+
let pl = Playlist {
157+
id,
158+
name: ep.name.clone(),
159+
description: String::new(),
160+
cover: None,
161+
tracks: track_paths,
162+
};
163+
playlists::write_pl(&pl)?;
164+
pl_count += 1;
165+
}
166+
167+
playlists::ensure_favs();
168+
let mut favs = playlists::read_pl(playlists::FAVS_ID)?;
169+
let mut fav_count = 0usize;
170+
for ft in &export.favorites {
171+
let key = norm_key(&ft.title, &ft.artist);
172+
if let Some(path) = lib_keys.get(&key) {
173+
if !favs.tracks.contains(path) {
174+
favs.tracks.push(path.clone());
175+
fav_count += 1;
176+
}
177+
}
178+
}
179+
playlists::write_pl(&favs)?;
180+
181+
Ok(ImportResult {
182+
playlists_created: pl_count,
183+
favorites_restored: fav_count,
184+
tracks_already_present: present,
185+
tracks_to_download: missing,
186+
})
187+
}
188+
189+
#[tauri::command]
190+
pub async fn import_fill_playlists(data: String, dir: String) -> Result<(), String> {
191+
let export: AccountExport =
192+
serde_json::from_str(&data).map_err(|e| format!("parse: {e}"))?;
193+
194+
let lib = scan_dir(dir);
195+
let lib_keys: HashMap<String, String> = lib
196+
.iter()
197+
.map(|t| (norm_key(&t.title, &t.artist), t.path.clone()))
198+
.collect();
199+
200+
let pl_dir = playlists::pl_dir();
201+
let entries = fs::read_dir(&pl_dir).map_err(|e| format!("readdir: {e}"))?;
202+
for entry in entries.flatten() {
203+
let path = entry.path();
204+
if path.extension().map_or(false, |e| e == "json") {
205+
if let Ok(file_data) = fs::read_to_string(&path) {
206+
if let Ok(mut pl) = serde_json::from_str::<Playlist>(&file_data) {
207+
if pl.id == playlists::FAVS_ID {
208+
continue;
209+
}
210+
if let Some(ep) = export.playlists.iter().find(|e| e.name == pl.name) {
211+
for et in &ep.tracks {
212+
let key = norm_key(&et.title, &et.artist);
213+
if let Some(track_path) = lib_keys.get(&key) {
214+
if !pl.tracks.contains(track_path) {
215+
pl.tracks.push(track_path.clone());
216+
}
217+
}
218+
}
219+
playlists::write_pl(&pl)?;
220+
}
221+
}
222+
}
223+
}
224+
}
225+
226+
playlists::ensure_favs();
227+
let mut favs = playlists::read_pl(playlists::FAVS_ID)?;
228+
for ft in &export.favorites {
229+
let key = norm_key(&ft.title, &ft.artist);
230+
if let Some(path) = lib_keys.get(&key) {
231+
if !favs.tracks.contains(path) {
232+
favs.tracks.push(path.clone());
233+
}
234+
}
235+
}
236+
playlists::write_pl(&favs)?;
237+
238+
Ok(())
239+
}

src-tauri/src/discover.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,11 @@ async fn dz_lookup(artist: &str, title: &str) -> Option<(String, String)> {
457457
None
458458
}
459459

460+
#[tauri::command]
461+
pub async fn lookup_cover(artist: String, title: String) -> Option<String> {
462+
dz_lookup(&artist, &title).await.map(|(cover, _)| cover)
463+
}
464+
460465
#[derive(Serialize, Clone)]
461466
pub struct YtImportProgress {
462467
pub phase: String,

src-tauri/src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@ mod spotify;
88
mod playlists;
99
mod track_ops;
1010
mod audio_device;
11+
mod account;
1112

1213
use scan::{scan_dir, get_cover, stream_file, fetch_lyrics, fetch_ttml};
13-
use discover::{search_tracks, search_albums, search_artists, get_album_tracks, get_artist_albums, download_track, yt_playlist_tracks};
14+
use discover::{search_tracks, search_albums, search_artists, get_album_tracks, get_artist_albums, download_track, yt_playlist_tracks, lookup_cover};
1415
use rpc::{rpc_on, rpc_off, rpc_set, rpc_clr, rpc_cover};
1516
use tray::tray_set;
1617
use ytdlp_setup::{check_ytdlp, install_ytdlp, check_ytdlp_update, check_ffmpeg, install_ffmpeg};
1718
use spotify::{sp_save_client_id, sp_load_client_id, sp_authorize, sp_load_tokens, sp_disconnect, sp_fresh_token, sp_playlists, sp_playlist_tracks, sp_liked_tracks};
1819
use playlists::{pl_list, pl_create, pl_rename, pl_delete, pl_get, pl_add_track, pl_remove_track, pl_cover, pl_set_cover, pl_favs, pl_fav_toggle};
1920
use track_ops::delete_track;
2021
use audio_device::get_audio_device;
22+
use account::{export_account, import_account, import_fill_playlists};
2123

2224
#[cfg_attr(mobile, tauri::mobile_entry_point)]
2325
pub fn run() {
@@ -27,7 +29,7 @@ pub fn run() {
2729
.plugin(tauri_plugin_shell::init())
2830
.manage(rpc::init())
2931
.manage(tray::init())
30-
.invoke_handler(tauri::generate_handler![scan_dir, get_cover, stream_file, fetch_lyrics, fetch_ttml, search_tracks, search_albums, search_artists, get_album_tracks, get_artist_albums, download_track, yt_playlist_tracks, rpc_on, rpc_off, rpc_set, rpc_clr, rpc_cover, tray_set, check_ytdlp, install_ytdlp, check_ytdlp_update, check_ffmpeg, install_ffmpeg, sp_save_client_id, sp_load_client_id, sp_authorize, sp_load_tokens, sp_disconnect, sp_fresh_token, sp_playlists, sp_playlist_tracks, sp_liked_tracks, pl_list, pl_create, pl_rename, pl_delete, pl_get, pl_add_track, pl_remove_track, pl_cover, pl_set_cover, pl_favs, pl_fav_toggle, delete_track, get_audio_device])
32+
.invoke_handler(tauri::generate_handler![scan_dir, get_cover, stream_file, fetch_lyrics, fetch_ttml, search_tracks, search_albums, search_artists, get_album_tracks, get_artist_albums, download_track, yt_playlist_tracks, rpc_on, rpc_off, rpc_set, rpc_clr, rpc_cover, tray_set, check_ytdlp, install_ytdlp, check_ytdlp_update, check_ffmpeg, install_ffmpeg, sp_save_client_id, sp_load_client_id, sp_authorize, sp_load_tokens, sp_disconnect, sp_fresh_token, sp_playlists, sp_playlist_tracks, sp_liked_tracks, pl_list, pl_create, pl_rename, pl_delete, pl_get, pl_add_track, pl_remove_track, pl_cover, pl_set_cover, pl_favs, pl_fav_toggle, delete_track, get_audio_device, export_account, import_account, import_fill_playlists, lookup_cover])
3133
.setup(|app| {
3234
#[cfg(target_os = "linux")]
3335
{

src-tauri/src/playlists.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pub struct Playlist {
1414
pub tracks: Vec<String>,
1515
}
1616

17-
fn pl_dir() -> PathBuf {
17+
pub(crate) fn pl_dir() -> PathBuf {
1818
let d = dirs::data_dir()
1919
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share"))
2020
.join("gs-music")
@@ -23,25 +23,25 @@ fn pl_dir() -> PathBuf {
2323
d
2424
}
2525

26-
fn pl_path(id: &str) -> PathBuf {
26+
pub(crate) fn pl_path(id: &str) -> PathBuf {
2727
pl_dir().join(format!("{id}.json"))
2828
}
2929

30-
fn read_pl(id: &str) -> Result<Playlist, String> {
30+
pub(crate) fn read_pl(id: &str) -> Result<Playlist, String> {
3131
let p = pl_path(id);
3232
let data = fs::read_to_string(&p).map_err(|e| format!("read: {e}"))?;
3333
serde_json::from_str(&data).map_err(|e| format!("parse: {e}"))
3434
}
3535

36-
fn write_pl(pl: &Playlist) -> Result<(), String> {
36+
pub(crate) fn write_pl(pl: &Playlist) -> Result<(), String> {
3737
let p = pl_path(&pl.id);
3838
let data = serde_json::to_string_pretty(pl).map_err(|e| format!("json: {e}"))?;
3939
fs::write(&p, data).map_err(|e| format!("write: {e}"))
4040
}
4141

42-
const FAVS_ID: &str = "__favorites";
42+
pub(crate) const FAVS_ID: &str = "__favorites";
4343

44-
fn ensure_favs() {
44+
pub(crate) fn ensure_favs() {
4545
let p = pl_path(FAVS_ID);
4646
if !p.exists() {
4747
let pl = Playlist {

src-tauri/tauri.conf.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
}
3131
},
3232
"bundle": {
33+
"targets": [],
3334
"icon": [
3435
"icons/32x32.png",
3536
"icons/64x64.png",

src/pages/discover/onboarding/step_migrate/index.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { YtTracksView } from "../views/yt_tracks";
1212
import { MigratingView } from "../views/migrating";
1313
import { DoneView } from "../views/done";
1414
import { HomeView } from "../views/home";
15+
import { use_account_export, ImportOverlay } from "@/pages/settings/account_export";
1516

1617
type View =
1718
| "home"
@@ -50,6 +51,7 @@ export function StepMigrate({ on_next }: Props) {
5051
const [mig_done, set_mig_done] = useState(0);
5152
const [mig_current, set_mig_current] = useState("");
5253
const [did_migrate, set_did_migrate] = useState(false);
54+
const { import_account: gs_import, import_state } = use_account_export();
5355

5456
const dl_id_ref = useRef(0);
5557
const batch_ids = useRef<Set<number>>(new Set());
@@ -453,11 +455,21 @@ export function StepMigrate({ on_next }: Props) {
453455
did_migrate={did_migrate}
454456
on_spotify={open_spotify}
455457
on_youtube={() => set_view("yt_input")}
458+
on_gs_import={async () => {
459+
await gs_import(true);
460+
set_did_migrate(true);
461+
set_view("done");
462+
}}
456463
on_next={on_next}
457464
/>
458465
);
459466
}
460467
}
461468

462-
return <div style={box}>{render_view()}</div>;
469+
return (
470+
<div style={box}>
471+
{render_view()}
472+
{import_state && <ImportOverlay state={import_state} />}
473+
</div>
474+
);
463475
}

0 commit comments

Comments
 (0)