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:
vicrodh committed 2026-07-16 20:37:51 -06:00
1 parent 33f91adcca
commit 134eddc1ae
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
View File
@@ -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
View File
@@ -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()?;