Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 76 additions & 7 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -730,13 +730,8 @@ fn read_image_file(path: String) -> Result<LocalArtwork, CommandError> {
return Err(cache_error("that image is too large to embed"));
}

let mime_type = match data.as_slice() {
[0xff, 0xd8, 0xff, ..] => "image/jpeg",
[0x89, b'P', b'N', b'G', ..] => "image/png",
[b'G', b'I', b'F', b'8', ..] => "image/gif",
[b'B', b'M', ..] => "image/bmp",
[b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => "image/webp",
_ => return Err(cache_error("that file is not a PNG, JPEG, GIF, BMP or WebP image")),
let Some(mime_type) = sniff_image_mime(&data) else {
return Err(cache_error("that file is not a PNG, JPEG, GIF, BMP or WebP image"));
};

Ok(LocalArtwork {
Expand All @@ -745,6 +740,17 @@ fn read_image_file(path: String) -> Result<LocalArtwork, CommandError> {
})
}

fn sniff_image_mime(data: &[u8]) -> Option<&'static str> {
match data {
[0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),
[0x89, b'P', b'N', b'G', ..] => Some("image/png"),
[b'G', b'I', b'F', b'8', ..] => Some("image/gif"),
[b'B', b'M', ..] => Some("image/bmp"),
[b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => Some("image/webp"),
_ => None,
}
}

/// The first embedded picture, for the one file the UI is about to show.
#[tauri::command]
fn local_audio_artwork(path: String) -> Result<Option<LocalArtwork>, CommandError> {
Expand Down Expand Up @@ -3183,6 +3189,7 @@ fn offline_entry_path(app: &tauri::AppHandle, track_id: &str) -> Result<PathBuf,
struct OfflineEntryInfo {
track_id: String,
byte_length: u64,
has_artwork: bool,
}

#[derive(Serialize)]
Expand Down Expand Up @@ -3675,13 +3682,71 @@ fn offline_audio_has(app: tauri::AppHandle, track_id: String) -> Result<bool, Co
#[tauri::command]
fn offline_audio_remove(app: tauri::AppHandle, track_id: String) -> Result<(), CommandError> {
let path = offline_entry_path(&app, &track_id)?;
let _ = fs::remove_file(path.with_extension("art"));
if path.exists() {
fs::remove_file(&path)
.map_err(|error| cache_error(format!("offline delete failed: {error}")))?;
}
Ok(())
}

/// The saved cover behind an `offline-artwork:<trackId>` URL, for the OS media controls.
pub(crate) fn offline_artwork_file(app: &tauri::AppHandle, url: &str) -> Option<PathBuf> {
let track_id = url.strip_prefix("offline-artwork:")?;
let path = offline_entry_path(app, track_id).ok()?.with_extension("art");
path.is_file().then_some(path)
}

/// Saves a downloaded track's cover next to its audio, so it still shows with no network.
#[tauri::command]
async fn offline_artwork_save(
app: tauri::AppHandle,
track_id: String,
url: String,
) -> Result<(), CommandError> {
let path = offline_entry_path(&app, &track_id)?.with_extension("art");
if !url.starts_with("https://") {
return Err(cache_error("artwork URL must be https"));
}
let client = http_client_builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| cache_error(format!("artwork client creation failed: {error}")))?;
let bytes = client
.get(&url)
.send()
.await
.and_then(|response| response.error_for_status())
.map_err(|error| cache_error(format!("artwork request failed: {error}")))?
.bytes()
.await
.map_err(|error| cache_error(format!("artwork read failed: {error}")))?;
if bytes.len() > MAX_ARTWORK_BYTES || sniff_image_mime(&bytes).is_none() {
return Err(cache_error("artwork response is not a usable image"));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| cache_error(format!("offline directory creation failed: {error}")))?;
}
fs::write(&path, &bytes).map_err(|error| cache_error(format!("artwork save failed: {error}")))
}

#[tauri::command]
fn offline_artwork(app: tauri::AppHandle, track_id: String) -> Result<Option<LocalArtwork>, CommandError> {
use base64::Engine;

let path = offline_entry_path(&app, &track_id)?.with_extension("art");
let data = match fs::read(&path) {
Ok(data) => data,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(cache_error(format!("artwork read failed: {error}"))),
};
Ok(sniff_image_mime(&data).map(|mime_type| LocalArtwork {
mime_type: mime_type.to_string(),
data_base64: base64::engine::general_purpose::STANDARD.encode(&data),
}))
}

/// The source of truth for what is downloaded. The frontend keeps its own manifest for track
/// metadata, but this is what reconciles it when the two disagree.
#[tauri::command]
Expand All @@ -3705,6 +3770,7 @@ fn offline_audio_list(app: tauri::AppHandle) -> Result<Vec<OfflineEntryInfo>, Co
entries.push(OfflineEntryInfo {
track_id: track_id.to_string(),
byte_length: entry.metadata().map(|meta| meta.len()).unwrap_or(0),
has_artwork: path.with_extension("art").exists(),
});
}
Ok(entries)
Expand Down Expand Up @@ -3756,6 +3822,7 @@ fn offline_audio_prune(app: tauri::AppHandle, max_bytes: u64) -> Result<OfflineS
break;
}
if fs::remove_file(path).is_ok() {
let _ = fs::remove_file(path.with_extension("art"));
used_bytes = used_bytes.saturating_sub(*size);
entry_count = entry_count.saturating_sub(1);
}
Expand Down Expand Up @@ -5430,6 +5497,8 @@ pub fn run() {
offline_audio_has,
offline_audio_remove,
offline_audio_list,
offline_artwork_save,
offline_artwork,
offline_audio_stats,
offline_audio_prune,
fetch_youtube_music_audio,
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/linux_media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,10 @@ pub fn update_linux_media_session(
state: State<'_, LinuxMediaSession>,
update: MediaSessionUpdate,
) -> Result<(), String> {
// MPRIS takes `file://` covers, which is how a downloaded song keeps its artwork offline.
let mut update = update;
if let Some(path) = update.artwork_url.as_deref().and_then(|url| crate::offline_artwork_file(&app, url)) {
update.artwork_url = Some(format!("file://{}", path.display()));
}
state.update(&app, update)
}
11 changes: 8 additions & 3 deletions src-tauri/src/macos_media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ impl MacosMediaSession {
fn spawn_artwork_fetch(app: &AppHandle, url: String) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
let bytes = load_artwork_bytes(&url).await.unwrap_or_default();
let bytes = load_artwork_bytes(&app, &url).await.unwrap_or_default();
let Some(session) = app.try_state::<MacosMediaSession>() else {
return;
};
Expand All @@ -140,9 +140,14 @@ fn spawn_artwork_fetch(app: &AppHandle, url: String) {
});
}

