fix(share): resolve Song.link/Album.link via Deezer ISRC/UPC (#514)
The 2.0 port kept only the Odesli-by-URL path, which rejects Qobuz URLs
(400 could_not_resolve_entity), so album.link/Song.link never resolved
while the raw Qobuz link still copied fine. Restore the Tauri behaviour:
look the track up on Deezer by ISRC (album by UPC, with a leading-zero
retry) and build song.link/d/{id} / album.link/d/{id} from the match.
cc
This commit is contained in:
10 files changed
+248
-11
No files matched your search
@@ -4568,3 +4568,16 @@ msgstr "Bündel gespeichert. Auf dem Daemon-Rechner:"
|
||||
msgid "Could not export settings"
|
||||
msgstr "Einstellungen konnten nicht exportiert werden"
|
||||
|
||||
|
||||
# Share menu Song.link/Album.link toasts (#514)
|
||||
msgid "Fetching Song.link..."
|
||||
msgstr "Song.link wird abgerufen..."
|
||||
|
||||
msgid "Fetching Album.link..."
|
||||
msgstr "Album.link wird abgerufen..."
|
||||
|
||||
msgid "Link copied"
|
||||
msgstr "Link kopiert"
|
||||
|
||||
msgid "Failed to copy link"
|
||||
msgstr "Link konnte nicht kopiert werden"
|
||||
@@ -343,3 +343,16 @@ msgid_plural "Showing {} of {} tracks."
|
||||
msgstr[0] "Showing {} of {} track."
|
||||
msgstr[1] "Showing {} of {} tracks."
|
||||
|
||||
|
||||
# Share menu Song.link/Album.link toasts (#514)
|
||||
msgid "Fetching Song.link..."
|
||||
msgstr "Fetching Song.link..."
|
||||
|
||||
msgid "Fetching Album.link..."
|
||||
msgstr "Fetching Album.link..."
|
||||
|
||||
msgid "Link copied"
|
||||
msgstr "Link copied"
|
||||
|
||||
msgid "Failed to copy link"
|
||||
msgstr "Failed to copy link"
|
||||
@@ -4598,3 +4598,16 @@ msgstr "Paquete guardado. En la máquina del daemon:"
|
||||
msgid "Could not export settings"
|
||||
msgstr "No se pudieron exportar los ajustes"
|
||||
|
||||
|
||||
# Share menu Song.link/Album.link toasts (#514)
|
||||
msgid "Fetching Song.link..."
|
||||
msgstr "Obteniendo Song.link..."
|
||||
|
||||
msgid "Fetching Album.link..."
|
||||
msgstr "Obteniendo Album.link..."
|
||||
|
||||
msgid "Link copied"
|
||||
msgstr "Enlace copiado"
|
||||
|
||||
msgid "Failed to copy link"
|
||||
msgstr "No se pudo copiar el enlace"
|
||||
@@ -4568,3 +4568,16 @@ msgstr "Ensemble enregistré. Sur la machine du démon :"
|
||||
msgid "Could not export settings"
|
||||
msgstr "Impossible d'exporter les réglages"
|
||||
|
||||
|
||||
# Share menu Song.link/Album.link toasts (#514)
|
||||
msgid "Fetching Song.link..."
|
||||
msgstr "Récupération du Song.link..."
|
||||
|
||||
msgid "Fetching Album.link..."
|
||||
msgstr "Récupération de l'Album.link..."
|
||||
|
||||
msgid "Link copied"
|
||||
msgstr "Lien copié"
|
||||
|
||||
msgid "Failed to copy link"
|
||||
msgstr "Échec de la copie du lien"
|
||||
@@ -4556,3 +4556,16 @@ msgstr "バンドルを保存しました。デーモンのマシンで:"
|
||||
msgid "Could not export settings"
|
||||
msgstr "設定をエクスポートできませんでした"
|
||||
|
||||
|
||||
# Share menu Song.link/Album.link toasts (#514)
|
||||
msgid "Fetching Song.link..."
|
||||
msgstr "Song.linkを取得中..."
|
||||
|
||||
msgid "Fetching Album.link..."
|
||||
msgstr "Album.linkを取得中..."
|
||||
|
||||
msgid "Link copied"
|
||||
msgstr "リンクをコピーしました"
|
||||
|
||||
msgid "Failed to copy link"
|
||||
msgstr "リンクをコピーできませんでした"
|
||||
@@ -4580,3 +4580,16 @@ msgstr "Bundel opgeslagen. Op de daemon-machine:"
|
||||
msgid "Could not export settings"
|
||||
msgstr "Kan instellingen niet exporteren"
|
||||
|
||||
|
||||
# Share menu Song.link/Album.link toasts (#514)
|
||||
msgid "Fetching Song.link..."
|
||||
msgstr "Song.link ophalen..."
|
||||
|
||||
msgid "Fetching Album.link..."
|
||||
msgstr "Album.link ophalen..."
|
||||
|
||||
msgid "Link copied"
|
||||
msgstr "Link gekopieerd"
|
||||
|
||||
msgid "Failed to copy link"
|
||||
msgstr "Kopiëren van link mislukt"
|
||||
@@ -4568,3 +4568,16 @@ msgstr "Pacote guardado. Na máquina do daemon:"
|
||||
msgid "Could not export settings"
|
||||
msgstr "Não foi possível exportar as definições"
|
||||
|
||||
|
||||
# Share menu Song.link/Album.link toasts (#514)
|
||||
msgid "Fetching Song.link..."
|
||||
msgstr "Obtendo Song.link..."
|
||||
|
||||
msgid "Fetching Album.link..."
|
||||
msgstr "Obtendo Album.link..."
|
||||
|
||||
msgid "Link copied"
|
||||
msgstr "Link copiado"
|
||||
|
||||
msgid "Failed to copy link"
|
||||
msgstr "Falha ao copiar o link"
|
||||
@@ -4582,3 +4582,16 @@ msgstr "Набор сохранён. На машине демона:"
|
||||
msgid "Could not export settings"
|
||||
msgstr "Не удалось экспортировать настройки"
|
||||
|
||||
|
||||
# Share menu Song.link/Album.link toasts (#514)
|
||||
msgid "Fetching Song.link..."
|
||||
msgstr "Получение Song.link..."
|
||||
|
||||
msgid "Fetching Album.link..."
|
||||
msgstr "Получение Album.link..."
|
||||
|
||||
msgid "Link copied"
|
||||
msgstr "Ссылка скопирована"
|
||||
|
||||
msgid "Failed to copy link"
|
||||
msgstr "Не удалось скопировать ссылку"
|
||||
+46
-7
@@ -10717,16 +10717,33 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::info!("[qbz-slint] copied Qobuz link for album {id}");
|
||||
}
|
||||
("album", "share-songlink") => {
|
||||
let source = share::qobuz_album_url(&id);
|
||||
// Tauri-parity resolution (#514): fetch the album to get
|
||||
// its UPC, then UPC -> Deezer -> album.link. The old
|
||||
// URL-only Odesli call never worked for Qobuz input
|
||||
// (could_not_resolve_entity) — see share.rs.
|
||||
let album = id.clone();
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
crate::toast::info_weak(&weak, qbz_i18n::t("Fetching Album.link..."));
|
||||
handle.spawn(async move {
|
||||
match share::songlink_url(&source).await {
|
||||
let upc = runtime
|
||||
.core()
|
||||
.get_album(&album)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|a| a.upc);
|
||||
match share::albumlink_for_album(&album, upc.as_deref()).await {
|
||||
Some(url) => {
|
||||
share::copy_to_clipboard(url);
|
||||
log::info!("[qbz-slint] copied Album.link for album {album}");
|
||||
crate::toast::success_weak(&weak, qbz_i18n::t("Link copied"));
|
||||
}
|
||||
None => {
|
||||
log::warn!("[qbz-slint] Album.link resolution failed for {album}")
|
||||
log::warn!("[qbz-slint] Album.link resolution failed for {album}");
|
||||
crate::toast::error_weak(
|
||||
&weak,
|
||||
qbz_i18n::t("Failed to copy link"),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -11003,15 +11020,37 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::info!("[qbz-slint] copied Qobuz link for track {id}");
|
||||
}
|
||||
("track", "share-songlink") => {
|
||||
let source = share::qobuz_track_url(&id);
|
||||
// Tauri-parity resolution (#514): fetch the track to get
|
||||
// its ISRC, then ISRC -> Deezer -> song.link. The old
|
||||
// URL-only Odesli call never worked for Qobuz input
|
||||
// (could_not_resolve_entity) — see share.rs.
|
||||
let track = id.clone();
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
crate::toast::info_weak(&weak, qbz_i18n::t("Fetching Song.link..."));
|
||||
handle.spawn(async move {
|
||||
match share::songlink_url(&source).await {
|
||||
let isrc = match track.parse::<u64>() {
|
||||
Ok(tid) => runtime
|
||||
.core()
|
||||
.get_track(tid)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|t| t.isrc),
|
||||
Err(_) => None,
|
||||
};
|
||||
match share::songlink_for_track(&track, isrc.as_deref()).await {
|
||||
Some(url) => {
|
||||
share::copy_to_clipboard(url);
|
||||
log::info!("[qbz-slint] copied Song.link for track {track}");
|
||||
crate::toast::success_weak(&weak, qbz_i18n::t("Link copied"));
|
||||
}
|
||||
None => {
|
||||
log::warn!("[qbz-slint] Song.link resolution failed for {track}");
|
||||
crate::toast::error_weak(
|
||||
&weak,
|
||||
qbz_i18n::t("Failed to copy link"),
|
||||
);
|
||||
}
|
||||
None => log::warn!("[qbz-slint] Song.link resolution failed for {track}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11160,7 +11199,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !artist_id.is_empty() {
|
||||
share::copy_to_clipboard(share::qobuz_artist_url(&artist_id));
|
||||
if let Some(w) = weak.upgrade() {
|
||||
crate::toast::success(&w, "Link copied");
|
||||
crate::toast::success(&w, qbz_i18n::t("Link copied"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+98
-4
@@ -63,17 +63,111 @@ pub fn copy_to_clipboard(text: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared HTTP client settings for the share resolvers (Tauri parity:
|
||||
/// 10 s request / 5 s connect timeouts).
|
||||
fn share_http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve an ISRC/UPC code to a Deezer catalog id. `path` is
|
||||
/// `track/isrc:{code}` or `album/upc:{code}`. Deezer answers misses with
|
||||
/// HTTP 200 + an `{"error": ...}` body, so both shapes are checked.
|
||||
async fn deezer_lookup(path: &str) -> Option<u64> {
|
||||
let url = format!("https://api.deezer.com/2.0/{path}");
|
||||
let resp = match share_http_client().get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] deezer lookup {path}: request failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
log::warn!("[qbz-slint] deezer lookup {path}: HTTP {}", resp.status());
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] deezer lookup {path}: bad JSON: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Some(err) = body.get("error") {
|
||||
log::info!("[qbz-slint] deezer lookup {path}: no match ({err})");
|
||||
return None;
|
||||
}
|
||||
body.get("id").and_then(|v| v.as_u64())
|
||||
}
|
||||
|
||||
/// Song.link page URL for a track — ISRC-first via Deezer (#514).
|
||||
///
|
||||
/// The Odesli API does not know Qobuz as an input platform: every
|
||||
/// `qobuz.com` URL form answers 400 `could_not_resolve_entity` (verified
|
||||
/// 2026-07-16), so the URL-only path the 2.0 port shipped never worked.
|
||||
/// Tauri's working path resolved the track's ISRC to a Deezer id (one GET)
|
||||
/// and built `https://song.link/d/{id}` directly — restored here. The
|
||||
/// Odesli URL fallback is kept as a last resort for the no-ISRC case.
|
||||
pub async fn songlink_for_track(track_id: &str, isrc: Option<&str>) -> Option<String> {
|
||||
if let Some(code) = isrc.map(str::trim).filter(|c| !c.is_empty()) {
|
||||
if let Some(deezer_id) = deezer_lookup(&format!("track/isrc:{code}")).await {
|
||||
log::info!("[qbz-slint] song.link via ISRC {code} -> deezer track {deezer_id}");
|
||||
return Some(format!("https://song.link/d/{deezer_id}"));
|
||||
}
|
||||
} else {
|
||||
log::info!("[qbz-slint] track {track_id} has no ISRC; trying Odesli URL fallback");
|
||||
}
|
||||
songlink_url(&qobuz_track_url(track_id)).await
|
||||
}
|
||||
|
||||
/// Album.link page URL for an album — UPC-first via Deezer (#514).
|
||||
///
|
||||
/// Same story as [`songlink_for_track`]: Odesli cannot resolve Qobuz URLs,
|
||||
/// so the UPC -> Deezer -> `https://album.link/d/{id}` path (Tauri's
|
||||
/// `get_by_upc`) is the one that works. Qobuz UPCs often carry a leading
|
||||
/// zero (13-digit EAN) while Deezer stores the 12-digit form and does NOT
|
||||
/// match zero-padded input (verified), so a trimmed retry is attempted.
|
||||
pub async fn albumlink_for_album(album_id: &str, upc: Option<&str>) -> Option<String> {
|
||||
if let Some(code) = upc.map(str::trim).filter(|c| !c.is_empty()) {
|
||||
if let Some(deezer_id) = deezer_lookup(&format!("album/upc:{code}")).await {
|
||||
log::info!("[qbz-slint] album.link via UPC {code} -> deezer album {deezer_id}");
|
||||
return Some(format!("https://album.link/d/{deezer_id}"));
|
||||
}
|
||||
let trimmed = code.trim_start_matches('0');
|
||||
if trimmed != code && !trimmed.is_empty() {
|
||||
if let Some(deezer_id) = deezer_lookup(&format!("album/upc:{trimmed}")).await {
|
||||
log::info!(
|
||||
"[qbz-slint] album.link via UPC {trimmed} (leading zeros trimmed) -> deezer album {deezer_id}"
|
||||
);
|
||||
return Some(format!("https://album.link/d/{deezer_id}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!("[qbz-slint] album {album_id} has no UPC; trying Odesli URL fallback");
|
||||
}
|
||||
songlink_url(&qobuz_album_url(album_id)).await
|
||||
}
|
||||
|
||||
/// Resolve a source URL to its universal Song.link (Odesli) page URL.
|
||||
/// One GET to the Odesli API; returns the `pageUrl` field.
|
||||
/// One GET to the Odesli API; returns the `pageUrl` field. NOTE: Odesli
|
||||
/// cannot resolve Qobuz URLs (400 `could_not_resolve_entity`) — for Qobuz
|
||||
/// content use the ISRC/UPC resolvers above; this remains as their last
|
||||
/// resort and for any non-Qobuz source URL.
|
||||
pub async fn songlink_url(source_url: &str) -> Option<String> {
|
||||
let resp = reqwest::Client::new()
|
||||
let resp = share_http_client()
|
||||
.get("https://api.song.link/v1-alpha.1/links")
|
||||
.query(&[("url", source_url)])
|
||||
.query(&[("url", source_url), ("userCountry", "US")])
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
log::warn!("[qbz-slint] song.link status {}", resp.status());
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let snippet: String = body.chars().take(200).collect();
|
||||
log::warn!("[qbz-slint] song.link status {status} for {source_url}: {snippet}");
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = resp.json().await.ok()?;
|
||||
|
||||
Reference in new issue
Block a user