This commit is contained in:
afkarxyz
2026-01-14 08:23:50 +07:00
parent 4ee252f438
commit 4f135f1153
2 changed files with 160 additions and 89 deletions
+89 -27
View File
@@ -740,28 +740,35 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
} }
type MPD struct { type SegmentTemplate struct {
XMLName xml.Name `xml:"MPD"`
Period struct {
AdaptationSet struct {
Representation struct {
SegmentTemplate struct {
Initialization string `xml:"initialization,attr"` Initialization string `xml:"initialization,attr"`
Media string `xml:"media,attr"` Media string `xml:"media,attr"`
Timeline struct { Timeline struct {
Segments []struct { Segments []struct {
Duration int `xml:"d,attr"` Duration int64 `xml:"d,attr"`
Repeat int `xml:"r,attr"` Repeat int `xml:"r,attr"`
} `xml:"S"` } `xml:"S"`
} `xml:"SegmentTimeline"` } `xml:"SegmentTimeline"`
} `xml:"SegmentTemplate"` }
type MPD struct {
XMLName xml.Name `xml:"MPD"`
Period struct {
AdaptationSets []struct {
MimeType string `xml:"mimeType,attr"`
Codecs string `xml:"codecs,attr"`
Representations []struct {
ID string `xml:"id,attr"`
Codecs string `xml:"codecs,attr"`
Bandwidth int `xml:"bandwidth,attr"`
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
} `xml:"Representation"` } `xml:"Representation"`
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
} `xml:"AdaptationSet"` } `xml:"AdaptationSet"`
} `xml:"Period"` } `xml:"Period"`
} }
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil { if err != nil {
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err) return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
@@ -769,8 +776,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
manifestStr := string(manifestBytes) manifestStr := string(manifestBytes)
if strings.HasPrefix(manifestStr, "{") { if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
var btsManifest TidalBTSManifest var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err) return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
@@ -787,15 +793,69 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
fmt.Println("Manifest: DASH format") fmt.Println("Manifest: DASH format")
var mpd MPD var mpd MPD
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil { var segTemplate *SegmentTemplate
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
var selectedBandwidth int
var selectedCodecs string
for _, as := range mpd.Period.AdaptationSets {
if as.SegmentTemplate != nil {
if segTemplate == nil {
segTemplate = as.SegmentTemplate
selectedCodecs = as.Codecs
}
} }
segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate for _, rep := range as.Representations {
initURL = segTemplate.Initialization if rep.SegmentTemplate != nil {
mediaTemplate := segTemplate.Media if rep.Bandwidth > selectedBandwidth {
selectedBandwidth = rep.Bandwidth
segTemplate = rep.SegmentTemplate
if initURL == "" || mediaTemplate == "" { if rep.Codecs != "" {
selectedCodecs = rep.Codecs
} else {
selectedCodecs = as.Codecs
}
}
}
}
}
if selectedBandwidth > 0 {
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
}
}
var mediaTemplate string
segmentCount := 0
if segTemplate != nil {
initURL = segTemplate.Initialization
mediaTemplate = segTemplate.Media
for _, seg := range segTemplate.Timeline.Segments {
segmentCount += seg.Repeat + 1
}
}
if segmentCount > 0 && initURL != "" && mediaTemplate != "" {
initURL = strings.ReplaceAll(initURL, "&", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
fmt.Printf("Parsed manifest via XML: %d segments\n", segmentCount)
for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, nil
}
fmt.Println("Using regex fallback for DASH manifest...")
initRe := regexp.MustCompile(`initialization="([^"]+)"`) initRe := regexp.MustCompile(`initialization="([^"]+)"`)
mediaRe := regexp.MustCompile(`media="([^"]+)"`) mediaRe := regexp.MustCompile(`media="([^"]+)"`)
@@ -806,7 +866,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 { if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
mediaTemplate = match[1] mediaTemplate = match[1]
} }
}
if initURL == "" { if initURL == "" {
return "", "", nil, fmt.Errorf("no initialization URL found in manifest") return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
@@ -815,23 +874,26 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
initURL = strings.ReplaceAll(initURL, "&amp;", "&") initURL = strings.ReplaceAll(initURL, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&") mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
segmentCount := 0 segmentCount = 0
for _, seg := range segTemplate.Timeline.Segments {
segmentCount += seg.Repeat + 1 segTagRe := regexp.MustCompile(`<S\s+[^>]*>`)
} matches := segTagRe.FindAllString(manifestStr, -1)
if segmentCount == 0 {
segRe := regexp.MustCompile(`<S d="\d+"(?: r="(\d+)")?`)
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
for _, match := range matches { for _, match := range matches {
repeat := 0 repeat := 0
if len(match) > 1 && match[1] != "" { rRe := regexp.MustCompile(`r="(\d+)"`)
fmt.Sscanf(match[1], "%d", &repeat) if rMatch := rRe.FindStringSubmatch(match); len(rMatch) > 1 {
fmt.Sscanf(rMatch[1], "%d", &repeat)
} }
segmentCount += repeat + 1 segmentCount += repeat + 1
} }
if segmentCount == 0 {
return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
} }
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
for i := 1; i <= segmentCount; i++ { for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL) mediaURLs = append(mediaURLs, mediaURL)
+26 -17
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useLayoutEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -57,6 +57,15 @@ function App() {
const cover = useCover(); const cover = useCover();
const availability = useAvailability(); const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog(); const downloadQueue = useDownloadQueueDialog();
useLayoutEffect(() => {
const savedSettings = getSettings();
if (savedSettings) {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
}
}, []);
useEffect(() => { useEffect(() => {
const initSettings = async () => { const initSettings = async () => {
const settings = await loadSettings(); const settings = await loadSettings();
@@ -253,7 +262,7 @@ function App() {
return null; return null;
if ("track" in metadata.metadata) { if ("track" in metadata.metadata) {
const { track } = metadata.metadata; const { track } = metadata.metadata;
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder}/>); return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} />);
} }
if ("album_info" in metadata.metadata) { if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata; const { album_info, track_list } = metadata.metadata;
@@ -267,7 +276,7 @@ function App() {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}}/>); }} />);
} }
if ("playlist_info" in metadata.metadata) { if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata; const { playlist_info, track_list } = metadata.metadata;
@@ -281,7 +290,7 @@ function App() {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}}/>); }} />);
} }
if ("artist_info" in metadata.metadata) { if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata; const { artist_info, album_list, track_list } = metadata.metadata;
@@ -295,7 +304,7 @@ function App() {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}}/>); }} />);
} }
return null; return null;
}; };
@@ -328,7 +337,7 @@ function App() {
const renderPage = () => { const renderPage = () => {
switch (currentPage) { switch (currentPage) {
case "settings": case "settings":
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>; return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn} />;
case "debug": case "debug":
return <DebugLoggerPage />; return <DebugLoggerPage />;
case "audio-analysis": case "audio-analysis":
@@ -339,14 +348,14 @@ function App() {
return <FileManagerPage />; return <FileManagerPage />;
default: default:
return (<> return (<>
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/> <Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate} />
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}> <Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden"> <DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4"> <div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}> <Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
<X className="h-4 w-4"/> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle> <DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
@@ -360,7 +369,7 @@ function App() {
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="timeout">Timeout (seconds)</Label> <Label htmlFor="timeout">Timeout (seconds)</Label>
<Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}/> <Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))} />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10 Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
minutes). minutes).
@@ -372,7 +381,7 @@ function App() {
Cancel Cancel
</Button> </Button>
<Button onClick={metadata.handleConfirmFetch}> <Button onClick={metadata.handleConfirmFetch}>
<Search className="h-4 w-4"/> <Search className="h-4 w-4" />
Fetch Fetch
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -384,7 +393,7 @@ function App() {
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden"> <DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4"> <div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}> <Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
<X className="h-4 w-4"/> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle> <DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
@@ -404,7 +413,7 @@ function App() {
setSpotifyUrl(albumUrl); setSpotifyUrl(albumUrl);
} }
}}> }}>
<Search className="h-4 w-4"/> <Search className="h-4 w-4" />
Fetch Album Fetch Album
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -417,7 +426,7 @@ function App() {
if (updatedUrl) { if (updatedUrl) {
setSpotifyUrl(updatedUrl); setSpotifyUrl(updatedUrl);
} }
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/> }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} />
{!isSearchMode && metadata.metadata && renderMetadata()} {!isSearchMode && metadata.metadata && renderMetadata()}
</>); </>);
@@ -426,7 +435,7 @@ function App() {
return (<TooltipProvider> return (<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col"> <div className="min-h-screen bg-background flex flex-col">
<TitleBar /> <TitleBar />
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/> <Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
<div className="flex-1 ml-14 mt-10 p-4 md:p-8"> <div className="flex-1 ml-14 mt-10 p-4 md:p-8">
@@ -436,14 +445,14 @@ function App() {
</div> </div>
<DownloadProgressToast onClick={downloadQueue.openQueue}/> <DownloadProgressToast onClick={downloadQueue.openQueue} />
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/> <DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue} />
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon"> {showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
<ArrowUp className="h-5 w-5"/> <ArrowUp className="h-5 w-5" />
</Button>)} </Button>)}