From 7ec5d28caf3bf610a6bb1d2de6b173b6fec31287 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 9 Feb 2026 18:15:43 +0700 Subject: [PATCH] feat: add YouTube provider for lossy downloads via Cobalt API - New YouTube download provider with Opus 256kbps and MP3 320kbps options - SongLink/Odesli integration for Spotify/Deezer ID to YouTube URL conversion - YouTube video ID detection for YT Music extension compatibility - Parallel cover art and lyrics fetching during download - Queue progress shows bytes (X.X MB) for streaming downloads - Full metadata embedding: cover, lyrics, title, artist, album, track#, disc#, year, ISRC - Removed Tidal HIGH (lossy AAC) option - use YouTube for lossy instead - Bumped version to 3.6.0 --- CHANGELOG.md | 25 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 9 + go_backend/exports.go | 75 +++ go_backend/songlink.go | 154 +++++- go_backend/youtube.go | 510 ++++++++++++++++++ lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_de.dart | 4 + lib/l10n/app_localizations_en.dart | 4 + lib/l10n/app_localizations_es.dart | 4 + lib/l10n/app_localizations_fr.dart | 4 + lib/l10n/app_localizations_hi.dart | 4 + lib/l10n/app_localizations_id.dart | 4 + lib/l10n/app_localizations_ja.dart | 4 + lib/l10n/app_localizations_ko.dart | 4 + lib/l10n/app_localizations_nl.dart | 4 + lib/l10n/app_localizations_pt.dart | 4 + lib/l10n/app_localizations_ru.dart | 4 + lib/l10n/app_localizations_tr.dart | 4 + lib/l10n/app_localizations_zh.dart | 4 + lib/l10n/arb/app_en.arb | 2 + lib/models/download_item.dart | 4 + lib/models/download_item.g.dart | 2 + lib/providers/download_queue_provider.dart | 143 ++++- lib/screens/queue_tab.dart | 14 +- .../settings/download_settings_page.dart | 12 +- lib/screens/setup_screen.dart | 17 + lib/services/platform_bridge.dart | 73 ++- lib/utils/file_access.dart | 129 +++++ lib/widgets/download_service_picker.dart | 162 +----- pubspec.yaml | 2 +- 30 files changed, 1225 insertions(+), 166 deletions(-) create mode 100644 go_backend/youtube.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 982bc103..5b1d8e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [3.6.0] - 2026-02-09 + +### Highlights + +- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services + - Opus 256kbps (recommended) or MP3 320kbps quality options + - Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC + - Lyrics fetching from lrclib.net with embed and external .lrc support + - Works as fallback when Tidal/Qobuz/Amazon downloads fail + +### Added + +- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion +- YouTube video ID detection for YT Music extension compatibility +- Parallel cover art and lyrics fetching during YouTube download +- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode) +- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC + +### Changed + +- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead +- Simplified download service picker by removing dead lossy format code + +--- + ## [3.5.3] - 2026-02-09 ### Added diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 21c0f962..88f54edf 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1927,6 +1927,15 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "downloadFromYouTube" -> { + val requestJson = call.arguments as String + val response = withContext(Dispatchers.IO) { + handleSafDownload(requestJson) { json -> + Gobackend.downloadFromYouTube(json) + } + } + result.success(response) + } "enrichTrackWithExtension" -> { val extensionId = call.argument("extension_id") ?: "" val trackJson = call.argument("track") ?: "{}" diff --git a/go_backend/exports.go b/go_backend/exports.go index f466f722..9c9102e6 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -267,6 +267,24 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = amazonErr + case "youtube": + youtubeResult, youtubeErr := downloadFromYouTube(req) + if youtubeErr == nil { + result = DownloadResult{ + FilePath: youtubeResult.FilePath, + BitDepth: 0, // Lossy format, no bit depth + SampleRate: 0, // Lossy format + Title: youtubeResult.Title, + Artist: youtubeResult.Artist, + Album: youtubeResult.Album, + ReleaseDate: youtubeResult.ReleaseDate, + TrackNumber: youtubeResult.TrackNumber, + DiscNumber: youtubeResult.DiscNumber, + ISRC: youtubeResult.ISRC, + LyricsLRC: youtubeResult.LyricsLRC, + } + } + err = youtubeErr default: return errorResponse("Unknown service: " + req.Service) } @@ -1074,6 +1092,63 @@ func errorResponse(msg string) (string, error) { return string(jsonBytes), nil } +// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ==================== + +// DownloadFromYouTube downloads a track from YouTube via Cobalt API +// This is a lossy-only provider (Opus 256kbps or MP3 320kbps) +// It does NOT participate in the lossless fallback chain +func DownloadFromYouTube(requestJSON string) (string, error) { + var req DownloadRequest + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return errorResponse("Invalid request: " + err.Error()) + } + + req.TrackName = strings.TrimSpace(req.TrackName) + req.ArtistName = strings.TrimSpace(req.ArtistName) + req.AlbumName = strings.TrimSpace(req.AlbumName) + req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) + req.OutputDir = strings.TrimSpace(req.OutputDir) + req.OutputPath = strings.TrimSpace(req.OutputPath) + req.OutputExt = strings.TrimSpace(req.OutputExt) + + if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" { + AddAllowedDownloadDir(req.OutputDir) + } + + youtubeResult, err := downloadFromYouTube(req) + if err != nil { + return errorResponse(err.Error()) + } + + resp := DownloadResponse{ + Success: true, + Message: "Downloaded from YouTube", + FilePath: youtubeResult.FilePath, + Service: "youtube", + Title: youtubeResult.Title, + Artist: youtubeResult.Artist, + Album: youtubeResult.Album, + ReleaseDate: youtubeResult.ReleaseDate, + TrackNumber: youtubeResult.TrackNumber, + DiscNumber: youtubeResult.DiscNumber, + ISRC: youtubeResult.ISRC, + LyricsLRC: youtubeResult.LyricsLRC, + } + + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter) +func IsYouTubeURLExport(urlStr string) bool { + return IsYouTubeURL(urlStr) +} + +// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter) +func ExtractYouTubeVideoIDExport(urlStr string) (string, error) { + return ExtractYouTubeVideoID(urlStr) +} + // ==================== EXTENSION SYSTEM ==================== func InitExtensionSystem(extensionsDir, dataDir string) error { diff --git a/go_backend/songlink.go b/go_backend/songlink.go index ff7f1bd2..1af317b2 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -15,18 +15,21 @@ type SongLinkClient struct { } type TrackAvailability struct { - SpotifyID string `json:"spotify_id"` - Tidal bool `json:"tidal"` - Amazon bool `json:"amazon"` - Qobuz bool `json:"qobuz"` - Deezer bool `json:"deezer"` - TidalURL string `json:"tidal_url,omitempty"` - AmazonURL string `json:"amazon_url,omitempty"` - QobuzURL string `json:"qobuz_url,omitempty"` - DeezerURL string `json:"deezer_url,omitempty"` - DeezerID string `json:"deezer_id,omitempty"` - QobuzID string `json:"qobuz_id,omitempty"` - TidalID string `json:"tidal_id,omitempty"` + SpotifyID string `json:"spotify_id"` + Tidal bool `json:"tidal"` + Amazon bool `json:"amazon"` + Qobuz bool `json:"qobuz"` + Deezer bool `json:"deezer"` + YouTube bool `json:"youtube"` + TidalURL string `json:"tidal_url,omitempty"` + AmazonURL string `json:"amazon_url,omitempty"` + QobuzURL string `json:"qobuz_url,omitempty"` + DeezerURL string `json:"deezer_url,omitempty"` + YouTubeURL string `json:"youtube_url,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + TidalID string `json:"tidal_id,omitempty"` + YouTubeID string `json:"youtube_id,omitempty"` } var ( @@ -119,6 +122,21 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) } + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + } + + // Also check youtubeMusic as fallback + if !availability.YouTube { + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + } + } + return availability, nil } @@ -246,6 +264,52 @@ func extractTidalIDFromURL(tidalURL string) string { return "" } +// extractYouTubeIDFromURL extracts YouTube video ID from URL +// URL formats: +// - https://www.youtube.com/watch?v=VIDEO_ID +// - https://youtu.be/VIDEO_ID +// - https://music.youtube.com/watch?v=VIDEO_ID +func extractYouTubeIDFromURL(youtubeURL string) string { + if youtubeURL == "" { + return "" + } + + // Handle youtu.be short URLs + if strings.Contains(youtubeURL, "youtu.be/") { + parts := strings.Split(youtubeURL, "youtu.be/") + if len(parts) >= 2 { + idPart := parts[1] + if idx := strings.Index(idPart, "?"); idx > 0 { + idPart = idPart[:idx] + } + if idx := strings.Index(idPart, "&"); idx > 0 { + idPart = idPart[:idx] + } + return strings.TrimSpace(idPart) + } + } + + // Handle youtube.com URLs with ?v= parameter + parsed, err := url.Parse(youtubeURL) + if err != nil { + return "" + } + + if v := parsed.Query().Get("v"); v != "" { + return v + } + + // Handle /embed/ format + if strings.Contains(parsed.Path, "/embed/") { + parts := strings.Split(parsed.Path, "/embed/") + if len(parts) >= 2 { + return strings.Split(parts[1], "/")[0] + } + } + + return "" +} + // isNumeric is defined in library_scan.go func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { @@ -261,6 +325,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, return availability.DeezerID, nil } +// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink +func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) { + availability, err := s.CheckTrackAvailability(spotifyTrackID, "") + if err != nil { + return "", err + } + + if !availability.YouTube || availability.YouTubeURL == "" { + return "", fmt.Errorf("track not found on YouTube") + } + + return availability.YouTubeURL, nil +} + // AlbumAvailability represents album availability on different platforms type AlbumAvailability struct { SpotifyID string `json:"spotify_id"` @@ -441,6 +519,19 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin availability.DeezerURL = deezerLink.URL } + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + } + if !availability.YouTube { + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + } + } + return availability, nil } @@ -528,6 +619,19 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + } + if !availability.YouTube { + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + } + } + return availability, nil } @@ -584,6 +688,20 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e return availability.AmazonURL, nil } +// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink +func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) { + availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) + if err != nil { + return "", err + } + + if !availability.YouTube || availability.YouTubeURL == "" { + return "", fmt.Errorf("track not found on YouTube") + } + + return availability.YouTubeURL, nil +} + func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() @@ -652,6 +770,18 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila availability.DeezerURL = deezerLink.URL availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + } + if !availability.YouTube { + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + } + } return availability, nil } diff --git a/go_backend/youtube.go b/go_backend/youtube.go new file mode 100644 index 00000000..46b50934 --- /dev/null +++ b/go_backend/youtube.go @@ -0,0 +1,510 @@ +// Package gobackend provides YouTube download functionality via Cobalt API +// YouTube is a lossy-only provider (not part of lossless fallback chain) +package gobackend + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type YouTubeDownloader struct { + client *http.Client + apiURL string + mu sync.Mutex +} + +var ( + globalYouTubeDownloader *YouTubeDownloader + youtubeDownloaderOnce sync.Once +) + +type YouTubeQuality string + +const ( + YouTubeQualityOpus256 YouTubeQuality = "opus_256" + YouTubeQualityMP3320 YouTubeQuality = "mp3_320" +) + +type CobaltRequest struct { + URL string `json:"url"` + AudioBitrate string `json:"audioBitrate,omitempty"` + AudioFormat string `json:"audioFormat,omitempty"` + DownloadMode string `json:"downloadMode,omitempty"` + FilenameStyle string `json:"filenameStyle,omitempty"` + DisableMetadata bool `json:"disableMetadata,omitempty"` +} + +type CobaltResponse struct { + Status string `json:"status"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + Error *struct { + Code string `json:"code"` + Context *struct { + Service string `json:"service,omitempty"` + Limit int `json:"limit,omitempty"` + } `json:"context,omitempty"` + } `json:"error,omitempty"` +} + +type YouTubeDownloadResult struct { + FilePath string + Title string + Artist string + Album string + ReleaseDate string + TrackNumber int + DiscNumber int + ISRC string + Format string // "opus" or "mp3" + Bitrate int + LyricsLRC string + CoverData []byte +} + +// NewYouTubeDownloader creates or returns the singleton YouTube downloader + youtubeDownloaderOnce.Do(func() { + globalYouTubeDownloader = &YouTubeDownloader{ + client: NewHTTPClientWithTimeout(120 * time.Second), + apiURL: "https://api.qwkuns.me", // Cobalt-based API + } + }) + return globalYouTubeDownloader +} + +// SearchYouTube searches for a track on YouTube and returns the best matching video URL +func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) { + // Build search query + query := fmt.Sprintf("%s %s", artistName, trackName) + + // Use YouTube's search to find the video + // We'll use a simple approach: construct a YouTube Music search URL pattern + // The actual video ID will be resolved by Cobalt + searchQuery := url.QueryEscape(query) + + GoLog("[YouTube] Search query: %s\n", query) + + // For now, we'll need to use YouTube Music's /watch endpoint with search + // A better approach is to use YouTube Data API, but Cobalt can handle music.youtube.com + youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery) + + return youtubeMusicURL, nil +} + +// GetDownloadURL gets the direct download URL from Cobalt API +func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) { + y.mu.Lock() + defer y.mu.Unlock() + + var audioFormat string + var audioBitrate string + + switch quality { + case YouTubeQualityOpus256: + audioFormat = "opus" + audioBitrate = "256" + case YouTubeQualityMP3320: + audioFormat = "mp3" + audioBitrate = "320" + default: + audioFormat = "mp3" + audioBitrate = "320" + } + + reqBody := CobaltRequest{ + URL: youtubeURL, + AudioFormat: audioFormat, + AudioBitrate: audioBitrate, + DownloadMode: "audio", + FilenameStyle: "basic", + DisableMetadata: false, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n", + youtubeURL, audioFormat, audioBitrate) + + req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := DoRequestWithUserAgent(y.client, req) + if err != nil { + return nil, fmt.Errorf("cobalt API request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode) + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body)) + } + + var cobaltResp CobaltResponse + if err := json.Unmarshal(body, &cobaltResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if cobaltResp.Status == "error" && cobaltResp.Error != nil { + return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code) + } + + if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" { + return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status) + } + + if cobaltResp.URL == "" { + return nil, fmt.Errorf("no download URL in response") + } + + GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status) + + return &cobaltResp, nil +} + +// DownloadFile downloads the audio file from the given URL +func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { + ctx := context.Background() + + if itemID != "" { + StartItemProgress(itemID) + defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + } + + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := DoRequestWithUserAgent(y.client, req) + if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + } + + expectedSize := resp.ContentLength + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) + } + + out, err := openOutputForWrite(outputPath, outputFD) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + + bufWriter := bufio.NewWriterSize(out, 256*1024) + + var written int64 + if itemID != "" { + progressWriter := NewItemProgressWriter(bufWriter, itemID) + written, err = io.Copy(progressWriter, resp.Body) + } else { + written, err = io.Copy(bufWriter, resp.Body) + } + + flushErr := bufWriter.Flush() + closeErr := out.Close() + + if err != nil { + cleanupOutputOnError(outputPath, outputFD) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download interrupted: %w", err) + } + if flushErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to flush buffer: %w", flushErr) + } + if closeErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to close file: %w", closeErr) + } + + if expectedSize > 0 && written != expectedSize { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + GoLog("[YouTube] Download completed: %d bytes written\n", written) + + return nil +} + +// BuildYouTubeSearchURL constructs a YouTube Music search URL for a track +func BuildYouTubeSearchURL(trackName, artistName string) string { + query := fmt.Sprintf("%s %s official audio", artistName, trackName) + return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query)) +} + +// BuildYouTubeWatchURL constructs a YouTube watch URL from a video ID +func BuildYouTubeWatchURL(videoID string) string { + return fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) +} + +// isYouTubeVideoID checks if a string looks like a YouTube video ID +// YouTube video IDs are exactly 11 characters, containing alphanumeric, - and _ +func isYouTubeVideoID(s string) bool { + if len(s) != 11 { + return false + } + for _, c := range s { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { + return false + } + } + return true +} + +// IsYouTubeURL checks if the given URL is a YouTube URL +func IsYouTubeURL(urlStr string) bool { + lower := strings.ToLower(urlStr) + return strings.Contains(lower, "youtube.com") || + strings.Contains(lower, "youtu.be") || + strings.Contains(lower, "music.youtube.com") +} + +// ExtractYouTubeVideoID extracts the video ID from a YouTube URL +func ExtractYouTubeVideoID(urlStr string) (string, error) { + // Handle youtu.be short URLs + if strings.Contains(urlStr, "youtu.be/") { + parts := strings.Split(urlStr, "youtu.be/") + if len(parts) >= 2 { + videoID := strings.Split(parts[1], "?")[0] + videoID = strings.Split(videoID, "&")[0] + return strings.TrimSpace(videoID), nil + } + } + + // Handle youtube.com URLs + parsed, err := url.Parse(urlStr) + if err != nil { + return "", fmt.Errorf("invalid URL: %w", err) + } + + // Check for /watch?v= format + if v := parsed.Query().Get("v"); v != "" { + return v, nil + } + + // Check for /embed/ format + if strings.Contains(parsed.Path, "/embed/") { + parts := strings.Split(parsed.Path, "/embed/") + if len(parts) >= 2 { + return strings.Split(parts[1], "/")[0], nil + } + } + + // Check for /v/ format + if strings.Contains(parsed.Path, "/v/") { + parts := strings.Split(parsed.Path, "/v/") + if len(parts) >= 2 { + return strings.Split(parts[1], "/")[0], nil + } + } + + return "", fmt.Errorf("could not extract video ID from URL") +} + +// downloadFromYouTube handles the complete download flow from YouTube +func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { + downloader := NewYouTubeDownloader() + + // Determine quality from request + var quality YouTubeQuality + switch strings.ToLower(req.Quality) { + case "opus_256", "opus256", "opus": + quality = YouTubeQualityOpus256 + case "mp3_320", "mp3320", "mp3": + quality = YouTubeQualityMP3320 + default: + quality = YouTubeQualityMP3320 // Default to MP3 320kbps + } + + // Try to get YouTube URL + // Priority: Direct YouTube Video ID -> Spotify ID -> Deezer ID -> ISRC + var youtubeURL string + var lookupErr error + + // Method 0: Check if SpotifyID is actually a YouTube video ID (from YT Music extension) + // YouTube video IDs are 11 characters, alphanumeric with _ and - + if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) { + youtubeURL = BuildYouTubeWatchURL(req.SpotifyID) + GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL) + } + + // Method 1: Try Spotify ID via SongLink (if it looks like a real Spotify ID) + if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) { + GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) + songlink := NewSongLinkClient() + youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID) + if lookupErr != nil { + GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr) + } else { + GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL) + } + } + + // Method 2: Try Deezer ID if Spotify lookup failed or Spotify ID not available + if youtubeURL == "" && req.DeezerID != "" { + GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) + songlink := NewSongLinkClient() + youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID) + if lookupErr != nil { + GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr) + } else { + GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL) + } + } + + // Method 3: Try ISRC if both Spotify and Deezer failed + if youtubeURL == "" && req.ISRC != "" { + GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) + songlink := NewSongLinkClient() + // First get Spotify ID from ISRC, then get YouTube URL + availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC) + if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" { + youtubeURL = availability.YouTubeURL + GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL) + } else if isrcErr != nil { + GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr) + } + } + + // Fallback: if we couldn't get URL from SongLink, return error + // (Cobalt doesn't support search URLs, only direct video URLs) + if youtubeURL == "" { + return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName) + } + + GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL) + + // Get download URL from Cobalt + cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality) + if err != nil { + return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) + } + + // Determine file extension based on quality + var ext string + var format string + var bitrate int + switch quality { + case YouTubeQualityOpus256: + ext = ".opus" + format = "opus" + bitrate = 256 + case YouTubeQualityMP3320: + ext = ".mp3" + format = "mp3" + bitrate = 320 + } + + // Build filename + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "track": req.TrackNumber, + "year": extractYear(req.ReleaseDate), + "disc": req.DiscNumber, + }) + filename = sanitizeFilename(filename) + ext + + // Determine output path + var outputPath string + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" + if isSafOutput { + outputPath = strings.TrimSpace(req.OutputPath) + if outputPath == "" && isFDOutput(req.OutputFD) { + outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) + } + } else { + outputPath = req.OutputDir + "/" + filename + } + + GoLog("[YouTube] Downloading to: %s\n", outputPath) + + // Start parallel fetch for cover art and lyrics while downloading + var parallelResult *ParallelDownloadResult + if req.EmbedLyrics || req.CoverURL != "" { + GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n") + parallelResult = FetchCoverAndLyricsParallel( + req.CoverURL, + req.EmbedMaxQualityCover, + req.SpotifyID, + req.TrackName, + req.ArtistName, + req.EmbedLyrics, + int64(req.DurationMS), + ) + } + + // Download the file + if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil { + return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err) + } + + // Extract lyrics LRC if available + lyricsLRC := "" + var coverData []byte + if parallelResult != nil { + if parallelResult.LyricsLRC != "" { + lyricsLRC = parallelResult.LyricsLRC + GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines)) + } + if parallelResult.CoverData != nil { + coverData = parallelResult.CoverData + GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData)) + } + } + + return YouTubeDownloadResult{ + FilePath: outputPath, + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + ReleaseDate: req.ReleaseDate, + TrackNumber: req.TrackNumber, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + Format: format, + Bitrate: bitrate, + LyricsLRC: lyricsLRC, + CoverData: coverData, + }, nil +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 314d70dd..2918949f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3502,6 +3502,12 @@ abstract class AppLocalizations { /// **'Actual quality depends on track availability from the service'** String get qualityNote; + /// Note for YouTube service explaining lossy-only quality + /// + /// In en, this message translates to: + /// **'YouTube provides lossy audio only. Not part of lossless fallback.'** + String get youtubeQualityNote; + /// Setting - show quality picker /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 3ba4767d..a7930a2e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1933,6 +1933,10 @@ class AppLocalizationsDe extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 09f1a73a..41bddfee 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1918,6 +1918,10 @@ class AppLocalizationsEn extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index f50225e8..0e6ea103 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1918,6 +1918,10 @@ class AppLocalizationsEs extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 4a072dd7..66ef7d1e 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1918,6 +1918,10 @@ class AppLocalizationsFr extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 6dbd5e13..c51b2f92 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1918,6 +1918,10 @@ class AppLocalizationsHi extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 3d622818..22525c08 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1930,6 +1930,10 @@ class AppLocalizationsId extends AppLocalizations { String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index d4ed6e5e..d5270e9e 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1906,6 +1906,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 46e5cbf2..167e028c 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1918,6 +1918,10 @@ class AppLocalizationsKo extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 47502e33..5842cd24 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1918,6 +1918,10 @@ class AppLocalizationsNl extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 02bbd0dd..a1b3b668 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1918,6 +1918,10 @@ class AppLocalizationsPt extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 58d935a1..f795098c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1956,6 +1956,10 @@ class AppLocalizationsRu extends AppLocalizations { String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index b6d0b136..0ad6a988 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1933,6 +1933,10 @@ class AppLocalizationsTr extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a2ae85f8..e36052f6 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1918,6 +1918,10 @@ class AppLocalizationsZh extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 45939a0f..39c59f67 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1414,6 +1414,8 @@ "@lossyFormatOpusSubtitle": {"description": "Opus format description"}, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": {"description": "Note about quality availability"}, + "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", + "@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"}, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": {"description": "Setting - show quality picker"}, diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index fc17705c..4d8a650e 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -28,6 +28,7 @@ class DownloadItem { final DownloadStatus status; final double progress; final double speedMBps; + final int bytesReceived; // Bytes downloaded so far (for unknown size downloads) final String? filePath; final String? error; final DownloadErrorType? errorType; @@ -41,6 +42,7 @@ class DownloadItem { this.status = DownloadStatus.queued, this.progress = 0.0, this.speedMBps = 0.0, + this.bytesReceived = 0, this.filePath, this.error, this.errorType, @@ -55,6 +57,7 @@ class DownloadItem { DownloadStatus? status, double? progress, double? speedMBps, + int? bytesReceived, String? filePath, String? error, DownloadErrorType? errorType, @@ -68,6 +71,7 @@ class DownloadItem { status: status ?? this.status, progress: progress ?? this.progress, speedMBps: speedMBps ?? this.speedMBps, + bytesReceived: bytesReceived ?? this.bytesReceived, filePath: filePath ?? this.filePath, error: error ?? this.error, errorType: errorType ?? this.errorType, diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 815ad996..098290ce 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( DownloadStatus.queued, progress: (json['progress'] as num?)?.toDouble() ?? 0.0, speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0, + bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0, filePath: json['filePath'] as String?, error: json['error'] as String?, errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), @@ -30,6 +31,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => 'status': _$DownloadStatusEnumMap[instance.status]!, 'progress': instance.progress, 'speedMBps': instance.speedMBps, + 'bytesReceived': instance.bytesReceived, 'filePath': instance.filePath, 'error': instance.error, 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a597c842..27d863e9 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -600,11 +600,13 @@ class _ProgressUpdate { final DownloadStatus status; final double progress; final double? speedMBps; + final int? bytesReceived; const _ProgressUpdate({ required this.status, required this.progress, this.speedMBps, + this.bytesReceived, }); } @@ -801,6 +803,7 @@ class DownloadQueueNotifier extends Notifier { status: DownloadStatus.downloading, progress: percentage, speedMBps: speedMBps, + bytesReceived: bytesReceived, ); final mbReceived = bytesReceived / (1024 * 1024); @@ -835,10 +838,12 @@ class DownloadQueueNotifier extends Notifier { status: update.status, progress: update.progress, speedMBps: update.speedMBps ?? current.speedMBps, + bytesReceived: update.bytesReceived ?? current.bytesReceived, ); if (current.status != next.status || current.progress != next.progress || - current.speedMBps != next.speedMBps) { + current.speedMBps != next.speedMBps || + current.bytesReceived != next.bytesReceived) { if (!changed) { updatedItems = List.from(updatedItems); changed = true; @@ -1203,6 +1208,13 @@ class DownloadQueueNotifier extends Notifier { } String _determineOutputExt(String quality, String service) { + // YouTube provider - lossy only (Opus or MP3) + if (service.toLowerCase() == 'youtube') { + if (quality.toLowerCase().contains('mp3')) { + return '.mp3'; + } + return '.opus'; + } if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { return '.m4a'; } @@ -2259,6 +2271,15 @@ class DownloadQueueNotifier extends Notifier { await musicDir.create(recursive: true); } state = state.copyWith(outputDir: musicDir.path); + } else if (!isValidIosWritablePath(state.outputDir)) { + // Check for other invalid paths (like container root without Documents/) + _log.w( + 'iOS: Invalid output path detected (container root?), falling back to app Documents folder', + ); + _log.w('Original path: ${state.outputDir}'); + final correctedPath = await validateOrFixIosPath(state.outputDir); + _log.i('Corrected path: $correctedPath'); + state = state.copyWith(outputDir: correctedPath); } } @@ -2637,6 +2658,36 @@ class DownloadQueueNotifier extends Notifier { final fileName = useSaf ? (safFileName ?? '') : ''; final outputExt = useSaf ? safOutputExt : ''; + // YouTube provider - lossy only, bypasses fallback chain + if (item.service == 'youtube') { + _log.d('Using YouTube/Cobalt provider for download'); + _log.d('Quality: $quality (lossy only)'); + _log.d('Output dir: $outputDir'); + return PlatformBridge.downloadFromYouTube( + trackName: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: trackToDownload.albumName, + albumArtist: normalizedAlbumArtist, + coverUrl: trackToDownload.coverUrl, + outputDir: outputDir, + filenameFormat: state.filenameFormat, + quality: quality, + trackNumber: trackToDownload.trackNumber ?? 1, + discNumber: trackToDownload.discNumber ?? 1, + releaseDate: trackToDownload.releaseDate, + itemId: item.id, + durationMs: trackToDownload.duration, + isrc: trackToDownload.isrc, + spotifyId: trackToDownload.id, + deezerId: deezerTrackId, + storageMode: storageMode, + safTreeUri: treeUri, + safRelativeDir: relativeDir, + safFileName: fileName, + safOutputExt: outputExt, + ); + } + if (useExtensions) { _log.d('Using extension providers for download'); _log.d( @@ -3236,6 +3287,96 @@ class DownloadQueueNotifier extends Notifier { await File(tempPath).delete(); } catch (_) {} } + } + } + + // YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt + if (!wasExisting && + item.service == 'youtube' && + filePath != null) { + final isOpusFile = filePath.endsWith('.opus'); + final isMp3File = filePath.endsWith('.mp3'); + + if (isOpusFile || isMp3File) { + _log.i('YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); + + final isContentUriPath = isContentUri(filePath); + if (isContentUriPath && effectiveSafMode) { + // SAF mode: copy to temp, embed, write back + final tempPath = await _copySafToTemp(filePath); + if (tempPath != null) { + try { + if (isMp3File) { + await _embedMetadataToMp3( + tempPath, + trackToDownload, + genre: genre, + label: label, + ); + } else { + await _embedMetadataToOpus( + tempPath, + trackToDownload, + genre: genre, + label: label, + ); + } + // Write back to SAF + final ext = isMp3File ? '.mp3' : '.opus'; + final newFileName = '${safBaseName ?? 'track'}$ext'; + final newUri = await _writeTempToSaf( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: newFileName, + mimeType: _mimeTypeForExt(ext), + srcPath: tempPath, + ); + if (newUri != null) { + if (newUri != filePath) { + await _deleteSafFile(filePath); + } + filePath = newUri; + finalSafFileName = newFileName; + _log.d('YouTube SAF metadata embedding completed'); + } else { + _log.w('Failed to write metadata-updated file back to SAF'); + } + } catch (e) { + _log.w('YouTube SAF metadata embedding failed: $e'); + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + } + } + } else { + // Non-SAF mode: embed directly + try { + if (isMp3File) { + await _embedMetadataToMp3( + filePath, + trackToDownload, + genre: genre, + label: label, + ); + } else { + await _embedMetadataToOpus( + filePath, + trackToDownload, + genre: genre, + label: label, + ); + } + _log.d('YouTube metadata embedding completed'); + } catch (e) { + _log.w('YouTube metadata embedding failed: $e'); + } + } } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 40f46b89..0c0265b3 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2942,9 +2942,17 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 8), Text( - item.speedMBps > 0 - ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : '${(item.progress * 100).toStringAsFixed(0)}%', + // When progress is 0 (unknown size, e.g. YouTube tunnel mode), + // show bytes downloaded instead of percentage + item.progress > 0 + ? (item.speedMBps > 0 + ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : '${(item.progress * 100).toStringAsFixed(0)}%') + : (item.bytesReceived > 0 + ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : (item.speedMBps > 0 + ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : 'Starting...')), style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: colorScheme.primary, diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 6ce36ed6..00b48ed1 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class DownloadSettingsPage extends ConsumerStatefulWidget { @@ -917,17 +918,14 @@ class _DownloadSettingsPageState extends ConsumerState { // Note: iOS requires folder to have at least one file to be selectable final result = await FilePicker.platform.getDirectoryPath(); if (result != null) { - // iOS: Check if user selected iCloud Drive (not accessible by Go backend) + // iOS: Validate the selected path is writable (not iCloud or container root) if (Platform.isIOS) { - final isICloudPath = - result.contains('Mobile Documents') || - result.contains('CloudDocs') || - result.contains('com~apple~CloudDocs'); - if (isICloudPath) { + final validation = validateIosPath(result); + if (!validation.isValid) { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar( - content: Text(context.l10n.setupIcloudNotSupported), + content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported), backgroundColor: Theme.of(ctx).colorScheme.error, duration: const Duration(seconds: 4), ), diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index cba73a38..230a5fad 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -9,6 +9,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; class SetupScreen extends ConsumerStatefulWidget { const SetupScreen({super.key}); @@ -322,6 +323,22 @@ class _SetupScreenState extends ConsumerState { Navigator.pop(ctx); final result = await FilePicker.platform.getDirectoryPath(); if (result != null) { + // iOS: Validate the selected path is writable + if (Platform.isIOS) { + final validation = validateIosPath(result); + if (!validation.isValid) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(validation.errorReason ?? 'Invalid folder selected'), + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); + } + return; + } + } setState(() => _selectedDirectory = result); } }, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index e5e8f662..1aeacfe4 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1113,8 +1113,71 @@ static Future> downloadWithExtensions({ return result as String; } - static Future clearStoreCache() async { - _log.d('clearStoreCache'); - await _channel.invokeMethod('clearStoreCache'); - } -} + static Future clearStoreCache() async { + _log.d('clearStoreCache'); + await _channel.invokeMethod('clearStoreCache'); + } + + // ==================== YOUTUBE / COBALT ==================== + + /// Download a track from YouTube using the Cobalt API. + /// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps). + /// It does NOT participate in the lossless fallback chain. + static Future> downloadFromYouTube({ + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + String quality = 'opus_256', + int trackNumber = 1, + int discNumber = 1, + String? releaseDate, + String? itemId, + int durationMs = 0, + String? isrc, + String? spotifyId, + String? deezerId, + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', + }) async { + _log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)'); + final request = jsonEncode({ + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist ?? artistName, + 'cover_url': coverUrl, + 'output_dir': outputDir, + 'filename_format': filenameFormat, + 'quality': quality, + 'track_number': trackNumber, + 'disc_number': discNumber, + 'release_date': releaseDate ?? '', + 'item_id': itemId ?? '', + 'duration_ms': durationMs, + 'isrc': isrc ?? '', + 'spotify_id': spotifyId ?? '', + 'deezer_id': deezerId ?? '', + 'storage_mode': storageMode, + 'saf_tree_uri': safTreeUri, + 'saf_relative_dir': safRelativeDir, + 'saf_file_name': safFileName, + 'saf_output_ext': safOutputExt, + }); + + final result = await _channel.invokeMethod('downloadFromYouTube', request); + final response = jsonDecode(result as String) as Map; + if (response['success'] == true) { + _log.i('YouTube download success: ${response['file_path']}'); + } else { + _log.w('YouTube download failed: ${response['error']}'); + } + return response; + } +} diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index e0227719..1ac2b66c 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -1,9 +1,138 @@ import 'dart:io'; import 'package:open_filex/open_filex.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; +/// Regular expression to detect iOS app container paths. +/// Matches paths like /var/mobile/Containers/Data/Application/{UUID} +/// or /private/var/mobile/Containers/Data/Application/{UUID} +final _iosContainerRootPattern = RegExp( + r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$', + caseSensitive: false, +); + +/// Checks if a path is a valid writable directory on iOS. +/// Returns false if: +/// - The path is the app container root (not writable) +/// - The path is an iCloud Drive path (not accessible by Go backend) +/// - The path is outside the app sandbox +bool isValidIosWritablePath(String path) { + if (!Platform.isIOS) return true; + if (path.isEmpty) return false; + + // Check if it's the container root (without Documents/, tmp/, etc.) + if (_iosContainerRootPattern.hasMatch(path)) { + return false; + } + + // Check for iCloud Drive paths + if (path.contains('Mobile Documents') || + path.contains('CloudDocs') || + path.contains('com~apple~CloudDocs')) { + return false; + } + + // Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.) + // This handles cases where FilePicker returns container root + final containerPattern = RegExp( + r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+', + caseSensitive: false, + ); + final match = containerPattern.firstMatch(path); + if (match != null) { + final remainingPath = path.substring(match.end); + // Valid paths should have something after the UUID + if (remainingPath.isEmpty || remainingPath == '/') { + return false; + } + } + + return true; +} + +/// Validates and potentially corrects an iOS path. +/// Returns a valid Documents subdirectory path if the input is invalid. +Future validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async { + if (!Platform.isIOS) return path; + + if (isValidIosWritablePath(path)) { + return path; + } + + // Fall back to app Documents directory + final dir = await getApplicationDocumentsDirectory(); + final musicDir = Directory('${dir.path}/$subfolder'); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir.path; +} + +/// Detailed result for iOS path validation +class IosPathValidationResult { + final bool isValid; + final String? correctedPath; + final String? errorReason; + + const IosPathValidationResult({ + required this.isValid, + this.correctedPath, + this.errorReason, + }); +} + +/// Validates an iOS path and returns detailed information about the result. +IosPathValidationResult validateIosPath(String path) { + if (!Platform.isIOS) { + return const IosPathValidationResult(isValid: true); + } + + if (path.isEmpty) { + return const IosPathValidationResult( + isValid: false, + errorReason: 'Path is empty', + ); + } + + // Check if it's the container root + if (_iosContainerRootPattern.hasMatch(path)) { + return const IosPathValidationResult( + isValid: false, + errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.', + ); + } + + // Check for iCloud Drive paths + if (path.contains('Mobile Documents') || + path.contains('CloudDocs') || + path.contains('com~apple~CloudDocs')) { + return const IosPathValidationResult( + isValid: false, + errorReason: 'iCloud Drive is not supported. Please choose a local folder.', + ); + } + + // Check for container root without subdirectory + final containerPattern = RegExp( + r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+', + caseSensitive: false, + ); + final match = containerPattern.firstMatch(path); + if (match != null) { + final remainingPath = path.substring(match.end); + if (remainingPath.isEmpty || remainingPath == '/') { + return const IosPathValidationResult( + isValid: false, + errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.', + ); + } + } + + return const IosPathValidationResult(isValid: true); +} + class FileAccessStat { final int? size; final DateTime? modified; diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 6a4f3fa9..f87713ab 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -22,7 +22,9 @@ class BuiltInService { }); } -/// Default quality options for built-in services (Tidal, Qobuz, Amazon) +/// Default quality options for built-in services (Tidal, Qobuz, YouTube) +/// Note: Amazon is fallback-only and not shown in picker +/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads const _builtInServices = [ BuiltInService( id: 'tidal', @@ -31,7 +33,6 @@ const _builtInServices = [ QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), - QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'), ], ), BuiltInService( @@ -44,15 +45,14 @@ const _builtInServices = [ ], ), BuiltInService( - id: 'amazon', - label: 'Amazon', + id: 'youtube', + label: 'YouTube', qualityOptions: [ - QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), - QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), - QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'), + QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'), ], - isDisabled: true, - disabledReason: 'Fallback only', + isDisabled: false, + disabledReason: null, ), ]; @@ -211,7 +211,7 @@ Padding( ), ), - if (_builtInServices.any((s) => s.id == _selectedService)) + if (_builtInServices.any((s) => s.id == _selectedService && s.id != 'youtube')) Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), child: Text( @@ -223,19 +223,26 @@ Padding( ), ), + if (_selectedService == 'youtube') + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + context.l10n.youtubeQualityNote, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + for (final quality in qualityOptions) _QualityOption( title: quality.label, subtitle: quality.description ?? '', icon: _getQualityIcon(quality.id), onTap: () { - // For Tidal HIGH quality, show format picker first - if (_selectedService == 'tidal' && quality.id == 'HIGH') { - _showLossyFormatPicker(context); - } else { - Navigator.pop(context); - widget.onSelect(quality.id, _selectedService); - } + Navigator.pop(context); + widget.onSelect(quality.id, _selectedService); }, ), @@ -254,136 +261,17 @@ Padding( return Icons.high_quality; case 'LOSSLESS': return Icons.music_note; - case 'HIGH': - return Icons.aod; case 'MP3_320': case 'MP3': return Icons.audiotrack; case 'OPUS': case 'OPUS_128': + case 'OPUS_256': return Icons.graphic_eq; default: return Icons.music_note; } } - - void _showLossyFormatPicker(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.read(settingsProvider); - final currentFormat = settings.tidalHighFormat; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (modalContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text( - 'Select Lossy Format', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - 'Choose output format for 320kbps lossy download', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20), - ), - title: const Text('MP3 320kbps'), - subtitle: const Text('Best compatibility, ~10MB per track'), - trailing: currentFormat == 'mp3_320' - ? Icon(Icons.check_circle, color: colorScheme.primary) - : null, - onTap: () { - ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320'); - Navigator.pop(modalContext); // Close format picker - Navigator.pop(context); // Close service picker - widget.onSelect('HIGH', _selectedService); - }, - ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20), - ), - title: const Text('Opus 256kbps'), - subtitle: const Text('Best quality Opus, ~8MB per track'), - trailing: currentFormat == 'opus_256' - ? Icon(Icons.check_circle, color: colorScheme.primary) - : null, - onTap: () { - ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256'); - Navigator.pop(modalContext); // Close format picker - Navigator.pop(context); // Close service picker - widget.onSelect('HIGH', _selectedService); - }, - ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20), - ), - title: const Text('Opus 128kbps'), - subtitle: const Text('Smallest size, ~4MB per track'), - trailing: currentFormat == 'opus_128' - ? Icon(Icons.check_circle, color: colorScheme.primary) - : null, - onTap: () { - ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128'); - Navigator.pop(modalContext); // Close format picker - Navigator.pop(context); // Close service picker - widget.onSelect('HIGH', _selectedService); - }, - ), - const SizedBox(height: 16), - ], - ), - ), - ); - } } diff --git a/pubspec.yaml b/pubspec.yaml index 48febfb1..3073f601 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.5.2+76 +version: 3.6.0+77 environment: sdk: ^3.10.0