v7.0.5
This commit is contained in:
+89
-27
@@ -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)
|
||||
}
|
||||
|
||||
type MPD struct {
|
||||
XMLName xml.Name `xml:"MPD"`
|
||||
Period struct {
|
||||
AdaptationSet struct {
|
||||
Representation struct {
|
||||
SegmentTemplate struct {
|
||||
type SegmentTemplate struct {
|
||||
Initialization string `xml:"initialization,attr"`
|
||||
Media string `xml:"media,attr"`
|
||||
Timeline struct {
|
||||
Segments []struct {
|
||||
Duration int `xml:"d,attr"`
|
||||
Duration int64 `xml:"d,attr"`
|
||||
Repeat int `xml:"r,attr"`
|
||||
} `xml:"S"`
|
||||
} `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"`
|
||||
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||
} `xml:"AdaptationSet"`
|
||||
} `xml:"Period"`
|
||||
}
|
||||
|
||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
||||
|
||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
if strings.HasPrefix(manifestStr, "{") {
|
||||
|
||||
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
|
||||
var btsManifest TidalBTSManifest
|
||||
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
||||
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")
|
||||
|
||||
var mpd MPD
|
||||
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
||||
var segTemplate *SegmentTemplate
|
||||
|
||||
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
|
||||
initURL = segTemplate.Initialization
|
||||
mediaTemplate := segTemplate.Media
|
||||
for _, rep := range as.Representations {
|
||||
if rep.SegmentTemplate != nil {
|
||||
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="([^"]+)"`)
|
||||
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 {
|
||||
mediaTemplate = match[1]
|
||||
}
|
||||
}
|
||||
|
||||
if initURL == "" {
|
||||
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, "&", "&")
|
||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||
|
||||
segmentCount := 0
|
||||
for _, seg := range segTemplate.Timeline.Segments {
|
||||
segmentCount += seg.Repeat + 1
|
||||
}
|
||||
segmentCount = 0
|
||||
|
||||
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 {
|
||||
repeat := 0
|
||||
if len(match) > 1 && match[1] != "" {
|
||||
fmt.Sscanf(match[1], "%d", &repeat)
|
||||
rRe := regexp.MustCompile(`r="(\d+)"`)
|
||||
if rMatch := rRe.FindStringSubmatch(match); len(rMatch) > 1 {
|
||||
fmt.Sscanf(rMatch[1], "%d", &repeat)
|
||||
}
|
||||
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++ {
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
|
||||
+26
-17
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -57,6 +57,15 @@ function App() {
|
||||
const cover = useCover();
|
||||
const availability = useAvailability();
|
||||
const downloadQueue = useDownloadQueueDialog();
|
||||
useLayoutEffect(() => {
|
||||
const savedSettings = getSettings();
|
||||
if (savedSettings) {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
applyFont(savedSettings.fontFamily);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initSettings = async () => {
|
||||
const settings = await loadSettings();
|
||||
@@ -253,7 +262,7 @@ function App() {
|
||||
return null;
|
||||
if ("track" in 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) {
|
||||
const { album_info, track_list } = metadata.metadata;
|
||||
@@ -267,7 +276,7 @@ function App() {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
}} />);
|
||||
}
|
||||
if ("playlist_info" in metadata.metadata) {
|
||||
const { playlist_info, track_list } = metadata.metadata;
|
||||
@@ -281,7 +290,7 @@ function App() {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
}} />);
|
||||
}
|
||||
if ("artist_info" in metadata.metadata) {
|
||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||
@@ -295,7 +304,7 @@ function App() {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
}} />);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -328,7 +337,7 @@ function App() {
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "settings":
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn} />;
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "audio-analysis":
|
||||
@@ -339,14 +348,14 @@ function App() {
|
||||
return <FileManagerPage />;
|
||||
default:
|
||||
return (<>
|
||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate} />
|
||||
|
||||
|
||||
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<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)}>
|
||||
<X className="h-4 w-4"/>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<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-2">
|
||||
<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">
|
||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
||||
minutes).
|
||||
@@ -372,7 +381,7 @@ function App() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={metadata.handleConfirmFetch}>
|
||||
<Search className="h-4 w-4"/>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -384,7 +393,7 @@ function App() {
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<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)}>
|
||||
<X className="h-4 w-4"/>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||
@@ -404,7 +413,7 @@ function App() {
|
||||
setSpotifyUrl(albumUrl);
|
||||
}
|
||||
}}>
|
||||
<Search className="h-4 w-4"/>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch Album
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -417,7 +426,7 @@ function App() {
|
||||
if (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()}
|
||||
</>);
|
||||
@@ -426,7 +435,7 @@ function App() {
|
||||
return (<TooltipProvider>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<TitleBar />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
|
||||
|
||||
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
@@ -436,14 +445,14 @@ function App() {
|
||||
</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">
|
||||
<ArrowUp className="h-5 w-5"/>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</Button>)}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user