mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(song-info): show absolute file path on Navidrome via native API (#504)
* feat(song-info): show absolute file path on Navidrome via native API
Subsonic's `getSong.view` returns at most a relative path (or none on
Navidrome), so the Path row in the Song Info modal stayed empty for
most users. Feishin and the Navidrome web client surface the full
server-side path by hitting Navidrome's native `/api/song/{id}` instead.
Added an `nd_get_song_path` Tauri command that logs in to the native
API with the active server's credentials, fetches the song, and returns
the `path` string. Wired it into `SongInfoModal`: the Subsonic
`getSong` call still drives the rest of the dialog, and the native call
runs in parallel only when the active server's identity is
"navidrome". When it returns a path, that absolute path replaces the
relative Subsonic value; native-API failures are silent and the modal
falls back to whatever Subsonic provided.
No token cache yet — the modal is opened occasionally enough that one
fresh login per call is fine.
Closes discussion #479.
* docs: changelog entry for PR #504
Logs the Navidrome native-API absolute file path support for the
Song Info dialog in v1.46.0 "## Added".
* docs: settings contributor entry for PR #504, drop competitor mention
Adds the song-info absolute path bullet to Psychotoxical's contributors
list in Settings, and rewords the existing CHANGELOG entry so neither
text references competing clients.
This commit is contained in:
committed by
GitHub
parent
726f3f0ff2
commit
a41e3a624a
@@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Added
|
## 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
|
### Discord — album cover art from your own server
|
||||||
|
|
||||||
**By [@Sayykii](https://github.com/Sayykii), PR [#462](https://github.com/Psychotoxical/psysonic/pull/462)**
|
**By [@Sayykii](https://github.com/Sayykii), PR [#462](https://github.com/Psychotoxical/psysonic/pull/462)**
|
||||||
|
|||||||
@@ -946,6 +946,7 @@ pub fn run() {
|
|||||||
nd_update_playlist,
|
nd_update_playlist,
|
||||||
nd_get_playlist,
|
nd_get_playlist,
|
||||||
nd_delete_playlist,
|
nd_delete_playlist,
|
||||||
|
nd_get_song_path,
|
||||||
search_radio_browser,
|
search_radio_browser,
|
||||||
get_top_radio_stations,
|
get_top_radio_stations,
|
||||||
fetch_url_bytes,
|
fetch_url_bytes,
|
||||||
|
|||||||
@@ -619,3 +619,37 @@ pub(crate) async fn nd_delete_playlist(
|
|||||||
}
|
}
|
||||||
Ok(())
|
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<Option<String>, 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()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,3 +107,27 @@ export async function ndUpdateUser(
|
|||||||
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
|
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
|
||||||
await invoke('nd_delete_user', { serverUrl, token, id });
|
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<string | null> {
|
||||||
|
try {
|
||||||
|
const raw = await invoke<string | null>('nd_get_song_path', { serverUrl, username, password, id });
|
||||||
|
return raw && raw.length > 0 ? raw : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { X } from 'lucide-react';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { getSong, SubsonicSong } from '../api/subsonic';
|
import { getSong, SubsonicSong } from '../api/subsonic';
|
||||||
|
import { ndGetSongPath } from '../api/navidromeAdmin';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { copyTextToClipboard } from '../utils/serverMagicString';
|
import { copyTextToClipboard } from '../utils/serverMagicString';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
@@ -62,17 +64,39 @@ export default function SongInfoModal() {
|
|||||||
);
|
);
|
||||||
const [song, setSong] = useState<SubsonicSong | null>(null);
|
const [song, setSong] = useState<SubsonicSong | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!songInfoModal.isOpen || !songInfoModal.songId) {
|
if (!songInfoModal.isOpen || !songInfoModal.songId) {
|
||||||
setSong(null);
|
setSong(null);
|
||||||
|
setAbsolutePath(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getSong(songInfoModal.songId).then(s => {
|
setAbsolutePath(null);
|
||||||
|
const songId = songInfoModal.songId;
|
||||||
|
getSong(songId).then(s => {
|
||||||
if (!cancelled) { setSong(s); setLoading(false); }
|
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; };
|
return () => { cancelled = true; };
|
||||||
}, [songInfoModal.isOpen, songInfoModal.songId]);
|
}, [songInfoModal.isOpen, songInfoModal.songId]);
|
||||||
|
|
||||||
@@ -139,10 +163,10 @@ export default function SongInfoModal() {
|
|||||||
<Row label={t('songInfo.channels')} value={channels} />
|
<Row label={t('songInfo.channels')} value={channels} />
|
||||||
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
|
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
|
||||||
|
|
||||||
{song.path && (
|
{(absolutePath || song.path) && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Row label={t('songInfo.path')} value={<span className="song-info-path">{song.path}</span>} />
|
<Row label={t('songInfo.path')} value={<span className="song-info-path">{absolutePath ?? song.path}</span>} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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)',
|
'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)',
|
'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)',
|
'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;
|
] as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user