async fn load_artwork_bytes(url: &str) -> Option<Vec<u8>> {
async fn load_artwork_bytes(app: &AppHandle, url: &str) -> Option<Vec<u8>> {
use base64::Engine;

// A downloaded song's saved cover, so Now Playing keeps its artwork offline.
if let Some(path) = crate::offline_artwork_file(app, url) {
return std::fs::read(path).ok();
}

// Embedded cover of a local file. The tag read blocks, but once per track, not per update.
if let Some(path) = url.strip_prefix("local-art:") {
let artwork = crate::local_audio_artwork(path.to_string()).ok()??;
Expand All @@ -151,7 +156,7 @@ async fn load_artwork_bytes(url: &str) -> Option<Vec<u8>> {
.ok();
}

let response = reqwest::get(url).await.ok()?;
let response = crate::http_client_builder().build().ok()?.get(url).send().await.ok()?;
if !response.status().is_success() {
return None;
}
Expand Down
27 changes: 18 additions & 9 deletions src-tauri/src/windows_media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use windows::{
MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControls,
SystemMediaTransportControlsTimelineProperties,
},
Storage::Streams::RandomAccessStreamReference,
Storage::{StorageFile, Streams::RandomAccessStreamReference},
Win32::{
Foundation::{HWND, LPARAM, LRESULT, RPC_E_CHANGED_MODE, WPARAM},
System::Com::{
Expand Down Expand Up @@ -248,14 +248,10 @@ impl NativeMediaSession {
})
.map_err(|error| error.to_string())?;

if let Some(artwork_url) = metadata.artwork_url.as_ref() {
if let Ok(uri) = Uri::CreateUri(&HSTRING::from(artwork_url.as_str())) {
if let Ok(thumbnail) = RandomAccessStreamReference::CreateFromUri(&uri) {
updater
.SetThumbnail(&thumbnail)
.map_err(|error| error.to_string())?;
}
}
if let Some(thumbnail) = metadata.artwork_url.as_deref().and_then(artwork_stream) {
updater
.SetThumbnail(&thumbnail)
.map_err(|error| error.to_string())?;
}
}

Expand Down Expand Up @@ -315,6 +311,15 @@ impl MediaMetadata {
}
}

/// A downloaded song's saved cover is a `file://` path; anything else is a URL the shell fetches.
fn artwork_stream(url: &str) -> Option<RandomAccessStreamReference> {
if let Some(path) = url.strip_prefix("file://") {
let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path)).ok()?.get().ok()?;
return RandomAccessStreamReference::CreateFromFile(&file).ok();
}
RandomAccessStreamReference::CreateFromUri(&Uri::CreateUri(&HSTRING::from(url)).ok()?).ok()
}

fn duration_to_timespan(seconds: f64) -> windows::Foundation::TimeSpan {
windows::Foundation::TimeSpan {
Duration: (seconds * 10_000_000.0).round() as i64,
Expand Down Expand Up @@ -603,5 +608,9 @@ pub fn update_windows_media_session(
state: tauri::State<'_, WindowsMediaSession>,
update: MediaSessionUpdate,
) -> Result<(), String> {
let mut update = update;
if let Some(path) = update.artwork_url.as_deref().and_then(|url| crate::offline_artwork_file(&app, url)) {
update.artwork_url = Some(format!("file://{}", path.display()));
}
state.with_session(&app, |session| session.update(update))
}
40 changes: 35 additions & 5 deletions src/datasource/youtube/YouTubeMusicDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,12 @@ export class YouTubeMusicDataSource extends DataSource {
private getMusicClient(): Promise<Innertube> {
if (!this.musicClientPromise) {
logInternalInfo("YouTubeMusicDataSource.getMusicClient creating client");
this.musicClientPromise = this.createMusicClient(true);
const promise = this.createMusicClient(true);
// A failure (offline at launch) must not be cached, or every call fails until restart.
promise.catch(() => {
if (this.musicClientPromise === promise) this.musicClientPromise = null;
});
this.musicClientPromise = promise;
}

return this.musicClientPromise;
Expand All @@ -547,10 +552,14 @@ export class YouTubeMusicDataSource extends DataSource {
logInternalInfo("YouTubeMusicDataSource.getWebClient creating client");
// No player needed: this client only enumerates accounts and resolves like endpoints,
// neither of which touches stream URLs, and retrieving it downloads the player script.
this.webClientPromise = Innertube.create({
const promise = Innertube.create({
...this.getSessionOptions(false),
client_type: ClientType.WEB,
});
promise.catch(() => {
if (this.webClientPromise === promise) this.webClientPromise = null;
});
this.webClientPromise = promise;
}

return this.webClientPromise;
Expand Down Expand Up @@ -579,7 +588,7 @@ export class YouTubeMusicDataSource extends DataSource {
private getDownloadClient(): Promise<Innertube> {
if (!this.downloadClientPromise) {
logInternalInfo("YouTubeMusicDataSource.getDownloadClient creating client");
this.downloadClientPromise = (async () => {
const promise = (async () => {
const bootstrap = await Innertube.create({
fetch: tauriFetch,
retrieve_player: false,
Expand All @@ -596,6 +605,15 @@ export class YouTubeMusicDataSource extends DataSource {
client_type: ClientType.MUSIC,
});
})();
/*
* Never cache a failure. Built offline at launch, it used to stay rejected for the whole
* session, so every stream fell back to the music client, whose URLs googlevideo refuses
* after the first chunk — songs cut out after a few seconds and were skipped.
*/
promise.catch(() => {
if (this.downloadClientPromise === promise) this.downloadClientPromise = null;
});
this.downloadClientPromise = promise;
}

return this.downloadClientPromise;
Expand Down Expand Up @@ -2979,7 +2997,16 @@ export class YouTubeMusicDataSource extends DataSource {
* fields are already the values it assigns.
*/
this.resetMusicClients();
await this.getMusicClient();
/*
* Offline, the client cannot be built, but the stored credential is still the session:
* failing here used to fall through to "signed out" and hide the cached library and
* downloads. The client is built on first use once the network is back.
*/
await this.getMusicClient().catch((error) => {
logInternalWarn("YouTubeMusicDataSource.restoreSession client deferred", {
error: error instanceof Error ? error.message : String(error),
});
});
logInternalInfo("YouTubeMusicDataSource.restoreSession success");
return true;
} catch (error) {
Expand Down Expand Up @@ -5726,7 +5753,10 @@ export class YouTubeMusicDataSource extends DataSource {
logInternalWarn("YouTubeMusicDataSource.getStreamData client failed", {
trackId: track.id,
client: label,
error: error instanceof Error ? error.message : String(error),
// tauriFetch rejects with a plain `{ message }`, which String() turns into "[object Object]".
error: error instanceof Error
? error.message
: (error as { message?: string } | null)?.message ?? String(error),
});
}
}
Expand Down
24 changes: 23 additions & 1 deletion src/datasource/youtube/artwork.check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
*/
export {};

import { getArtworkSizeBucket, getArtworkUrlCandidates, selectArtworkUrl } from "./artwork";
import {
artworkIdentity,
getArtworkSizeBucket,
getArtworkUrlCandidates,
selectArtworkUrl,
} from "./artwork";

function check(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
Expand Down Expand Up @@ -95,4 +100,21 @@ check(
"a 2x slot needing more than 544 still keeps the original",
);

// A saved download cover has to match the same song at every size it is shown at.
check(
artworkIdentity("https://lh3.googleusercontent.com/abc=w120-h120-l90-rj")
=== artworkIdentity("//lh3.googleusercontent.com/abc=w544-h544-l90-rj"),
"size variants of one cover share an identity",
);
check(
artworkIdentity("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg")
=== artworkIdentity("https://i9.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp"),
"every thumbnail of one video shares an identity",
);
check(
artworkIdentity("https://lh3.googleusercontent.com/abc=w120")
!== artworkIdentity("https://lh3.googleusercontent.com/xyz=w120"),
"different covers stay different",
);

console.log("artwork.check.ts OK");
14 changes: 14 additions & 0 deletions src/datasource/youtube/artwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ export function getArtworkUrlCandidates(url?: string, size?: number | null): str
});
}

/**
* One key per image whatever size variant a URL asks for, so a download's saved cover matches
* the same song wherever it shows — a queue row's `=w120` and a header's `=w544` alike.
*/
export function artworkIdentity(url: string): string {
const normalized = normalizeArtworkUrl(url);
const videoThumbnail = normalized.match(/^https:\/\/i\d?\.ytimg\.com\/vi(?:_webp)?\/([^/]+)\//);
if (videoThumbnail) return `ytimg:${videoThumbnail[1]}`;
if (/googleusercontent\.com|ggpht\.com/.test(normalized) && !/[?&]/.test(normalized)) {
return normalized.replace(/=[^=/]+$/, "");
}
return normalized;
}

export function getVideoArtworkFallback(videoId: string): string | undefined {
return /^[A-Za-z0-9_-]{11}$/.test(videoId)
? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
Expand Down
Loading
Loading