diff --git a/CHANGELOG.md b/CHANGELOG.md index d9be1b5e..54c35f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Added +### Song Info — absolute file path on Navidrome servers + +**By [@Psychotoxical](https://github.com/Psychotoxical), suggested by volcs0, PR [#504](https://github.com/Psychotoxical/psysonic/pull/504)** + +* The **Path** row in the Song Info dialog now shows the **absolute server-side filesystem path** of a track on Navidrome servers. Subsonic's `getSong` only ever returned a relative path (or nothing at all on Navidrome), which is why the row was effectively empty before. A new native-API call (`/api/song/{id}`) runs in parallel with `getSong`; on non-Navidrome servers the dialog falls back to whatever the Subsonic response carried. + + + ### Discord — album cover art from your own server **By [@Sayykii](https://github.com/Sayykii), PR [#462](https://github.com/Psychotoxical/psysonic/pull/462)** diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3c08e50e..1796563b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -946,6 +946,7 @@ pub fn run() { nd_update_playlist, nd_get_playlist, nd_delete_playlist, + nd_get_song_path, search_radio_browser, get_top_radio_stations, fetch_url_bytes, diff --git a/src-tauri/src/lib_commands/app_api/navidrome.rs b/src-tauri/src/lib_commands/app_api/navidrome.rs index 67568fdc..e39f8957 100644 --- a/src-tauri/src/lib_commands/app_api/navidrome.rs +++ b/src-tauri/src/lib_commands/app_api/navidrome.rs @@ -619,3 +619,37 @@ pub(crate) async fn nd_delete_playlist( } Ok(()) } + +/// GET `/api/song/{id}` and return the absolute filesystem `path` field. +/// +/// Subsonic `getSong.view` returns at most a relative path (`Artist/Album/track.flac`), +/// or nothing at all on Navidrome. The Navidrome native API exposes the absolute +/// path the server stores the file at — same source Feishin and the Navidrome web +/// client use for their "show file location" feature. Logs in fresh (no token +/// cache yet); the call is occasional (Song Info modal open) so the extra +/// roundtrip is acceptable. +/// +/// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit +/// it for non-admin users on some configurations. +#[tauri::command] +pub(crate) async fn nd_get_song_path( + server_url: String, + username: String, + password: String, + id: String, +) -> Result, String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let url = format!("{}/api/song/{}", server_url, id); + let resp = nd_retry(|| { + nd_http_client() + .get(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }) + .await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let data: serde_json::Value = resp.json().await.map_err(nd_err)?; + Ok(data["path"].as_str().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} diff --git a/src/api/navidromeAdmin.ts b/src/api/navidromeAdmin.ts index 91ddd2d8..610c4610 100644 --- a/src/api/navidromeAdmin.ts +++ b/src/api/navidromeAdmin.ts @@ -107,3 +107,27 @@ export async function ndUpdateUser( export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise { await invoke('nd_delete_user', { serverUrl, token, id }); } + +/** + * Fetch the absolute filesystem path of a song from Navidrome's native API. + * Subsonic `getSong.view` only returns a relative path (or none on Navidrome); + * the native `/api/song/:id` endpoint returns the absolute path the server + * stores the file at, the same way Feishin and the Navidrome web client get it. + * + * Returns `null` when the server doesn't expose the field (non-admin on some + * Navidrome configurations) or when the call fails — the Song Info modal then + * falls back to whatever the Subsonic response had. + */ +export async function ndGetSongPath( + serverUrl: string, + username: string, + password: string, + id: string, +): Promise { + try { + const raw = await invoke('nd_get_song_path', { serverUrl, username, password, id }); + return raw && raw.length > 0 ? raw : null; + } catch { + return null; + } +} diff --git a/src/components/SongInfoModal.tsx b/src/components/SongInfoModal.tsx index 7a73a681..8e227ddb 100644 --- a/src/components/SongInfoModal.tsx +++ b/src/components/SongInfoModal.tsx @@ -4,6 +4,8 @@ import { X } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { getSong, SubsonicSong } from '../api/subsonic'; +import { ndGetSongPath } from '../api/navidromeAdmin'; +import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { copyTextToClipboard } from '../utils/serverMagicString'; import { showToast } from '../utils/toast'; @@ -62,17 +64,39 @@ export default function SongInfoModal() { ); const [song, setSong] = useState(null); const [loading, setLoading] = useState(false); + // Absolute filesystem path resolved via Navidrome's native API in parallel + // with the Subsonic getSong call. Subsonic only ever returns a relative + // path (or none on Navidrome); the native endpoint is what Feishin and the + // Navidrome web client use to surface the full server-side location. + const [absolutePath, setAbsolutePath] = useState(null); useEffect(() => { if (!songInfoModal.isOpen || !songInfoModal.songId) { setSong(null); + setAbsolutePath(null); return; } let cancelled = false; setLoading(true); - getSong(songInfoModal.songId).then(s => { + setAbsolutePath(null); + const songId = songInfoModal.songId; + getSong(songId).then(s => { if (!cancelled) { setSong(s); setLoading(false); } }); + // Try the native API in parallel; only when the active server is Navidrome + // and we have credentials. Failures are silent — modal falls back to + // whatever the Subsonic `path` field carried (typically nothing). + const auth = useAuthStore.getState(); + const sid = auth.activeServerId; + const profile = sid ? auth.servers.find(p => p.id === sid) : null; + const identity = sid ? auth.subsonicServerIdentityByServer[sid] : undefined; + const isNavidrome = identity?.type?.trim().toLowerCase() === 'navidrome'; + if (isNavidrome && profile?.url && profile.username && profile.password) { + const serverUrl = (profile.url.startsWith('http') ? profile.url : `http://${profile.url}`).replace(/\/$/, ''); + ndGetSongPath(serverUrl, profile.username, profile.password, songId).then(p => { + if (!cancelled && p) setAbsolutePath(p); + }); + } return () => { cancelled = true; }; }, [songInfoModal.isOpen, songInfoModal.songId]); @@ -139,10 +163,10 @@ export default function SongInfoModal() { - {song.path && ( + {(absolutePath || song.path) && ( <> - {song.path}} /> + {absolutePath ?? song.path}} /> )} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7b97270f..c4e195b0 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -371,6 +371,7 @@ const CONTRIBUTORS = [ 'Help page: full rewrite with 45 focused entries across 10 themed sections (Getting Started / Playback & Queue / Audio Tools / Library & Discovery / Lyrics / Sharing & Social / Personalization / Power User / Offline & Sync / Integrations & Troubleshooting), in-page live search with case-insensitive substring matching and auto-expand on hits, translated to all 8 locales (PR #485)', 'Library: Browse by Composer — native-API role listing for classical libraries, library-scoped queries, composer as a first-class share entity (PR #487)', 'Home: "Because you listened" recommendation rail — Last.fm-anchored similar-artist surfacing with round-robin anchor rotation per server (PR #489)', + 'Song Info: absolute file path on Navidrome via native /api/song/{id} — Subsonic only ever returned a relative path (or none on Navidrome), the native endpoint surfaces the full server-side location (PR #504)', ], }, ] as const;