diff --git a/go_backend/exports.go b/go_backend/exports.go index 3c9ec59d..23f80ad6 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -406,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = deezerErr - case "youtube": - youtubeResult, youtubeErr := downloadFromYouTube(req) - if youtubeErr == nil { - result = DownloadResult{ - FilePath: youtubeResult.FilePath, - BitDepth: 0, - SampleRate: 0, - 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) } @@ -475,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) { serviceNormalized := strings.ToLower(serviceRaw) normalizedReq := req - if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) { + if isBuiltInProvider(serviceNormalized) { normalizedReq.Service = serviceNormalized } @@ -485,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) { } normalizedJSON := string(normalizedBytes) - if serviceNormalized == "youtube" { - return DownloadFromYouTube(normalizedJSON) - } - if req.UseExtensions { // Respect strict mode when auto fallback is disabled: // for built-in providers, route directly to selected service only. @@ -1668,62 +1646,6 @@ func errorResponse(msg string) (string, error) { return string(jsonBytes), nil } -func DownloadFromYouTube(requestJSON string) (string, error) { - var req DownloadRequest - if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { - return errorResponse("Invalid request: " + err.Error()) - } - applySongLinkRegionFromRequest(&req) - defer closeOwnedOutputFD(req.OutputFD) - - 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, - CoverURL: req.CoverURL, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - } - - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -func IsYouTubeURLExport(urlStr string) bool { - return IsYouTubeURL(urlStr) -} - -func ExtractYouTubeVideoIDExport(urlStr string) (string, error) { - return ExtractYouTubeVideoID(urlStr) -} - func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { if coverURL == "" { return fmt.Errorf("no cover URL provided") diff --git a/go_backend/youtube.go b/go_backend/youtube.go deleted file mode 100644 index abffd2ff..00000000 --- a/go_backend/youtube.go +++ /dev/null @@ -1,745 +0,0 @@ -package gobackend - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "sync" -) - -type YouTubeDownloader struct { - client *http.Client - apiURL string - mu sync.Mutex -} - -const spotubeBaseURL = "https://spotubedl.com" - -var ( - globalYouTubeDownloader *YouTubeDownloader - youtubeDownloaderOnce sync.Once -) - -type YouTubeQuality string - -const ( - YouTubeQualityOpus320 YouTubeQuality = "opus_320" - YouTubeQualityOpus256 YouTubeQuality = "opus_256" - YouTubeQualityOpus128 YouTubeQuality = "opus_128" - YouTubeQualityMP3128 YouTubeQuality = "mp3_128" - YouTubeQualityMP3256 YouTubeQuality = "mp3_256" - YouTubeQualityMP3320 YouTubeQuality = "mp3_320" -) - -var ( - youtubeOpusSupportedBitrates = []int{128, 256, 320} - youtubeMp3SupportedBitrates = []int{128, 256, 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 -} - -func NewYouTubeDownloader() *YouTubeDownloader { - youtubeDownloaderOnce.Do(func() { - globalYouTubeDownloader = &YouTubeDownloader{ - client: NewHTTPClientWithTimeout(DownloadTimeout), - apiURL: "https://api.qwkuns.me", - } - }) - return globalYouTubeDownloader -} - -func extractBitrateFromQuality(raw string, defaultBitrate int) int { - parts := strings.FieldsFunc(raw, func(r rune) bool { - return (r < '0' || r > '9') - }) - for i := len(parts) - 1; i >= 0; i-- { - part := parts[i] - if part == "" { - continue - } - if parsed, err := strconv.Atoi(part); err == nil { - return parsed - } - } - return defaultBitrate -} - -func nearestSupportedBitrate(value int, supported []int) int { - nearest := supported[0] - nearestDistance := absInt(value - nearest) - - for _, option := range supported[1:] { - distance := absInt(value - option) - // On tie prefer higher quality. - if distance < nearestDistance || (distance == nearestDistance && option > nearest) { - nearest = option - nearestDistance = distance - } - } - - return nearest -} - -func absInt(value int) int { - if value < 0 { - return -value - } - return value -} - -func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) { - normalizedRaw := strings.ToLower(strings.TrimSpace(raw)) - - if strings.HasPrefix(normalizedRaw, "opus") { - parsed := extractBitrateFromQuality(normalizedRaw, 256) - finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates) - return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate)) - } - - if strings.HasPrefix(normalizedRaw, "mp3") { - parsed := extractBitrateFromQuality(normalizedRaw, 320) - finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates) - return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate)) - } - - // Backward compatibility for legacy symbolic values. - switch normalizedRaw { - case "opus_256", "opus256", "opus": - return "opus", 256, YouTubeQualityOpus256 - case "opus_320", "opus320": - return "opus", 320, YouTubeQualityOpus320 - case "opus_128", "opus128": - return "opus", 128, YouTubeQualityOpus128 - case "mp3_320", "mp3320", "mp3", "": - return "mp3", 320, YouTubeQualityMP3320 - case "mp3_256", "mp3256": - return "mp3", 256, YouTubeQualityMP3256 - case "mp3_128", "mp3128": - return "mp3", 128, YouTubeQualityMP3128 - default: - return "mp3", 320, YouTubeQualityMP3320 - } -} - -func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) { - query := fmt.Sprintf("%s %s", artistName, trackName) - searchQuery := url.QueryEscape(query) - - GoLog("[YouTube] Search query: %s\n", query) - - youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery) - - return youtubeMusicURL, nil -} - -func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) { - y.mu.Lock() - defer y.mu.Unlock() - - audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality)) - audioBitrate := strconv.Itoa(bitrate) - - // Try SpotubeDL first (primary) - var spotubeErr error - videoID, extractErr := ExtractYouTubeVideoID(youtubeURL) - if extractErr == nil { - GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n", - videoID, audioFormat, audioBitrate) - - resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate) - if err == nil { - return resp, nil - } - spotubeErr = err - GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err) - } else { - GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr) - } - - // Fallback: direct Cobalt API (api.qwkuns.me) - cobaltURL := toYouTubeMusicURL(youtubeURL) - GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n", - cobaltURL, audioFormat, audioBitrate) - - resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate) - if err != nil { - if spotubeErr != nil { - return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err) - } - return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err) - } - - return resp, nil -} - -func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) { - reqBody := CobaltRequest{ - URL: videoURL, - AudioFormat: audioFormat, - AudioBitrate: audioBitrate, - DownloadMode: "audio", - FilenameStyle: "basic", - DisableMetadata: true, - } - - jsonData, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - 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 -} - -// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances). -// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests. -func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) { - engines := []string{"v1"} - if strings.EqualFold(audioFormat, "mp3") { - engines = append(engines, "v3", "v2") - } - var lastErr error - - for _, engine := range engines { - resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine) - if err == nil { - return resp, nil - } - lastErr = err - GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err) - } - - if lastErr == nil { - lastErr = fmt.Errorf("no SpotubeDL engine available") - } - return nil, lastErr -} - -func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) { - apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s", - spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate)) - - GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL) - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Accept", "application/json") - - resp, err := DoRequestWithUserAgent(y.client, req) - if err != nil { - return nil, fmt.Errorf("spotubedl 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] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode) - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body)) - } - - var result struct { - URL string `json:"url"` - Status string `json:"status"` - Error string `json:"error"` - Message string `json:"message"` - Filename string `json:"filename"` - } - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse spotubedl response: %w", err) - } - - downloadURL := strings.TrimSpace(result.URL) - if downloadURL == "" { - if result.Error != "" { - return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error) - } - if result.Message != "" { - return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message) - } - return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine) - } - - if strings.HasPrefix(downloadURL, "/") { - downloadURL = spotubeBaseURL + downloadURL - } - - if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") { - return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL) - } - - filename := strings.TrimSpace(result.Filename) - if filename == "" { - if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil { - if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" { - if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil { - filename = decodedFilename - } else { - filename = queryFilename - } - } - } - } - - GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine) - return &CobaltResponse{ - Status: "tunnel", - URL: downloadURL, - Filename: filename, - }, nil -} - -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 -} - -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)) -} - -func BuildYouTubeWatchURL(videoID string) string { - return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) -} - -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 -} - -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") -} - -// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format. -// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt. -func toYouTubeMusicURL(rawURL string) string { - videoID, err := ExtractYouTubeVideoID(rawURL) - if err != nil { - return rawURL - } - return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) -} - -func ExtractYouTubeVideoID(urlStr string) (string, error) { - 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 - } - } - - parsed, err := url.Parse(urlStr) - if err != nil { - return "", fmt.Errorf("invalid URL: %w", err) - } - - if v := parsed.Query().Get("v"); v != "" { - return v, nil - } - - if strings.Contains(parsed.Path, "/embed/") { - parts := strings.Split(parsed.Path, "/embed/") - if len(parts) >= 2 { - return strings.Split(parts[1], "/")[0], nil - } - } - - 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") -} - -// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch -// to find a track by artist + title. It filters for tracks only (not videos, -// albums, or playlists) and returns the YouTube Music watch URL for the first -// matching track, or "" if nothing was found. -func searchYouTubeMusicViaExtension(artistName, trackName string) string { - extManager := GetExtensionManager() - searchProviders := extManager.GetSearchProviders() - - var ytProvider *ExtensionProviderWrapper - for _, p := range searchProviders { - if p.extension.ID == "ytmusic-spotiflac" { - ytProvider = p - break - } - } - if ytProvider == nil { - GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n") - return "" - } - - query := strings.TrimSpace(artistName + " " + trackName) - if query == "" { - return "" - } - - GoLog("[YouTube] Searching YT Music extension for: %s\n", query) - results, err := ytProvider.CustomSearch(query, map[string]interface{}{ - "filter": "tracks", - }) - if err != nil { - GoLog("[YouTube] YT Music extension search failed: %v\n", err) - return "" - } - - for _, track := range results { - if track.ItemType != "" && track.ItemType != "track" { - continue - } - videoID := strings.TrimSpace(track.ID) - if videoID == "" { - continue - } - if isYouTubeVideoID(videoID) { - return BuildYouTubeWatchURL(videoID) - } - } - - GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query) - return "" -} - -func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { - downloader := NewYouTubeDownloader() - - format, bitrate, quality := parseYouTubeQualityInput(req.Quality) - - // URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC) - var youtubeURL string - var lookupErr error - - // SpotifyID might actually be a YouTube video ID (from YT Music extension) - if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) { - youtubeURL = BuildYouTubeWatchURL(req.SpotifyID) - GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL) - } - - // Try YT Music extension search first (if installed) - more accurate, tracks only - if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") { - youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName) - if youtubeURL != "" { - GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL) - } - } - - 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) - } - } - - 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) - } - } - - if youtubeURL == "" && req.ISRC != "" { - GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) - songlink := NewSongLinkClient() - 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) - } - } - - // Cobalt requires direct video URLs, not search 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) - - cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality) - if err != nil { - return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) - } - - ext := ".mp3" - if format == "opus" { - ext = ".opus" - } - - // Some SpotubeDL engines may return a different output container than requested. - // Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension. - if cobaltResp != nil && cobaltResp.Filename != "" { - lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename)) - switch { - case strings.HasSuffix(lowerName, ".mp3"): - ext = ".mp3" - format = "mp3" - case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"): - ext = ".opus" - format = "opus" - } - } - - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "track": req.TrackNumber, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "disc": req.DiscNumber, - }) - filename = sanitizeFilename(filename) + ext - - 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) - - 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), - ) - } - - if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil { - return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err) - } - - 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/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go deleted file mode 100644 index e0f2ebbf..00000000 --- a/go_backend/youtube_quality_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package gobackend - -import "testing" - -func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) { - format, bitrate, normalized := parseYouTubeQualityInput("opus_160") - if format != "opus" { - t.Fatalf("expected opus format, got %s", format) - } - if bitrate != 128 { - t.Fatalf("expected 128 bitrate, got %d", bitrate) - } - if normalized != YouTubeQualityOpus128 { - t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized) - } -} - -func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) { - format, bitrate, normalized := parseYouTubeQualityInput("mp3_192") - if format != "mp3" { - t.Fatalf("expected mp3 format, got %s", format) - } - if bitrate != 256 { - t.Fatalf("expected 256 bitrate, got %d", bitrate) - } - if normalized != YouTubeQualityMP3256 { - t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized) - } -} - -func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) { - _, opusBitrate, _ := parseYouTubeQualityInput("opus_999") - if opusBitrate != 320 { - t.Fatalf("expected opus normalization to 320, got %d", opusBitrate) - } - - _, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1") - if mp3Bitrate != 128 { - t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate) - } -} - -func TestParseYouTubeQualityInput_Opus320(t *testing.T) { - format, bitrate, normalized := parseYouTubeQualityInput("opus_320") - if format != "opus" { - t.Fatalf("expected opus format, got %s", format) - } - if bitrate != 320 { - t.Fatalf("expected 320 bitrate, got %d", bitrate) - } - if normalized != YouTubeQualityOpus320 { - t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized) - } -} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index eb50e497..588eac10 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2722,24 +2722,6 @@ 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; - - /// Title for YouTube Opus bitrate setting - /// - /// In en, this message translates to: - /// **'YouTube Opus Bitrate'** - String get youtubeOpusBitrateTitle; - - /// Title for YouTube MP3 bitrate setting - /// - /// In en, this message translates to: - /// **'YouTube MP3 Bitrate'** - String get youtubeMp3BitrateTitle; - /// 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 5ca628be..f6e211bc 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1479,16 +1479,6 @@ class AppLocalizationsDe extends AppLocalizations { String get qualityNote => 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab'; - @override - String get youtubeQualityNote => - 'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Qualität vor Download fragen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d15c8b31..2a51e017 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1455,16 +1455,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 5c35784d..0442fc0e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1455,16 +1455,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -4466,16 +4456,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get qualityNote => 'La calidad real depende de la disponibilidad de la pista del servicio'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Preguntar antes de descargar'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 591427f1..9f27a85a 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1457,16 +1457,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index e69d5e81..9999389b 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1455,16 +1455,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 3fc89c44..ac0980df 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1463,16 +1463,6 @@ class AppLocalizationsId extends AppLocalizations { String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; - @override - String get youtubeQualityNote => - 'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.'; - - @override - String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus'; - - @override - String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube'; - @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index d4af16ec..2d6606ce 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1444,16 +1444,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus のビットレート'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート'; - @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e9af2187..4a6244d6 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1435,16 +1435,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 521ab98b..b0e709d9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1455,16 +1455,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index b81bdbdc..e22c9ed1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1455,16 +1455,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -4463,16 +4453,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get qualityNote => 'A qualidade real depende da faixa que estiver disponível no serviço'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 13af38da..1875d49f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1480,16 +1480,6 @@ class AppLocalizationsRu extends AppLocalizations { String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; - @override - String get youtubeQualityNote => - 'YouTube обеспечивает только звук с потерями(Lossy).'; - - @override - String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus'; - - @override - String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3'; - @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 7050af84..9ca79ab1 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1461,16 +1461,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 04a1fd43..cb4ee004 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1455,16 +1455,6 @@ 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -4429,16 +4419,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -6835,16 +6815,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { 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 youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 3dce0557..151c6a8d 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Qualität vor Download fragen", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 02ef0a43..465cefc6 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1909,18 +1909,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index dab6880d..e622a44c 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Preguntar antes de descargar", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 2fdf0477..5d2f1cae 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 0eeebf16..f2568821 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index eba93ff1..1cd89fba 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "Bitrate YouTube Opus", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "Kecepatan Bit MP3 YouTube", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Tanya Sebelum Unduh", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 44674a04..ad71c3f3 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus のビットレート", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 のビットレート", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "ダウンロード前に確認する", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index b872ef5c..1bec37ba 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 438519c3..ad97b6a3 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 9c7e843d..3cc94df2 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Perguntar qualidade antes de baixar", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 4a1ffc71..e9373f9c 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "Битрейт YouTube Opus", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "Битрейт YouTube MP3", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Спрашивать перед скачиванием", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 53ce4fb7..1afcb84d 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index db6943ab..ff232550 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index cf4f7b4a..598bb415 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -1773,18 +1773,6 @@ "@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" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index c2f2eed6..63f87abe 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -42,10 +42,6 @@ class AppSettings { final String lyricsMode; final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' - final int - youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps) - final int - youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps) final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE final bool @@ -121,8 +117,6 @@ class AppSettings { this.locale = 'system', this.lyricsMode = 'embed', this.tidalHighFormat = 'mp3_320', - this.youtubeOpusBitrate = 256, - this.youtubeMp3Bitrate = 320, this.useAllFilesAccess = false, this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', @@ -189,8 +183,6 @@ class AppSettings { String? locale, String? lyricsMode, String? tidalHighFormat, - int? youtubeOpusBitrate, - int? youtubeMp3Bitrate, bool? useAllFilesAccess, bool? autoExportFailedDownloads, String? downloadNetworkMode, @@ -257,8 +249,6 @@ class AppSettings { locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, - youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate, - youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 914e224f..d78bc81e 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', - youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256, - youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, @@ -125,8 +123,6 @@ Map _$AppSettingsToJson( 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'tidalHighFormat': instance.tidalHighFormat, - 'youtubeOpusBitrate': instance.youtubeOpusBitrate, - 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate, 'useAllFilesAccess': instance.useAllFilesAccess, 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index c2712eed..57f0e1f3 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2135,15 +2135,12 @@ class DownloadQueueNotifier extends Notifier { } String _determineOutputExt(String quality, String service) { - if (service.toLowerCase() == 'youtube') { - if (quality.toLowerCase().contains('mp3')) { - return '.mp3'; - } - return '.opus'; - } if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { return '.m4a'; } + final q = quality.toLowerCase(); + if (q.startsWith('opus')) return '.opus'; + if (q.startsWith('mp3')) return '.mp3'; return '.flac'; } @@ -3795,28 +3792,6 @@ class DownloadQueueNotifier extends Notifier { ); var quality = item.qualityOverride ?? state.audioQuality; - if (item.service.toLowerCase() == 'youtube') { - final normalized = quality.toLowerCase(); - final isYoutubeQuality = - normalized.startsWith('mp3_') || normalized.startsWith('opus_'); - if (!isYoutubeQuality) { - final mp3Bitrate = (() { - const supported = [128, 256, 320]; - var nearest = supported.first; - var nearestDistance = (settings.youtubeMp3Bitrate - nearest).abs(); - for (final option in supported.skip(1)) { - final distance = (settings.youtubeMp3Bitrate - option).abs(); - if (distance < nearestDistance || - (distance == nearestDistance && option > nearest)) { - nearest = option; - nearestDistance = distance; - } - } - return nearest; - })(); - quality = 'mp3_$mp3Bitrate'; - } - } final isSafMode = _isSafMode(settings); final relativeOutputDir = isSafMode ? await _buildRelativeOutputDir( @@ -4172,14 +4147,10 @@ class DownloadQueueNotifier extends Notifier { final relativeDir = useSaf ? outputDir : ''; final fileName = useSaf ? (safFileName ?? '') : ''; final outputExt = useSaf ? safOutputExt : ''; - final isYouTube = item.service == 'youtube'; - final shouldUseExtensions = !isYouTube && useExtensions; - final shouldUseFallback = !isYouTube && state.autoFallback; + final shouldUseExtensions = useExtensions; + final shouldUseFallback = state.autoFallback; - if (isYouTube) { - _log.d('Using YouTube/Cobalt provider for download'); - _log.d('Quality: $quality (lossy only)'); - } else if (shouldUseExtensions) { + if (shouldUseExtensions) { _log.d('Using extension providers for download'); _log.d( 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', @@ -4854,11 +4825,23 @@ class DownloadQueueNotifier extends Notifier { } else if (metadataEmbeddingEnabled && isContentUriPath && effectiveSafMode && - isFlacFile && + !isM4aFile && !wasExisting) { final currentFilePath = filePath; + final isOpusFile = filePath.endsWith('.opus'); + final isMp3File = filePath.endsWith('.mp3'); + final ext = isOpusFile + ? '.opus' + : isMp3File + ? '.mp3' + : '.flac'; + final formatName = isOpusFile + ? 'Opus' + : isMp3File + ? 'MP3' + : 'FLAC'; _log.d( - 'SAF FLAC detected, embedding metadata and cover via temp file...', + 'SAF $formatName detected, embedding metadata and cover via temp file...', ); final tempPath = await _copySafToTemp(currentFilePath); if (tempPath != null) { @@ -4878,21 +4861,39 @@ class DownloadQueueNotifier extends Notifier { final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; - await _embedMetadataAndCover( - tempPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - writeExternalLrc: false, - ); + if (isMp3File) { + await _embedMetadataToMp3( + tempPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } else if (isOpusFile) { + await _embedMetadataToOpus( + tempPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } else { + await _embedMetadataAndCover( + tempPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + writeExternalLrc: false, + ); + } - final newFileName = '${safBaseName ?? 'track'}.flac'; + final newFileName = '${safBaseName ?? 'track'}$ext'; final newUri = await _writeTempToSaf( treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), + mimeType: _mimeTypeForExt(ext), srcPath: tempPath, ); @@ -4902,12 +4903,14 @@ class DownloadQueueNotifier extends Notifier { } filePath = newUri; finalSafFileName = newFileName; - _log.d('SAF FLAC metadata embedding completed'); + _log.d('SAF $formatName metadata embedding completed'); } else { - _log.w('Failed to write metadata-updated FLAC back to SAF'); + _log.w( + 'Failed to write metadata-updated $formatName back to SAF', + ); } } catch (e) { - _log.w('SAF FLAC metadata embedding failed: $e'); + _log.w('SAF $formatName metadata embedding failed: $e'); } finally { try { await File(tempPath).delete(); @@ -4952,109 +4955,6 @@ class DownloadQueueNotifier extends Notifier { } } - // YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt - if (metadataEmbeddingEnabled && - !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 finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - final isContentUriPath = isContentUri(filePath); - if (isContentUriPath && effectiveSafMode) { - final tempPath = await _copySafToTemp(filePath); - if (tempPath != null) { - try { - if (isMp3File) { - await _embedMetadataToMp3( - tempPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } else { - await _embedMetadataToOpus( - tempPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } - 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 { - try { - if (isMp3File) { - await _embedMetadataToMp3( - filePath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } else { - await _embedMetadataToOpus( - filePath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } - _log.d('YouTube metadata embedding completed'); - } catch (e) { - _log.w('YouTube metadata embedding failed: $e'); - } - } - } - } - final itemAfterDownload = _findItemById(item.id); if (itemAfterDownload == null || _isLocallyCancelled(item.id, item: itemAfterDownload)) { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f2309d5d..77feff76 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -11,13 +11,11 @@ import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 6; +const _currentMigrationVersion = 7; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { - static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; - static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); final Future _prefs = SharedPreferences.getInstance(); @@ -40,7 +38,6 @@ class SettingsNotifier extends Notifier { await _runMigrations(prefs); await _normalizeIosDownloadDirectoryIfNeeded(); - await _normalizeYouTubeBitratesIfNeeded(); await _normalizeSongLinkRegionIfNeeded(); } @@ -122,6 +119,10 @@ class SettingsNotifier extends Notifier { ); } state = state.copyWith(lastSeenVersion: AppInfo.version); + // Migration 7: YouTube is no longer a built-in service — reset to Tidal + if (state.defaultService == 'youtube') { + state = state.copyWith(defaultService: 'tidal'); + } await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); } @@ -153,49 +154,6 @@ class SettingsNotifier extends Notifier { } } - int _nearestSupportedBitrate(int value, List supported) { - var nearest = supported.first; - var nearestDistance = (value - nearest).abs(); - - for (final option in supported.skip(1)) { - final distance = (value - option).abs(); - // On tie, prefer higher quality bitrate. - if (distance < nearestDistance || - (distance == nearestDistance && option > nearest)) { - nearest = option; - nearestDistance = distance; - } - } - - return nearest; - } - - int _normalizeYouTubeOpusBitrate(int bitrate) { - return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates); - } - - int _normalizeYouTubeMp3Bitrate(int bitrate) { - return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates); - } - - Future _normalizeYouTubeBitratesIfNeeded() async { - final normalizedOpus = _normalizeYouTubeOpusBitrate( - state.youtubeOpusBitrate, - ); - final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate); - - if (normalizedOpus == state.youtubeOpusBitrate && - normalizedMp3 == state.youtubeMp3Bitrate) { - return; - } - - state = state.copyWith( - youtubeOpusBitrate: normalizedOpus, - youtubeMp3Bitrate: normalizedMp3, - ); - await _saveSettings(); - } - Future _normalizeIosDownloadDirectoryIfNeeded() async { if (!Platform.isIOS) return; @@ -469,18 +427,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setYoutubeOpusBitrate(int bitrate) { - final normalized = _normalizeYouTubeOpusBitrate(bitrate); - state = state.copyWith(youtubeOpusBitrate: normalized); - _saveSettings(); - } - - void setYoutubeMp3Bitrate(int bitrate) { - final normalized = _normalizeYouTubeMp3Bitrate(bitrate); - state = state.copyWith(youtubeMp3Bitrate: normalized); - _saveSettings(); - } - void setUseAllFilesAccess(bool enabled) { state = state.copyWith(useAllFilesAccess: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 4f3fcbe1..cb292291 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -742,7 +742,7 @@ class _SwingIconState extends State TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20), TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20), TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20), - ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + ]).animate(_controller); _controller.forward(); } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 85470e99..b7c34cae 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -465,34 +465,6 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), ], - SettingsItem( - title: context.l10n.youtubeOpusBitrateTitle, - subtitle: - '${settings.youtubeOpusBitrate}kbps (128/256/320)', - onTap: () => _showYoutubeBitratePicker( - context: context, - title: context.l10n.youtubeOpusBitrateTitle, - currentValue: settings.youtubeOpusBitrate, - options: const [128, 256, 320], - onSave: (value) => ref - .read(settingsProvider.notifier) - .setYoutubeOpusBitrate(value), - ), - ), - SettingsItem( - title: context.l10n.youtubeMp3BitrateTitle, - subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)', - onTap: () => _showYoutubeBitratePicker( - context: context, - title: context.l10n.youtubeMp3BitrateTitle, - currentValue: settings.youtubeMp3Bitrate, - options: const [128, 256, 320], - onSave: (value) => ref - .read(settingsProvider.notifier) - .setYoutubeMp3Bitrate(value), - ), - showDivider: false, - ), ], ), ), @@ -1689,68 +1661,6 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - void _showYoutubeBitratePicker({ - required BuildContext context, - required String title, - required int currentValue, - required List options, - required void Function(int value) onSave, - }) { - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 8), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(sheetContext).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - for (final bitrate in options) - ListTile( - title: Text('$bitrate kbps'), - trailing: bitrate == currentValue - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - onSave(bitrate); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ); - } - void _showMusixmatchLanguagePicker( BuildContext context, WidgetRef ref, @@ -2100,7 +2010,7 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube']; + final builtInServiceIds = ['tidal', 'qobuz', 'deezer']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) @@ -2136,15 +2046,6 @@ class _ServiceSelector extends ConsumerWidget { onTap: () => onChanged('qobuz'), ), ), - const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.smart_display, - label: 'YouTube', - isSelected: effectiveService == 'youtube', - onTap: () => onChanged('youtube'), - ), - ), ], ), if (extensionProviders.isNotEmpty) ...[ diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index 02c15b1a..3d73a06d 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -340,12 +340,6 @@ class _ProviderItem extends StatelessWidget { icon: Icons.graphic_eq, isBuiltIn: true, ); - case 'youtube': - return _ProviderInfo( - name: 'YouTube', - icon: Icons.play_circle_outline, - isBuiltIn: true, - ); default: return _ProviderInfo( name: provider, diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index f11cfa1a..2d149f87 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -343,16 +343,23 @@ class _StoreTabState extends ConsumerState { labelText: context.l10n.storeRepoUrlLabel, prefixIcon: const Icon(Icons.link), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(28), borderSide: BorderSide(color: colorScheme.primary, width: 2), ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), ), keyboardType: TextInputType.url, autocorrect: false, @@ -441,7 +448,31 @@ class _StoreTabState extends ConsumerState { labelText: context.l10n.storeNewRepoUrlLabel, prefixIcon: const Icon(Icons.link), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, ), ), keyboardType: TextInputType.url, diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index d6c292d2..7d8a33f6 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -748,7 +748,7 @@ class _DownloadSuccessOverlayState extends State _flashAnimation = TweenSequence([ TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.15), weight: 30), TweenSequenceItem(tween: Tween(begin: 0.15, end: 0.0), weight: 70), - ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + ]).animate(_controller); } @override @@ -816,7 +816,7 @@ class _AnimatedBadgeState extends State _scaleAnimation = TweenSequence([ TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 40), TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 60), - ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + ]).animate(_controller); } @override diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 7c0a2599..51f4aa37 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -77,24 +77,6 @@ const _builtInServices = [ ), ], ), - BuiltInService( - id: 'youtube', - label: 'YouTube', - qualityOptions: [ - QualityOption( - id: 'opus_320', - label: 'Opus 320kbps', - description: 'Best quality lossy (~10MB per track)', - ), - QualityOption( - id: 'mp3_320', - label: 'MP3 320kbps', - description: 'Best compatibility (~10MB per track)', - ), - ], - isDisabled: false, - disabledReason: null, - ), ]; class DownloadServicePicker extends ConsumerStatefulWidget { @@ -148,9 +130,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget { } class _DownloadServicePickerState extends ConsumerState { - static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; - static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; - late String _selectedService; @override @@ -167,30 +146,6 @@ class _DownloadServicePickerState extends ConsumerState { /// Get quality options for the selected service List _getQualityOptions() { - final settings = ref.read(settingsProvider); - if (_selectedService == 'youtube') { - final opusBitrate = _nearestSupportedBitrate( - settings.youtubeOpusBitrate, - _youtubeOpusSupportedBitrates, - ); - final mp3Bitrate = _nearestSupportedBitrate( - settings.youtubeMp3Bitrate, - _youtubeMp3SupportedBitrates, - ); - return [ - QualityOption( - id: 'opus_$opusBitrate', - label: 'Opus ${opusBitrate}kbps', - description: 'Configured from YouTube settings', - ), - QualityOption( - id: 'mp3_$mp3Bitrate', - label: 'MP3 ${mp3Bitrate}kbps', - description: 'Configured from YouTube settings', - ), - ]; - } - final builtIn = _builtInServices .where((s) => s.id == _selectedService) .firstOrNull; @@ -215,22 +170,6 @@ class _DownloadServicePickerState extends ConsumerState { ]; } - int _nearestSupportedBitrate(int value, List supported) { - var nearest = supported.first; - var nearestDistance = (value - nearest).abs(); - - for (final option in supported.skip(1)) { - final distance = (value - option).abs(); - if (distance < nearestDistance || - (distance == nearestDistance && option > nearest)) { - nearest = option; - nearestDistance = distance; - } - } - - return nearest; - } - @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -324,9 +263,7 @@ class _DownloadServicePickerState extends ConsumerState { ), ), - if (_builtInServices.any( - (s) => s.id == _selectedService && s.id != 'youtube', - )) + if (_builtInServices.any((s) => s.id == _selectedService)) Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), child: Text( @@ -338,18 +275,6 @@ class _DownloadServicePickerState extends ConsumerState { ), ), - 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,