mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-28 10:31:27 +02:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<String>("extension_id") ?: ""
|
||||
val trackJson = call.argument<String>("track") ?: "{}"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+142
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'ダウンロード前に確認する';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Спрашивать перед скачиванием';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> 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<String, dynamic> _$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],
|
||||
|
||||
@@ -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<DownloadQueueState> {
|
||||
status: DownloadStatus.downloading,
|
||||
progress: percentage,
|
||||
speedMBps: speedMBps,
|
||||
bytesReceived: bytesReceived,
|
||||
);
|
||||
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
@@ -835,10 +838,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadItem>.from(updatedItems);
|
||||
changed = true;
|
||||
@@ -1203,6 +1208,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2942,9 +2942,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -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<DownloadSettingsPage> {
|
||||
// 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),
|
||||
),
|
||||
|
||||
@@ -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<SetupScreen> {
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1113,8 +1113,71 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
return result as String;
|
||||
}
|
||||
|
||||
static Future<void> clearStoreCache() async {
|
||||
_log.d('clearStoreCache');
|
||||
await _channel.invokeMethod('clearStoreCache');
|
||||
}
|
||||
}
|
||||
static Future<void> 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<Map<String, dynamic>> 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<String, dynamic>;
|
||||
if (response['success'] == true) {
|
||||
_log.i('YouTube download success: ${response['file_path']}');
|
||||
} else {
|
||||
_log.w('YouTube download failed: ${response['error']}');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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;
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user