2025-06-24 18:43:41 +08:00
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
2025-08-24 18:19:49 -04:00
"main/utils/ampapi"
2025-06-24 18:43:41 +08:00
"main/utils/lyrics"
"main/utils/runv2"
"main/utils/runv3"
"main/utils/structs"
2025-08-24 18:19:49 -04:00
"main/utils/task"
2025-06-24 18:43:41 +08:00
2025-08-24 18:19:49 -04:00
"github.com/AlecAivazis/survey/v2"
2025-06-24 18:43:41 +08:00
"github.com/fatih/color"
"github.com/grafov/m3u8"
"github.com/olekukonko/tablewriter"
"github.com/spf13/pflag"
"github.com/zhaarey/go-mp4tag"
"gopkg.in/yaml.v2"
)
var (
forbiddenNames = regexp . MustCompile ( ` [/\\<>:"|?*] ` )
dl_atmos bool
dl_aac bool
dl_select bool
dl_song bool
artist_select bool
debug_mode bool
alac_max * int
atmos_max * int
mv_max * int
mv_audio_type * string
aac_type * string
Config structs . ConfigSet
counter structs . Counter
okDict = make ( map [ string ] [ ] int )
)
func loadConfig ( ) error {
data , err := os . ReadFile ( "config.yaml" )
if err != nil {
return err
}
err = yaml . Unmarshal ( data , & Config )
if err != nil {
return err
}
2025-08-24 18:19:49 -04:00
if len ( Config . Storefront ) != 2 {
Config . Storefront = "us"
}
2025-06-24 18:43:41 +08:00
return nil
}
func LimitString ( s string ) string {
if len ( [ ] rune ( s ) ) > Config . LimitMax {
return string ( [ ] rune ( s ) [ : Config . LimitMax ] )
}
return s
}
func isInArray ( arr [ ] int , target int ) bool {
for _ , num := range arr {
if num == target {
return true
}
}
return false
}
func fileExists ( path string ) ( bool , error ) {
f , err := os . Stat ( path )
if err == nil {
return ! f . IsDir ( ) , nil
} else if os . IsNotExist ( err ) {
return false , nil
}
return false , err
}
func checkUrl ( url string ) ( string , string ) {
pat := regexp . MustCompile ( ` ^(?:https:\/\/(?:beta\.music|music|classical\.music)\.apple\.com\/(\w { 2})(?:\/album|\/album\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?) ` )
matches := pat . FindAllStringSubmatch ( url , - 1 )
if matches == nil {
return "" , ""
} else {
return matches [ 0 ] [ 1 ] , matches [ 0 ] [ 2 ]
}
}
func checkUrlMv ( url string ) ( string , string ) {
pat := regexp . MustCompile ( ` ^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w { 2})(?:\/music-video|\/music-video\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?) ` )
matches := pat . FindAllStringSubmatch ( url , - 1 )
if matches == nil {
return "" , ""
} else {
return matches [ 0 ] [ 1 ] , matches [ 0 ] [ 2 ]
}
}
func checkUrlSong ( url string ) ( string , string ) {
2025-08-24 18:19:49 -04:00
pat := regexp . MustCompile ( ` ^(?:https:\/\/(?:beta\.music|music|classical\.music)\.apple\.com\/(\w { 2})(?:\/song|\/song\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?) ` )
2025-06-24 18:43:41 +08:00
matches := pat . FindAllStringSubmatch ( url , - 1 )
if matches == nil {
return "" , ""
} else {
return matches [ 0 ] [ 1 ] , matches [ 0 ] [ 2 ]
}
}
func checkUrlPlaylist ( url string ) ( string , string ) {
2025-08-24 18:19:49 -04:00
pat := regexp . MustCompile ( ` ^(?:https:\/\/(?:beta\.music|music|classical\.music)\.apple\.com\/(\w { 2})(?:\/playlist|\/playlist\/.+))\/(?:id)?(pl\.[\w-]+)(?:$|\?) ` )
matches := pat . FindAllStringSubmatch ( url , - 1 )
if matches == nil {
return "" , ""
} else {
return matches [ 0 ] [ 1 ] , matches [ 0 ] [ 2 ]
}
}
func checkUrlStation ( url string ) ( string , string ) {
pat := regexp . MustCompile ( ` ^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w { 2})(?:\/station|\/station\/.+))\/(?:id)?(ra\.[\w-]+)(?:$|\?) ` )
2025-06-24 18:43:41 +08:00
matches := pat . FindAllStringSubmatch ( url , - 1 )
if matches == nil {
return "" , ""
} else {
return matches [ 0 ] [ 1 ] , matches [ 0 ] [ 2 ]
}
}
func checkUrlArtist ( url string ) ( string , string ) {
2025-08-24 18:19:49 -04:00
pat := regexp . MustCompile ( ` ^(?:https:\/\/(?:beta\.music|music|classical\.music)\.apple\.com\/(\w { 2})(?:\/artist|\/artist\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?) ` )
2025-06-24 18:43:41 +08:00
matches := pat . FindAllStringSubmatch ( url , - 1 )
if matches == nil {
return "" , ""
} else {
return matches [ 0 ] [ 1 ] , matches [ 0 ] [ 2 ]
}
}
func getUrlSong ( songUrl string , token string ) ( string , error ) {
storefront , songId := checkUrlSong ( songUrl )
2025-08-24 18:19:49 -04:00
manifest , err := ampapi . GetSongResp ( storefront , songId , Config . Language , token )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "\u26A0 Failed to get manifest:" , err )
counter . NotSong ++
return "" , err
}
2025-08-24 18:19:49 -04:00
albumId := manifest . Data [ 0 ] . Relationships . Albums . Data [ 0 ] . ID
2025-06-24 18:43:41 +08:00
songAlbumUrl := fmt . Sprintf ( "https://music.apple.com/%s/album/1/%s?i=%s" , storefront , albumId , songId )
return songAlbumUrl , nil
}
func getUrlArtistName ( artistUrl string , token string ) ( string , string , error ) {
storefront , artistId := checkUrlArtist ( artistUrl )
req , err := http . NewRequest ( "GET" , fmt . Sprintf ( "https://amp-api.music.apple.com/v1/catalog/%s/artists/%s" , storefront , artistId ) , nil )
if err != nil {
return "" , "" , err
}
req . Header . Set ( "Authorization" , fmt . Sprintf ( "Bearer %s" , token ) )
req . Header . Set ( "User-Agent" , "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" )
req . Header . Set ( "Origin" , "https://music.apple.com" )
query := url . Values { }
query . Set ( "l" , Config . Language )
2025-09-29 14:47:39 +08:00
req . URL . RawQuery = query . Encode ( )
2025-06-24 18:43:41 +08:00
do , err := http . DefaultClient . Do ( req )
if err != nil {
return "" , "" , err
}
defer do . Body . Close ( )
if do . StatusCode != http . StatusOK {
return "" , "" , errors . New ( do . Status )
}
obj := new ( structs . AutoGeneratedArtist )
err = json . NewDecoder ( do . Body ) . Decode ( & obj )
if err != nil {
return "" , "" , err
}
return obj . Data [ 0 ] . Attributes . Name , obj . Data [ 0 ] . ID , nil
}
func checkArtist ( artistUrl string , token string , relationship string ) ( [ ] string , error ) {
storefront , artistId := checkUrlArtist ( artistUrl )
Num := 0
//id := 1
var args [ ] string
var urls [ ] string
var options [ ] [ ] string
for {
req , err := http . NewRequest ( "GET" , fmt . Sprintf ( "https://amp-api.music.apple.com/v1/catalog/%s/artists/%s/%s?limit=100&offset=%d&l=%s" , storefront , artistId , relationship , Num , Config . Language ) , nil )
if err != nil {
return nil , err
}
req . Header . Set ( "Authorization" , fmt . Sprintf ( "Bearer %s" , token ) )
req . Header . Set ( "User-Agent" , "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" )
req . Header . Set ( "Origin" , "https://music.apple.com" )
do , err := http . DefaultClient . Do ( req )
if err != nil {
return nil , err
}
defer do . Body . Close ( )
if do . StatusCode != http . StatusOK {
return nil , errors . New ( do . Status )
}
obj := new ( structs . AutoGeneratedArtist )
err = json . NewDecoder ( do . Body ) . Decode ( & obj )
if err != nil {
return nil , err
}
for _ , album := range obj . Data {
options = append ( options , [ ] string { album . Attributes . Name , album . Attributes . ReleaseDate , album . ID , album . Attributes . URL } )
}
Num = Num + 100
if len ( obj . Next ) == 0 {
break
}
}
sort . Slice ( options , func ( i , j int ) bool {
// 将日期字符串解析为 time.Time 类型进行比较
dateI , _ := time . Parse ( "2006-01-02" , options [ i ] [ 1 ] )
dateJ , _ := time . Parse ( "2006-01-02" , options [ j ] [ 1 ] )
return dateI . Before ( dateJ ) // 返回 true 表示 i 在 j 前面
} )
table := tablewriter . NewWriter ( os . Stdout )
if relationship == "albums" {
table . SetHeader ( [ ] string { "" , "Album Name" , "Date" , "Album ID" } )
} else if relationship == "music-videos" {
table . SetHeader ( [ ] string { "" , "MV Name" , "Date" , "MV ID" } )
}
table . SetRowLine ( false )
table . SetHeaderColor ( tablewriter . Colors { } ,
tablewriter . Colors { tablewriter . FgRedColor , tablewriter . Bold } ,
tablewriter . Colors { tablewriter . Bold , tablewriter . FgBlackColor } ,
tablewriter . Colors { tablewriter . Bold , tablewriter . FgBlackColor } )
table . SetColumnColor ( tablewriter . Colors { tablewriter . FgCyanColor } ,
tablewriter . Colors { tablewriter . Bold , tablewriter . FgRedColor } ,
tablewriter . Colors { tablewriter . Bold , tablewriter . FgBlackColor } ,
tablewriter . Colors { tablewriter . Bold , tablewriter . FgBlackColor } )
for i , v := range options {
urls = append ( urls , v [ 3 ] )
options [ i ] = append ( [ ] string { fmt . Sprint ( i + 1 ) } , v [ : 3 ] ... )
table . Append ( options [ i ] )
}
table . Render ( )
if artist_select {
fmt . Println ( "You have selected all options:" )
return urls , nil
}
reader := bufio . NewReader ( os . Stdin )
fmt . Println ( "Please select from the " + relationship + " options above (multiple options separated by commas, ranges supported, or type 'all' to select all)" )
cyanColor := color . New ( color . FgCyan )
cyanColor . Print ( "Enter your choice: " )
input , _ := reader . ReadString ( '\n' )
input = strings . TrimSpace ( input )
if input == "all" {
fmt . Println ( "You have selected all options:" )
return urls , nil
}
selectedOptions := [ ] [ ] string { }
parts := strings . Split ( input , "," )
for _ , part := range parts {
2025-08-24 18:19:49 -04:00
if strings . Contains ( part , "-" ) {
2025-06-24 18:43:41 +08:00
rangeParts := strings . Split ( part , "-" )
selectedOptions = append ( selectedOptions , rangeParts )
2025-08-24 18:19:49 -04:00
} else {
2025-06-24 18:43:41 +08:00
selectedOptions = append ( selectedOptions , [ ] string { part } )
}
}
fmt . Println ( "You have selected the following options:" )
for _ , opt := range selectedOptions {
2025-08-24 18:19:49 -04:00
if len ( opt ) == 1 {
2025-06-24 18:43:41 +08:00
num , err := strconv . Atoi ( opt [ 0 ] )
if err != nil {
fmt . Println ( "Invalid option:" , opt [ 0 ] )
continue
}
if num > 0 && num <= len ( options ) {
fmt . Println ( options [ num - 1 ] )
args = append ( args , urls [ num - 1 ] )
} else {
fmt . Println ( "Option out of range:" , opt [ 0 ] )
}
2025-08-24 18:19:49 -04:00
} else if len ( opt ) == 2 {
2025-06-24 18:43:41 +08:00
start , err1 := strconv . Atoi ( opt [ 0 ] )
end , err2 := strconv . Atoi ( opt [ 1 ] )
if err1 != nil || err2 != nil {
fmt . Println ( "Invalid range:" , opt )
continue
}
if start < 1 || end > len ( options ) || start > end {
fmt . Println ( "Range out of range:" , opt )
continue
}
for i := start ; i <= end ; i ++ {
fmt . Println ( options [ i - 1 ] )
args = append ( args , urls [ i - 1 ] )
}
} else {
fmt . Println ( "Invalid option:" , opt )
}
}
return args , nil
}
func writeCover ( sanAlbumFolder , name string , url string ) ( string , error ) {
2025-07-02 05:41:14 -04:00
originalUrl := url
var ext string
var covPath string
2025-06-24 18:43:41 +08:00
if Config . CoverFormat == "original" {
2025-07-02 05:41:14 -04:00
ext = strings . Split ( url , "/" ) [ len ( strings . Split ( url , "/" ) ) - 2 ]
2025-06-24 18:43:41 +08:00
ext = ext [ strings . LastIndex ( ext , "." ) + 1 : ]
covPath = filepath . Join ( sanAlbumFolder , name + "." + ext )
2025-07-02 05:41:14 -04:00
} else {
covPath = filepath . Join ( sanAlbumFolder , name + "." + Config . CoverFormat )
2025-06-24 18:43:41 +08:00
}
exists , err := fileExists ( covPath )
if err != nil {
fmt . Println ( "Failed to check if cover exists." )
return "" , err
}
if exists {
_ = os . Remove ( covPath )
}
if Config . CoverFormat == "png" {
re := regexp . MustCompile ( ` \ { w\}x\ { h\} ` )
parts := re . Split ( url , 2 )
url = parts [ 0 ] + "{w}x{h}" + strings . Replace ( parts [ 1 ] , ".jpg" , ".png" , 1 )
}
2025-08-30 09:40:28 +08:00
url = strings . Replace ( url , "{w}x{h}" , Config . CoverSize , 1 )
2025-06-24 18:43:41 +08:00
if Config . CoverFormat == "original" {
url = strings . Replace ( url , "is1-ssl.mzstatic.com/image/thumb" , "a5.mzstatic.com/us/r1000/0" , 1 )
url = url [ : strings . LastIndex ( url , "/" ) ]
}
req , err := http . NewRequest ( "GET" , url , nil )
if err != nil {
return "" , err
}
req . Header . Set ( "User-Agent" , "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" )
do , err := http . DefaultClient . Do ( req )
if err != nil {
return "" , err
}
defer do . Body . Close ( )
if do . StatusCode != http . StatusOK {
2025-08-28 19:22:32 +08:00
if Config . CoverFormat == "original" {
2025-07-02 05:41:14 -04:00
fmt . Println ( "Failed to get cover, falling back to " + ext + " url." )
splitByDot := strings . Split ( originalUrl , "." )
last := splitByDot [ len ( splitByDot ) - 1 ]
fallback := originalUrl [ : len ( originalUrl ) - len ( last ) ] + ext
2025-08-30 09:40:28 +08:00
fallback = strings . Replace ( fallback , "{w}x{h}" , Config . CoverSize , 1 )
2025-07-02 05:41:14 -04:00
fmt . Println ( "Fallback URL:" , fallback )
req , err = http . NewRequest ( "GET" , fallback , nil )
if err != nil {
fmt . Println ( "Failed to create request for fallback url." )
return "" , err
}
req . Header . Set ( "User-Agent" , "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" )
do , err = http . DefaultClient . Do ( req )
if err != nil {
fmt . Println ( "Failed to get cover from fallback url." )
return "" , err
}
defer do . Body . Close ( )
if do . StatusCode != http . StatusOK {
fmt . Println ( fallback )
return "" , errors . New ( do . Status )
}
} else {
return "" , errors . New ( do . Status )
}
2025-06-24 18:43:41 +08:00
}
f , err := os . Create ( covPath )
if err != nil {
return "" , err
}
defer f . Close ( )
_ , err = io . Copy ( f , do . Body )
if err != nil {
return "" , err
}
return covPath , nil
}
func writeLyrics ( sanAlbumFolder , filename string , lrc string ) error {
lyricspath := filepath . Join ( sanAlbumFolder , filename )
f , err := os . Create ( lyricspath )
if err != nil {
return err
}
defer f . Close ( )
_ , err = f . WriteString ( lrc )
if err != nil {
return err
}
return nil
}
func contains ( slice [ ] string , item string ) bool {
for _ , v := range slice {
if v == item {
return true
}
}
return false
}
2025-08-24 18:19:49 -04:00
// START: New functions for search functionality
// SearchResultItem is a unified struct to hold search results for display.
type SearchResultItem struct {
Type string
Name string
Detail string
URL string
ID string
}
// QualityOption holds information about a downloadable quality.
type QualityOption struct {
ID string
Description string
}
// setDlFlags configures the global download flags based on the user's quality selection.
func setDlFlags ( quality string ) {
dl_atmos = false
dl_aac = false
switch quality {
case "atmos" :
dl_atmos = true
fmt . Println ( "Quality set to: Dolby Atmos" )
case "aac" :
dl_aac = true
* aac_type = "aac"
fmt . Println ( "Quality set to: High-Quality (AAC)" )
case "alac" :
fmt . Println ( "Quality set to: Lossless (ALAC)" )
}
}
// promptForQuality asks the user to select a download quality for the chosen media.
func promptForQuality ( item SearchResultItem , token string ) ( string , error ) {
if item . Type == "Artist" {
fmt . Println ( "Artist selected. Proceeding to list all albums/videos." )
return "default" , nil
}
fmt . Printf ( "\nFetching available qualities for: %s\n" , item . Name )
qualities := [ ] QualityOption {
{ ID : "alac" , Description : "Lossless (ALAC)" } ,
{ ID : "aac" , Description : "High-Quality (AAC)" } ,
{ ID : "atmos" , Description : "Dolby Atmos" } ,
}
qualityOptions := [ ] string { }
for _ , q := range qualities {
qualityOptions = append ( qualityOptions , q . Description )
}
prompt := & survey . Select {
Message : "Select a quality to download:" ,
Options : qualityOptions ,
PageSize : 5 ,
}
selectedIndex := 0
err := survey . AskOne ( prompt , & selectedIndex )
if err != nil {
// This can happen if the user presses Ctrl+C
return "" , nil
}
return qualities [ selectedIndex ] . ID , nil
}
// handleSearch manages the entire interactive search process.
func handleSearch ( searchType string , queryParts [ ] string , token string ) ( string , error ) {
query := strings . Join ( queryParts , " " )
validTypes := map [ string ] bool { "album" : true , "song" : true , "artist" : true }
if ! validTypes [ searchType ] {
return "" , fmt . Errorf ( "invalid search type: %s. Use 'album', 'song', or 'artist'" , searchType )
}
fmt . Printf ( "Searching for %ss: \"%s\" in storefront \"%s\"\n" , searchType , query , Config . Storefront )
offset := 0
limit := 15 // Increased limit for better navigation
apiSearchType := searchType + "s"
for {
searchResp , err := ampapi . Search ( Config . Storefront , query , apiSearchType , Config . Language , token , limit , offset )
if err != nil {
return "" , fmt . Errorf ( "error fetching search results: %w" , err )
}
var items [ ] SearchResultItem
var displayOptions [ ] string
hasNext := false
// Special options for navigation
const prevPageOpt = "⬅️ Previous Page"
const nextPageOpt = "➡️ Next Page"
// Add previous page option if applicable
if offset > 0 {
displayOptions = append ( displayOptions , prevPageOpt )
}
switch searchType {
case "album" :
if searchResp . Results . Albums != nil {
for _ , item := range searchResp . Results . Albums . Data {
year := ""
if len ( item . Attributes . ReleaseDate ) >= 4 {
year = item . Attributes . ReleaseDate [ : 4 ]
}
trackInfo := fmt . Sprintf ( "%d tracks" , item . Attributes . TrackCount )
detail := fmt . Sprintf ( "%s (%s, %s)" , item . Attributes . ArtistName , year , trackInfo )
displayOptions = append ( displayOptions , fmt . Sprintf ( "%s - %s" , item . Attributes . Name , detail ) )
items = append ( items , SearchResultItem { Type : "Album" , URL : item . Attributes . URL , ID : item . ID } )
}
hasNext = searchResp . Results . Albums . Next != ""
}
case "song" :
if searchResp . Results . Songs != nil {
for _ , item := range searchResp . Results . Songs . Data {
detail := fmt . Sprintf ( "%s (%s)" , item . Attributes . ArtistName , item . Attributes . AlbumName )
displayOptions = append ( displayOptions , fmt . Sprintf ( "%s - %s" , item . Attributes . Name , detail ) )
items = append ( items , SearchResultItem { Type : "Song" , URL : item . Attributes . URL , ID : item . ID } )
}
hasNext = searchResp . Results . Songs . Next != ""
}
case "artist" :
if searchResp . Results . Artists != nil {
for _ , item := range searchResp . Results . Artists . Data {
detail := ""
if len ( item . Attributes . GenreNames ) > 0 {
detail = strings . Join ( item . Attributes . GenreNames , ", " )
}
displayOptions = append ( displayOptions , fmt . Sprintf ( "%s (%s)" , item . Attributes . Name , detail ) )
items = append ( items , SearchResultItem { Type : "Artist" , URL : item . Attributes . URL , ID : item . ID } )
}
hasNext = searchResp . Results . Artists . Next != ""
}
}
if len ( items ) == 0 && offset == 0 {
fmt . Println ( "No results found." )
return "" , nil
}
// Add next page option if applicable
if hasNext {
displayOptions = append ( displayOptions , nextPageOpt )
}
prompt := & survey . Select {
Message : "Use arrow keys to navigate, Enter to select:" ,
Options : displayOptions ,
PageSize : limit , // Show a full page of results
}
selectedIndex := 0
err = survey . AskOne ( prompt , & selectedIndex )
if err != nil {
// User pressed Ctrl+C
return "" , nil
}
selectedOption := displayOptions [ selectedIndex ]
// Handle pagination
if selectedOption == nextPageOpt {
offset += limit
continue
}
if selectedOption == prevPageOpt {
offset -= limit
continue
}
2025-06-24 18:43:41 +08:00
2025-08-24 18:19:49 -04:00
// Adjust index to match the `items` slice if "Previous Page" was an option
itemIndex := selectedIndex
if offset > 0 {
itemIndex --
}
selectedItem := items [ itemIndex ]
// Automatically set single song download flag
if selectedItem . Type == "Song" {
dl_song = true
}
quality , err := promptForQuality ( selectedItem , token )
if err != nil {
return "" , fmt . Errorf ( "could not process quality selection: %w" , err )
}
if quality == "" { // User cancelled quality selection
fmt . Println ( "Selection cancelled." )
return "" , nil
}
if quality != "default" {
setDlFlags ( quality )
}
return selectedItem . URL , nil
}
}
// END: New functions for search functionality
2025-09-20 13:46:30 +08:00
// CONVERSION FEATURE: Determine if source codec is lossy (rough heuristic by extension/codec name).
func isLossySource ( ext string , codec string ) bool {
ext = strings . ToLower ( ext )
if ext == ".m4a" && ( codec == "AAC" || strings . Contains ( codec , "AAC" ) || strings . Contains ( codec , "ATMOS" ) ) {
return true
}
if ext == ".mp3" || ext == ".opus" || ext == ".ogg" {
return true
}
return false
}
// CONVERSION FEATURE: Build ffmpeg arguments for desired target.
func buildFFmpegArgs ( ffmpegPath , inPath , outPath , targetFmt , extraArgs string ) ( [ ] string , error ) {
args := [ ] string { "-y" , "-i" , inPath , "-vn" }
switch targetFmt {
case "flac" :
args = append ( args , "-c:a" , "flac" )
case "mp3" :
// VBR quality 2 ~ high quality
args = append ( args , "-c:a" , "libmp3lame" , "-qscale:a" , "2" )
case "opus" :
// Medium/high quality
args = append ( args , "-c:a" , "libopus" , "-b:a" , "192k" , "-vbr" , "on" )
case "wav" :
args = append ( args , "-c:a" , "pcm_s16le" )
case "copy" :
// Just container copy (probably pointless for same container)
args = append ( args , "-c" , "copy" )
default :
return nil , fmt . Errorf ( "unsupported convert-format: %s" , targetFmt )
}
if extraArgs != "" {
// naive split; for complex quoting you could enhance
args = append ( args , strings . Fields ( extraArgs ) ... )
}
args = append ( args , outPath )
return args , nil
}
// CONVERSION FEATURE: Perform conversion if enabled.
func convertIfNeeded ( track * task . Track ) {
if ! Config . ConvertAfterDownload {
return
}
if Config . ConvertFormat == "" {
return
}
srcPath := track . SavePath
if srcPath == "" {
return
}
ext := strings . ToLower ( filepath . Ext ( srcPath ) )
targetFmt := strings . ToLower ( Config . ConvertFormat )
// Map extension for output
if targetFmt == "copy" {
fmt . Println ( "Convert (copy) requested; skipping because it produces no new format." )
return
}
if Config . ConvertSkipIfSourceMatch {
if ext == "." + targetFmt {
fmt . Printf ( "Conversion skipped (already %s)\n" , targetFmt )
return
}
}
outBase := strings . TrimSuffix ( srcPath , ext )
outPath := outBase + "." + targetFmt
2025-11-02 14:01:40 +08:00
// Handle lossy -> lossless cases: optionally skip or warn
if ( targetFmt == "flac" || targetFmt == "wav" ) && isLossySource ( ext , track . Codec ) {
if Config . ConvertSkipLossyToLossless {
fmt . Println ( "Skipping conversion: source appears lossy and target is lossless; configured to skip." )
return
}
if Config . ConvertWarnLossyToLossless {
fmt . Println ( "Warning: Converting lossy source to lossless container will not improve quality." )
}
2025-09-20 13:46:30 +08:00
}
if _ , err := exec . LookPath ( Config . FFmpegPath ) ; err != nil {
fmt . Printf ( "ffmpeg not found at '%s'; skipping conversion.\n" , Config . FFmpegPath )
return
}
args , err := buildFFmpegArgs ( Config . FFmpegPath , srcPath , outPath , targetFmt , Config . ConvertExtraArgs )
if err != nil {
fmt . Println ( "Conversion config error:" , err )
return
}
fmt . Printf ( "Converting -> %s ...\n" , targetFmt )
cmd := exec . Command ( Config . FFmpegPath , args ... )
cmd . Stdout = nil
cmd . Stderr = nil
start := time . Now ( )
if err := cmd . Run ( ) ; err != nil {
fmt . Println ( "Conversion failed:" , err )
// leave original
return
}
fmt . Printf ( "Conversion completed in %s: %s\n" , time . Since ( start ) . Truncate ( time . Millisecond ) , filepath . Base ( outPath ) )
if ! Config . ConvertKeepOriginal {
if err := os . Remove ( srcPath ) ; err != nil {
fmt . Println ( "Failed to remove original after conversion:" , err )
} else {
track . SavePath = outPath
track . SaveName = filepath . Base ( outPath )
fmt . Println ( "Original removed." )
}
} else {
// Keep both but point track to new file (optional decision)
track . SavePath = outPath
track . SaveName = filepath . Base ( outPath )
}
}
2025-08-24 18:19:49 -04:00
func ripTrack ( track * task . Track , token string , mediaUserToken string ) {
var err error
counter . Total ++
fmt . Printf ( "Track %d of %d: %s\n" , track . TaskNum , track . TaskTotal , track . Type )
2025-09-22 12:54:07 +08:00
2025-08-24 18:19:49 -04:00
//提前获取到的播放列表下track所在的专辑信息
if track . PreType == "playlists" && Config . UseSongInfoForPlaylist {
track . GetAlbumData ( token )
}
2025-09-22 12:54:07 +08:00
2025-06-24 18:43:41 +08:00
//mv dl dev
if track . Type == "music-videos" {
if len ( mediaUserToken ) <= 50 {
fmt . Println ( "meida-user-token is not set, skip MV dl" )
counter . Success ++
return
}
if _ , err := exec . LookPath ( "mp4decrypt" ) ; err != nil {
fmt . Println ( "mp4decrypt is not found, skip MV dl" )
counter . Success ++
return
}
2025-08-24 18:19:49 -04:00
err := mvDownloader ( track . ID , track . SaveDir , token , track . Storefront , mediaUserToken , track )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "\u26A0 Failed to dl MV:" , err )
counter . Error ++
return
}
counter . Success ++
return
}
2025-09-22 12:54:07 +08:00
2025-06-24 18:43:41 +08:00
needDlAacLc := false
if dl_aac && Config . AacType == "aac-lc" {
needDlAacLc = true
}
2025-08-24 18:19:49 -04:00
if track . WebM3u8 == "" && ! needDlAacLc {
2025-06-24 18:43:41 +08:00
if dl_atmos {
fmt . Println ( "Unavailable" )
counter . Unavailable ++
return
}
2025-08-24 18:19:49 -04:00
fmt . Println ( "Unavailable, trying to dl aac-lc" )
2025-06-24 18:43:41 +08:00
needDlAacLc = true
}
needCheck := false
if Config . GetM3u8Mode == "all" {
needCheck = true
2025-08-24 18:19:49 -04:00
} else if Config . GetM3u8Mode == "hires" && contains ( track . Resp . Attributes . AudioTraits , "hi-res-lossless" ) {
2025-06-24 18:43:41 +08:00
needCheck = true
}
var EnhancedHls_m3u8 string
if needCheck && ! needDlAacLc {
EnhancedHls_m3u8 , _ = checkM3u8 ( track . ID , "song" )
if strings . HasSuffix ( EnhancedHls_m3u8 , ".m3u8" ) {
2025-08-24 18:19:49 -04:00
track . DeviceM3u8 = EnhancedHls_m3u8
track . M3u8 = EnhancedHls_m3u8
2025-06-24 18:43:41 +08:00
}
}
var Quality string
if strings . Contains ( Config . SongFileFormat , "Quality" ) {
if dl_atmos {
2025-08-24 18:19:49 -04:00
Quality = fmt . Sprintf ( "%dKbps" , Config . AtmosMax - 2000 )
2025-06-24 18:43:41 +08:00
} else if needDlAacLc {
2025-08-24 18:19:49 -04:00
Quality = "256Kbps"
2025-06-24 18:43:41 +08:00
} else {
2025-08-24 18:19:49 -04:00
_ , Quality , err = extractMedia ( track . M3u8 , true )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "Failed to extract quality from manifest.\n" , err )
counter . Error ++
return
}
}
}
2025-08-24 18:19:49 -04:00
track . Quality = Quality
2025-06-24 18:43:41 +08:00
stringsToJoin := [ ] string { }
2025-08-24 18:19:49 -04:00
if track . Resp . Attributes . IsAppleDigitalMaster {
2025-06-24 18:43:41 +08:00
if Config . AppleMasterChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . AppleMasterChoice )
}
}
2025-08-24 18:19:49 -04:00
if track . Resp . Attributes . ContentRating == "explicit" {
2025-06-24 18:43:41 +08:00
if Config . ExplicitChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . ExplicitChoice )
}
}
2025-08-24 18:19:49 -04:00
if track . Resp . Attributes . ContentRating == "clean" {
2025-06-24 18:43:41 +08:00
if Config . CleanChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . CleanChoice )
}
}
Tag_string := strings . Join ( stringsToJoin , " " )
songName := strings . NewReplacer (
"{SongId}" , track . ID ,
2025-08-24 18:19:49 -04:00
"{SongNumer}" , fmt . Sprintf ( "%02d" , track . TaskNum ) ,
"{SongName}" , LimitString ( track . Resp . Attributes . Name ) ,
"{DiscNumber}" , fmt . Sprintf ( "%0d" , track . Resp . Attributes . DiscNumber ) ,
"{TrackNumber}" , fmt . Sprintf ( "%0d" , track . Resp . Attributes . TrackNumber ) ,
2025-06-24 18:43:41 +08:00
"{Quality}" , Quality ,
"{Tag}" , Tag_string ,
2025-08-24 18:19:49 -04:00
"{Codec}" , track . Codec ,
2025-06-24 18:43:41 +08:00
) . Replace ( Config . SongFileFormat )
fmt . Println ( songName )
filename := fmt . Sprintf ( "%s.m4a" , forbiddenNames . ReplaceAllString ( songName , "_" ) )
2025-08-24 18:19:49 -04:00
track . SaveName = filename
trackPath := filepath . Join ( track . SaveDir , track . SaveName )
2025-06-24 18:43:41 +08:00
lrcFilename := fmt . Sprintf ( "%s.%s" , forbiddenNames . ReplaceAllString ( songName , "_" ) , Config . LrcFormat )
2025-09-22 12:54:07 +08:00
// Determine possible post-conversion target file (so we can skip re-download)
var convertedPath string
considerConverted := false
if Config . ConvertAfterDownload &&
Config . ConvertFormat != "" &&
strings . ToLower ( Config . ConvertFormat ) != "copy" &&
! Config . ConvertKeepOriginal {
convertedPath = strings . TrimSuffix ( trackPath , filepath . Ext ( trackPath ) ) + "." + strings . ToLower ( Config . ConvertFormat )
considerConverted = true
}
2025-06-24 18:43:41 +08:00
//get lrc
var lrc string = ""
if Config . EmbedLrc || Config . SaveLrcFile {
2025-08-24 18:19:49 -04:00
lrcStr , err := lyrics . Get ( track . Storefront , track . ID , Config . LrcType , Config . Language , Config . LrcFormat , token , mediaUserToken )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( err )
} else {
if Config . SaveLrcFile {
2025-08-24 18:19:49 -04:00
err := writeLyrics ( track . SaveDir , lrcFilename , lrcStr )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Printf ( "Failed to write lyrics" )
}
}
if Config . EmbedLrc {
lrc = lrcStr
}
}
}
2025-09-22 12:54:07 +08:00
// Existence check now considers converted output (if original was deleted)
existsOriginal , err := fileExists ( trackPath )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "Failed to check if track exists." )
}
2025-09-22 12:54:07 +08:00
if existsOriginal {
2025-06-24 18:43:41 +08:00
fmt . Println ( "Track already exists locally." )
counter . Success ++
2025-08-24 18:19:49 -04:00
okDict [ track . PreID ] = append ( okDict [ track . PreID ] , track . TaskNum )
2025-06-24 18:43:41 +08:00
return
}
2025-09-22 12:54:07 +08:00
if considerConverted {
existsConverted , err2 := fileExists ( convertedPath )
if err2 == nil && existsConverted {
fmt . Println ( "Converted track already exists locally." )
counter . Success ++
okDict [ track . PreID ] = append ( okDict [ track . PreID ] , track . TaskNum )
return
}
}
2025-06-24 18:43:41 +08:00
if needDlAacLc {
if len ( mediaUserToken ) <= 50 {
fmt . Println ( "Invalid media-user-token" )
counter . Error ++
return
}
2025-09-21 01:24:53 +08:00
_ , err := runv3 . Run ( track . ID , trackPath , token , mediaUserToken , false , "" )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "Failed to dl aac-lc:" , err )
2025-08-24 18:19:49 -04:00
if err . Error ( ) == "Unavailable" {
counter . Unavailable ++
return
}
2025-06-24 18:43:41 +08:00
counter . Error ++
return
}
} else {
2025-08-24 18:19:49 -04:00
trackM3u8Url , _ , err := extractMedia ( track . M3u8 , false )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "\u26A0 Failed to extract info from manifest:" , err )
counter . Unavailable ++
return
}
2025-08-24 18:19:49 -04:00
//边下载边解密
err = runv2 . Run ( track . ID , trackM3u8Url , trackPath , Config )
if err != nil {
fmt . Println ( "Failed to run v2:" , err )
counter . Error ++
return
}
}
2025-10-22 08:20:38 +08:00
//这里利用MP4box将fmp4转化为mp4, 并添加ilst box与cover, 方便后面的mp4tag添加更多自定义标签
2025-08-24 18:19:49 -04:00
tags := [ ] string {
"tool=" ,
2025-10-22 08:20:38 +08:00
"artist=AppleMusic" ,
2025-08-24 18:19:49 -04:00
}
if Config . EmbedCover {
if ( strings . Contains ( track . PreID , "pl." ) || strings . Contains ( track . PreID , "ra." ) ) && Config . DlAlbumcoverForPlaylist {
track . CoverPath , err = writeCover ( track . SaveDir , track . ID , track . Resp . Attributes . Artwork . URL )
if err != nil {
fmt . Println ( "Failed to write cover." )
}
}
tags = append ( tags , fmt . Sprintf ( "cover=%s" , track . CoverPath ) )
}
tagsString := strings . Join ( tags , ":" )
cmd := exec . Command ( "MP4Box" , "-itags" , tagsString , trackPath )
if err := cmd . Run ( ) ; err != nil {
fmt . Printf ( "Embed failed: %v\n" , err )
counter . Error ++
return
}
if ( strings . Contains ( track . PreID , "pl." ) || strings . Contains ( track . PreID , "ra." ) ) && Config . DlAlbumcoverForPlaylist {
if err := os . Remove ( track . CoverPath ) ; err != nil {
fmt . Printf ( "Error deleting file: %s\n" , track . CoverPath )
counter . Error ++
return
}
}
track . SavePath = trackPath
err = writeMP4Tags ( track , lrc )
if err != nil {
fmt . Println ( "\u26A0 Failed to write tags in media:" , err )
counter . Unavailable ++
return
}
2025-09-20 13:46:30 +08:00
// CONVERSION FEATURE hook
convertIfNeeded ( track )
2025-08-24 18:19:49 -04:00
counter . Success ++
okDict [ track . PreID ] = append ( okDict [ track . PreID ] , track . TaskNum )
}
func ripStation ( albumId string , token string , storefront string , mediaUserToken string ) error {
station := task . NewStation ( storefront , albumId )
err := station . GetResp ( mediaUserToken , token , Config . Language )
if err != nil {
return err
}
fmt . Println ( " -" , station . Type )
meta := station . Resp
var Codec string
if dl_atmos {
Codec = "ATMOS"
} else if dl_aac {
Codec = "AAC"
} else {
Codec = "ALAC"
}
station . Codec = Codec
var singerFoldername string
if Config . ArtistFolderFormat != "" {
singerFoldername = strings . NewReplacer (
"{ArtistName}" , "Apple Music Station" ,
"{ArtistId}" , "" ,
"{UrlArtistName}" , "Apple Music Station" ,
) . Replace ( Config . ArtistFolderFormat )
if strings . HasSuffix ( singerFoldername , "." ) {
singerFoldername = strings . ReplaceAll ( singerFoldername , "." , "" )
}
singerFoldername = strings . TrimSpace ( singerFoldername )
fmt . Println ( singerFoldername )
}
singerFolder := filepath . Join ( Config . AlacSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
if dl_atmos {
singerFolder = filepath . Join ( Config . AtmosSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
}
if dl_aac {
singerFolder = filepath . Join ( Config . AacSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
}
os . MkdirAll ( singerFolder , os . ModePerm )
station . SaveDir = singerFolder
playlistFolder := strings . NewReplacer (
"{ArtistName}" , "Apple Music Station" ,
"{PlaylistName}" , LimitString ( station . Name ) ,
"{PlaylistId}" , station . ID ,
"{Quality}" , "" ,
"{Codec}" , Codec ,
"{Tag}" , "" ,
) . Replace ( Config . PlaylistFolderFormat )
if strings . HasSuffix ( playlistFolder , "." ) {
playlistFolder = strings . ReplaceAll ( playlistFolder , "." , "" )
}
playlistFolder = strings . TrimSpace ( playlistFolder )
playlistFolderPath := filepath . Join ( singerFolder , forbiddenNames . ReplaceAllString ( playlistFolder , "_" ) )
os . MkdirAll ( playlistFolderPath , os . ModePerm )
station . SaveName = playlistFolder
fmt . Println ( playlistFolder )
covPath , err := writeCover ( playlistFolderPath , "cover" , meta . Data [ 0 ] . Attributes . Artwork . URL )
if err != nil {
fmt . Println ( "Failed to write cover." )
}
station . CoverPath = covPath
if Config . SaveAnimatedArtwork && meta . Data [ 0 ] . Attributes . EditorialVideo . MotionSquare . Video != "" {
fmt . Println ( "Found Animation Artwork." )
motionvideoUrlSquare , err := extractVideo ( meta . Data [ 0 ] . Attributes . EditorialVideo . MotionSquare . Video )
if err != nil {
fmt . Println ( "no motion video square.\n" , err )
} else {
exists , err := fileExists ( filepath . Join ( playlistFolderPath , "square_animated_artwork.mp4" ) )
if err != nil {
fmt . Println ( "Failed to check if animated artwork square exists." )
}
if exists {
fmt . Println ( "Animated artwork square already exists locally." )
} else {
fmt . Println ( "Animation Artwork Square Downloading..." )
cmd := exec . Command ( "ffmpeg" , "-loglevel" , "quiet" , "-y" , "-i" , motionvideoUrlSquare , "-c" , "copy" , filepath . Join ( playlistFolderPath , "square_animated_artwork.mp4" ) )
if err := cmd . Run ( ) ; err != nil {
fmt . Printf ( "animated artwork square dl err: %v\n" , err )
} else {
fmt . Println ( "Animation Artwork Square Downloaded" )
}
}
}
if Config . EmbyAnimatedArtwork {
cmd3 := exec . Command ( "ffmpeg" , "-i" , filepath . Join ( playlistFolderPath , "square_animated_artwork.mp4" ) , "-vf" , "scale=440:-1" , "-r" , "24" , "-f" , "gif" , filepath . Join ( playlistFolderPath , "folder.jpg" ) )
if err := cmd3 . Run ( ) ; err != nil {
fmt . Printf ( "animated artwork square to gif err: %v\n" , err )
}
}
}
if station . Type == "stream" {
counter . Total ++
if isInArray ( okDict [ station . ID ] , 1 ) {
counter . Success ++
return nil
}
songName := strings . NewReplacer (
"{SongId}" , station . ID ,
"{SongNumer}" , "01" ,
"{SongName}" , LimitString ( station . Name ) ,
"{DiscNumber}" , "1" ,
"{TrackNumber}" , "1" ,
"{Quality}" , "256Kbps" ,
"{Tag}" , "" ,
"{Codec}" , "AAC" ,
) . Replace ( Config . SongFileFormat )
fmt . Println ( songName )
trackPath := filepath . Join ( playlistFolderPath , fmt . Sprintf ( "%s.m4a" , forbiddenNames . ReplaceAllString ( songName , "_" ) ) )
exists , _ := fileExists ( trackPath )
if exists {
counter . Success ++
okDict [ station . ID ] = append ( okDict [ station . ID ] , 1 )
fmt . Println ( "Radio already exists locally." )
return nil
}
2025-09-21 01:24:53 +08:00
assetsUrl , serverUrl , err := ampapi . GetStationAssetsUrlAndServerUrl ( station . ID , mediaUserToken , token )
2025-08-24 18:19:49 -04:00
if err != nil {
fmt . Println ( "Failed to get station assets url." , err )
counter . Error ++
return err
}
trackM3U8 := strings . ReplaceAll ( assetsUrl , "index.m3u8" , "256/prog_index.m3u8" )
2025-09-21 01:24:53 +08:00
keyAndUrls , _ := runv3 . Run ( station . ID , trackM3U8 , token , mediaUserToken , true , serverUrl )
2025-08-24 18:19:49 -04:00
err = runv3 . ExtMvData ( keyAndUrls , trackPath )
if err != nil {
fmt . Println ( "Failed to download station stream." , err )
counter . Error ++
return err
}
tags := [ ] string {
"tool=" ,
"disk=1/1" ,
"track=1" ,
"tracknum=1/1" ,
fmt . Sprintf ( "artist=%s" , "Apple Music Station" ) ,
fmt . Sprintf ( "performer=%s" , "Apple Music Station" ) ,
fmt . Sprintf ( "album_artist=%s" , "Apple Music Station" ) ,
fmt . Sprintf ( "album=%s" , station . Name ) ,
fmt . Sprintf ( "title=%s" , station . Name ) ,
}
if Config . EmbedCover {
tags = append ( tags , fmt . Sprintf ( "cover=%s" , station . CoverPath ) )
}
tagsString := strings . Join ( tags , ":" )
cmd := exec . Command ( "MP4Box" , "-itags" , tagsString , trackPath )
if err := cmd . Run ( ) ; err != nil {
fmt . Printf ( "Embed failed: %v\n" , err )
}
counter . Success ++
okDict [ station . ID ] = append ( okDict [ station . ID ] , 1 )
return nil
}
for i := range station . Tracks {
station . Tracks [ i ] . CoverPath = covPath
station . Tracks [ i ] . SaveDir = playlistFolderPath
station . Tracks [ i ] . Codec = Codec
}
trackTotal := len ( station . Tracks )
arr := make ( [ ] int , trackTotal )
for i := 0 ; i < trackTotal ; i ++ {
arr [ i ] = i + 1
}
var selected [ ] int
if true {
selected = arr
}
for i := range station . Tracks {
i ++
if isInArray ( selected , i ) {
ripTrack ( & station . Tracks [ i - 1 ] , token , mediaUserToken )
}
}
return nil
}
func ripAlbum ( albumId string , token string , storefront string , mediaUserToken string , urlArg_i string ) error {
album := task . NewAlbum ( storefront , albumId )
err := album . GetResp ( token , Config . Language )
if err != nil {
fmt . Println ( "Failed to get album response." )
return err
}
meta := album . Resp
if debug_mode {
fmt . Println ( meta . Data [ 0 ] . Attributes . ArtistName )
fmt . Println ( meta . Data [ 0 ] . Attributes . Name )
for trackNum , track := range meta . Data [ 0 ] . Relationships . Tracks . Data {
trackNum ++
fmt . Printf ( "\nTrack %d of %d:\n" , trackNum , len ( meta . Data [ 0 ] . Relationships . Tracks . Data ) )
fmt . Printf ( "%02d. %s\n" , trackNum , track . Attributes . Name )
manifest , err := ampapi . GetSongResp ( storefront , track . ID , album . Language , token )
if err != nil {
fmt . Printf ( "Failed to get manifest for track %d: %v\n" , trackNum , err )
continue
}
var m3u8Url string
if manifest . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls != "" {
m3u8Url = manifest . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls
}
needCheck := false
if Config . GetM3u8Mode == "all" {
needCheck = true
} else if Config . GetM3u8Mode == "hires" && contains ( track . Attributes . AudioTraits , "hi-res-lossless" ) {
needCheck = true
}
if needCheck {
fullM3u8Url , err := checkM3u8 ( track . ID , "song" )
if err == nil && strings . HasSuffix ( fullM3u8Url , ".m3u8" ) {
m3u8Url = fullM3u8Url
} else {
fmt . Println ( "Failed to get best quality m3u8 from device m3u8 port, will use m3u8 from Web API" )
}
}
_ , _ , err = extractMedia ( m3u8Url , true )
if err != nil {
fmt . Printf ( "Failed to extract quality info for track %d: %v\n" , trackNum , err )
continue
}
}
return nil
}
var Codec string
if dl_atmos {
Codec = "ATMOS"
} else if dl_aac {
Codec = "AAC"
} else {
Codec = "ALAC"
}
album . Codec = Codec
var singerFoldername string
if Config . ArtistFolderFormat != "" {
if len ( meta . Data [ 0 ] . Relationships . Artists . Data ) > 0 {
singerFoldername = strings . NewReplacer (
"{UrlArtistName}" , LimitString ( meta . Data [ 0 ] . Attributes . ArtistName ) ,
"{ArtistName}" , LimitString ( meta . Data [ 0 ] . Attributes . ArtistName ) ,
"{ArtistId}" , meta . Data [ 0 ] . Relationships . Artists . Data [ 0 ] . ID ,
) . Replace ( Config . ArtistFolderFormat )
} else {
singerFoldername = strings . NewReplacer (
"{UrlArtistName}" , LimitString ( meta . Data [ 0 ] . Attributes . ArtistName ) ,
"{ArtistName}" , LimitString ( meta . Data [ 0 ] . Attributes . ArtistName ) ,
"{ArtistId}" , "" ,
) . Replace ( Config . ArtistFolderFormat )
}
if strings . HasSuffix ( singerFoldername , "." ) {
singerFoldername = strings . ReplaceAll ( singerFoldername , "." , "" )
}
singerFoldername = strings . TrimSpace ( singerFoldername )
fmt . Println ( singerFoldername )
}
singerFolder := filepath . Join ( Config . AlacSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
if dl_atmos {
singerFolder = filepath . Join ( Config . AtmosSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
}
if dl_aac {
singerFolder = filepath . Join ( Config . AacSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
}
os . MkdirAll ( singerFolder , os . ModePerm )
album . SaveDir = singerFolder
var Quality string
if strings . Contains ( Config . AlbumFolderFormat , "Quality" ) {
if dl_atmos {
Quality = fmt . Sprintf ( "%dKbps" , Config . AtmosMax - 2000 )
} else if dl_aac && Config . AacType == "aac-lc" {
Quality = "256Kbps"
} else {
manifest1 , err := ampapi . GetSongResp ( storefront , meta . Data [ 0 ] . Relationships . Tracks . Data [ 0 ] . ID , album . Language , token )
if err != nil {
fmt . Println ( "Failed to get manifest.\n" , err )
} else {
if manifest1 . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls == "" {
Codec = "AAC"
Quality = "256Kbps"
} else {
needCheck := false
if Config . GetM3u8Mode == "all" {
needCheck = true
} else if Config . GetM3u8Mode == "hires" && contains ( meta . Data [ 0 ] . Relationships . Tracks . Data [ 0 ] . Attributes . AudioTraits , "hi-res-lossless" ) {
needCheck = true
}
var EnhancedHls_m3u8 string
if needCheck {
EnhancedHls_m3u8 , _ = checkM3u8 ( meta . Data [ 0 ] . Relationships . Tracks . Data [ 0 ] . ID , "album" )
if strings . HasSuffix ( EnhancedHls_m3u8 , ".m3u8" ) {
manifest1 . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls = EnhancedHls_m3u8
}
}
_ , Quality , err = extractMedia ( manifest1 . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls , true )
if err != nil {
fmt . Println ( "Failed to extract quality from manifest.\n" , err )
}
}
}
}
}
stringsToJoin := [ ] string { }
if meta . Data [ 0 ] . Attributes . IsAppleDigitalMaster || meta . Data [ 0 ] . Attributes . IsMasteredForItunes {
if Config . AppleMasterChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . AppleMasterChoice )
}
}
if meta . Data [ 0 ] . Attributes . ContentRating == "explicit" {
if Config . ExplicitChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . ExplicitChoice )
}
}
if meta . Data [ 0 ] . Attributes . ContentRating == "clean" {
if Config . CleanChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . CleanChoice )
}
}
Tag_string := strings . Join ( stringsToJoin , " " )
var albumFolderName string
albumFolderName = strings . NewReplacer (
"{ReleaseDate}" , meta . Data [ 0 ] . Attributes . ReleaseDate ,
"{ReleaseYear}" , meta . Data [ 0 ] . Attributes . ReleaseDate [ : 4 ] ,
"{ArtistName}" , LimitString ( meta . Data [ 0 ] . Attributes . ArtistName ) ,
"{AlbumName}" , LimitString ( meta . Data [ 0 ] . Attributes . Name ) ,
"{UPC}" , meta . Data [ 0 ] . Attributes . Upc ,
"{RecordLabel}" , meta . Data [ 0 ] . Attributes . RecordLabel ,
"{Copyright}" , meta . Data [ 0 ] . Attributes . Copyright ,
"{AlbumId}" , albumId ,
"{Quality}" , Quality ,
"{Codec}" , Codec ,
"{Tag}" , Tag_string ,
) . Replace ( Config . AlbumFolderFormat )
if strings . HasSuffix ( albumFolderName , "." ) {
albumFolderName = strings . ReplaceAll ( albumFolderName , "." , "" )
}
albumFolderName = strings . TrimSpace ( albumFolderName )
albumFolderPath := filepath . Join ( singerFolder , forbiddenNames . ReplaceAllString ( albumFolderName , "_" ) )
os . MkdirAll ( albumFolderPath , os . ModePerm )
album . SaveName = albumFolderName
fmt . Println ( albumFolderName )
2025-10-22 00:59:58 +08:00
if Config . SaveArtistCover && len ( meta . Data [ 0 ] . Relationships . Artists . Data ) > 0 {
if meta . Data [ 0 ] . Relationships . Artists . Data [ 0 ] . Attributes . Artwork . Url != "" {
2025-08-24 18:19:49 -04:00
_ , err = writeCover ( singerFolder , "folder" , meta . Data [ 0 ] . Relationships . Artists . Data [ 0 ] . Attributes . Artwork . Url )
if err != nil {
fmt . Println ( "Failed to write artist cover." )
}
}
}
covPath , err := writeCover ( albumFolderPath , "cover" , meta . Data [ 0 ] . Attributes . Artwork . URL )
if err != nil {
fmt . Println ( "Failed to write cover." )
}
if Config . SaveAnimatedArtwork && meta . Data [ 0 ] . Attributes . EditorialVideo . MotionDetailSquare . Video != "" {
fmt . Println ( "Found Animation Artwork." )
motionvideoUrlSquare , err := extractVideo ( meta . Data [ 0 ] . Attributes . EditorialVideo . MotionDetailSquare . Video )
if err != nil {
fmt . Println ( "no motion video square.\n" , err )
} else {
exists , err := fileExists ( filepath . Join ( albumFolderPath , "square_animated_artwork.mp4" ) )
if err != nil {
fmt . Println ( "Failed to check if animated artwork square exists." )
}
if exists {
fmt . Println ( "Animated artwork square already exists locally." )
} else {
fmt . Println ( "Animation Artwork Square Downloading..." )
cmd := exec . Command ( "ffmpeg" , "-loglevel" , "quiet" , "-y" , "-i" , motionvideoUrlSquare , "-c" , "copy" , filepath . Join ( albumFolderPath , "square_animated_artwork.mp4" ) )
if err := cmd . Run ( ) ; err != nil {
fmt . Printf ( "animated artwork square dl err: %v\n" , err )
} else {
fmt . Println ( "Animation Artwork Square Downloaded" )
}
}
}
if Config . EmbyAnimatedArtwork {
cmd3 := exec . Command ( "ffmpeg" , "-i" , filepath . Join ( albumFolderPath , "square_animated_artwork.mp4" ) , "-vf" , "scale=440:-1" , "-r" , "24" , "-f" , "gif" , filepath . Join ( albumFolderPath , "folder.jpg" ) )
if err := cmd3 . Run ( ) ; err != nil {
fmt . Printf ( "animated artwork square to gif err: %v\n" , err )
}
}
motionvideoUrlTall , err := extractVideo ( meta . Data [ 0 ] . Attributes . EditorialVideo . MotionDetailTall . Video )
2025-06-24 18:43:41 +08:00
if err != nil {
2025-08-24 18:19:49 -04:00
fmt . Println ( "no motion video tall.\n" , err )
} else {
exists , err := fileExists ( filepath . Join ( albumFolderPath , "tall_animated_artwork.mp4" ) )
if err != nil {
fmt . Println ( "Failed to check if animated artwork tall exists." )
}
if exists {
fmt . Println ( "Animated artwork tall already exists locally." )
} else {
fmt . Println ( "Animation Artwork Tall Downloading..." )
cmd := exec . Command ( "ffmpeg" , "-loglevel" , "quiet" , "-y" , "-i" , motionvideoUrlTall , "-c" , "copy" , filepath . Join ( albumFolderPath , "tall_animated_artwork.mp4" ) )
if err := cmd . Run ( ) ; err != nil {
fmt . Printf ( "animated artwork tall dl err: %v\n" , err )
} else {
fmt . Println ( "Animation Artwork Tall Downloaded" )
}
}
2025-06-24 18:43:41 +08:00
}
}
2025-08-24 18:19:49 -04:00
for i := range album . Tracks {
album . Tracks [ i ] . CoverPath = covPath
album . Tracks [ i ] . SaveDir = albumFolderPath
album . Tracks [ i ] . Codec = Codec
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
trackTotal := len ( meta . Data [ 0 ] . Relationships . Tracks . Data )
arr := make ( [ ] int , trackTotal )
for i := 0 ; i < trackTotal ; i ++ {
arr [ i ] = i + 1
}
if dl_song {
if urlArg_i == "" {
2025-06-24 18:43:41 +08:00
} else {
2025-08-24 18:19:49 -04:00
for i := range album . Tracks {
if urlArg_i == album . Tracks [ i ] . ID {
ripTrack ( & album . Tracks [ i ] , token , mediaUserToken )
return nil
}
}
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
return nil
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
var selected [ ] int
if ! dl_select {
selected = arr
} else {
selected = album . ShowSelect ( )
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
for i := range album . Tracks {
i ++
if isInArray ( okDict [ albumId ] , i ) {
counter . Total ++
counter . Success ++
continue
}
if isInArray ( selected , i ) {
ripTrack ( & album . Tracks [ i - 1 ] , token , mediaUserToken )
2025-06-24 18:43:41 +08:00
}
}
2025-08-24 18:19:49 -04:00
return nil
2025-06-24 18:43:41 +08:00
2025-08-24 18:19:49 -04:00
}
func ripPlaylist ( playlistId string , token string , storefront string , mediaUserToken string ) error {
playlist := task . NewPlaylist ( storefront , playlistId )
err := playlist . GetResp ( token , Config . Language )
2025-06-24 18:43:41 +08:00
if err != nil {
2025-08-24 18:19:49 -04:00
fmt . Println ( "Failed to get playlist response." )
2025-06-24 18:43:41 +08:00
return err
}
2025-08-24 18:19:49 -04:00
meta := playlist . Resp
2025-06-24 18:43:41 +08:00
if debug_mode {
fmt . Println ( meta . Data [ 0 ] . Attributes . ArtistName )
fmt . Println ( meta . Data [ 0 ] . Attributes . Name )
for trackNum , track := range meta . Data [ 0 ] . Relationships . Tracks . Data {
trackNum ++
fmt . Printf ( "\nTrack %d of %d:\n" , trackNum , len ( meta . Data [ 0 ] . Relationships . Tracks . Data ) )
fmt . Printf ( "%02d. %s\n" , trackNum , track . Attributes . Name )
2025-08-24 18:19:49 -04:00
manifest , err := ampapi . GetSongResp ( storefront , track . ID , playlist . Language , token )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Printf ( "Failed to get manifest for track %d: %v\n" , trackNum , err )
continue
}
var m3u8Url string
2025-08-24 18:19:49 -04:00
if manifest . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls != "" {
m3u8Url = manifest . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls
2025-06-24 18:43:41 +08:00
}
needCheck := false
if Config . GetM3u8Mode == "all" {
needCheck = true
} else if Config . GetM3u8Mode == "hires" && contains ( track . Attributes . AudioTraits , "hi-res-lossless" ) {
needCheck = true
}
if needCheck {
fullM3u8Url , err := checkM3u8 ( track . ID , "song" )
if err == nil && strings . HasSuffix ( fullM3u8Url , ".m3u8" ) {
m3u8Url = fullM3u8Url
} else {
fmt . Println ( "Failed to get best quality m3u8 from device m3u8 port, will use m3u8 from Web API" )
}
}
_ , _ , err = extractMedia ( m3u8Url , true )
if err != nil {
fmt . Printf ( "Failed to extract quality info for track %d: %v\n" , trackNum , err )
continue
}
}
2025-08-24 18:19:49 -04:00
return nil
2025-06-24 18:43:41 +08:00
}
var Codec string
if dl_atmos {
Codec = "ATMOS"
} else if dl_aac {
Codec = "AAC"
} else {
Codec = "ALAC"
}
2025-08-24 18:19:49 -04:00
playlist . Codec = Codec
2025-06-24 18:43:41 +08:00
var singerFoldername string
if Config . ArtistFolderFormat != "" {
2025-08-24 18:19:49 -04:00
singerFoldername = strings . NewReplacer (
"{ArtistName}" , "Apple Music" ,
"{ArtistId}" , "" ,
"{UrlArtistName}" , "Apple Music" ,
) . Replace ( Config . ArtistFolderFormat )
2025-06-24 18:43:41 +08:00
if strings . HasSuffix ( singerFoldername , "." ) {
singerFoldername = strings . ReplaceAll ( singerFoldername , "." , "" )
}
singerFoldername = strings . TrimSpace ( singerFoldername )
fmt . Println ( singerFoldername )
}
singerFolder := filepath . Join ( Config . AlacSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
if dl_atmos {
singerFolder = filepath . Join ( Config . AtmosSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
}
2025-08-24 18:19:49 -04:00
if dl_aac {
singerFolder = filepath . Join ( Config . AacSaveFolder , forbiddenNames . ReplaceAllString ( singerFoldername , "_" ) )
}
os . MkdirAll ( singerFolder , os . ModePerm )
playlist . SaveDir = singerFolder
2025-06-24 18:43:41 +08:00
var Quality string
if strings . Contains ( Config . AlbumFolderFormat , "Quality" ) {
if dl_atmos {
2025-08-24 18:19:49 -04:00
Quality = fmt . Sprintf ( "%dKbps" , Config . AtmosMax - 2000 )
2025-06-24 18:43:41 +08:00
} else if dl_aac && Config . AacType == "aac-lc" {
2025-08-24 18:19:49 -04:00
Quality = "256Kbps"
2025-06-24 18:43:41 +08:00
} else {
2025-08-24 18:19:49 -04:00
manifest1 , err := ampapi . GetSongResp ( storefront , meta . Data [ 0 ] . Relationships . Tracks . Data [ 0 ] . ID , playlist . Language , token )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "Failed to get manifest.\n" , err )
} else {
2025-08-24 18:19:49 -04:00
if manifest1 . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls == "" {
2025-06-24 18:43:41 +08:00
Codec = "AAC"
2025-08-24 18:19:49 -04:00
Quality = "256Kbps"
2025-06-24 18:43:41 +08:00
} else {
needCheck := false
if Config . GetM3u8Mode == "all" {
needCheck = true
} else if Config . GetM3u8Mode == "hires" && contains ( meta . Data [ 0 ] . Relationships . Tracks . Data [ 0 ] . Attributes . AudioTraits , "hi-res-lossless" ) {
needCheck = true
}
var EnhancedHls_m3u8 string
if needCheck {
EnhancedHls_m3u8 , _ = checkM3u8 ( meta . Data [ 0 ] . Relationships . Tracks . Data [ 0 ] . ID , "album" )
if strings . HasSuffix ( EnhancedHls_m3u8 , ".m3u8" ) {
2025-08-24 18:19:49 -04:00
manifest1 . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls = EnhancedHls_m3u8
2025-06-24 18:43:41 +08:00
}
}
2025-08-24 18:19:49 -04:00
_ , Quality , err = extractMedia ( manifest1 . Data [ 0 ] . Attributes . ExtendedAssetUrls . EnhancedHls , true )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "Failed to extract quality from manifest.\n" , err )
}
}
}
}
}
stringsToJoin := [ ] string { }
if meta . Data [ 0 ] . Attributes . IsAppleDigitalMaster || meta . Data [ 0 ] . Attributes . IsMasteredForItunes {
if Config . AppleMasterChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . AppleMasterChoice )
}
}
if meta . Data [ 0 ] . Attributes . ContentRating == "explicit" {
if Config . ExplicitChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . ExplicitChoice )
}
}
if meta . Data [ 0 ] . Attributes . ContentRating == "clean" {
if Config . CleanChoice != "" {
stringsToJoin = append ( stringsToJoin , Config . CleanChoice )
}
}
Tag_string := strings . Join ( stringsToJoin , " " )
2025-08-24 18:19:49 -04:00
playlistFolder := strings . NewReplacer (
"{ArtistName}" , "Apple Music" ,
"{PlaylistName}" , LimitString ( meta . Data [ 0 ] . Attributes . Name ) ,
"{PlaylistId}" , playlistId ,
"{Quality}" , Quality ,
"{Codec}" , Codec ,
"{Tag}" , Tag_string ,
) . Replace ( Config . PlaylistFolderFormat )
if strings . HasSuffix ( playlistFolder , "." ) {
playlistFolder = strings . ReplaceAll ( playlistFolder , "." , "" )
}
playlistFolder = strings . TrimSpace ( playlistFolder )
playlistFolderPath := filepath . Join ( singerFolder , forbiddenNames . ReplaceAllString ( playlistFolder , "_" ) )
os . MkdirAll ( playlistFolderPath , os . ModePerm )
playlist . SaveName = playlistFolder
fmt . Println ( playlistFolder )
covPath , err := writeCover ( playlistFolderPath , "cover" , meta . Data [ 0 ] . Attributes . Artwork . URL )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "Failed to write cover." )
}
2025-08-24 18:19:49 -04:00
for i := range playlist . Tracks {
playlist . Tracks [ i ] . CoverPath = covPath
playlist . Tracks [ i ] . SaveDir = playlistFolderPath
playlist . Tracks [ i ] . Codec = Codec
}
2025-06-24 18:43:41 +08:00
if Config . SaveAnimatedArtwork && meta . Data [ 0 ] . Attributes . EditorialVideo . MotionDetailSquare . Video != "" {
fmt . Println ( "Found Animation Artwork." )
motionvideoUrlSquare , err := extractVideo ( meta . Data [ 0 ] . Attributes . EditorialVideo . MotionDetailSquare . Video )
if err != nil {
fmt . Println ( "no motion video square.\n" , err )
} else {
2025-08-24 18:19:49 -04:00
exists , err := fileExists ( filepath . Join ( playlistFolderPath , "square_animated_artwork.mp4" ) )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "Failed to check if animated artwork square exists." )
}
if exists {
fmt . Println ( "Animated artwork square already exists locally." )
} else {
fmt . Println ( "Animation Artwork Square Downloading..." )
2025-08-24 18:19:49 -04:00
cmd := exec . Command ( "ffmpeg" , "-loglevel" , "quiet" , "-y" , "-i" , motionvideoUrlSquare , "-c" , "copy" , filepath . Join ( playlistFolderPath , "square_animated_artwork.mp4" ) )
2025-06-24 18:43:41 +08:00
if err := cmd . Run ( ) ; err != nil {
fmt . Printf ( "animated artwork square dl err: %v\n" , err )
} else {
fmt . Println ( "Animation Artwork Square Downloaded" )
}
}
}
if Config . EmbyAnimatedArtwork {
2025-08-24 18:19:49 -04:00
cmd3 := exec . Command ( "ffmpeg" , "-i" , filepath . Join ( playlistFolderPath , "square_animated_artwork.mp4" ) , "-vf" , "scale=440:-1" , "-r" , "24" , "-f" , "gif" , filepath . Join ( playlistFolderPath , "folder.jpg" ) )
2025-06-24 18:43:41 +08:00
if err := cmd3 . Run ( ) ; err != nil {
fmt . Printf ( "animated artwork square to gif err: %v\n" , err )
}
}
motionvideoUrlTall , err := extractVideo ( meta . Data [ 0 ] . Attributes . EditorialVideo . MotionDetailTall . Video )
if err != nil {
fmt . Println ( "no motion video tall.\n" , err )
} else {
2025-08-24 18:19:49 -04:00
exists , err := fileExists ( filepath . Join ( playlistFolderPath , "tall_animated_artwork.mp4" ) )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "Failed to check if animated artwork tall exists." )
}
if exists {
fmt . Println ( "Animated artwork tall already exists locally." )
} else {
fmt . Println ( "Animation Artwork Tall Downloading..." )
2025-08-24 18:19:49 -04:00
cmd := exec . Command ( "ffmpeg" , "-loglevel" , "quiet" , "-y" , "-i" , motionvideoUrlTall , "-c" , "copy" , filepath . Join ( playlistFolderPath , "tall_animated_artwork.mp4" ) )
2025-06-24 18:43:41 +08:00
if err := cmd . Run ( ) ; err != nil {
fmt . Printf ( "animated artwork tall dl err: %v\n" , err )
} else {
fmt . Println ( "Animation Artwork Tall Downloaded" )
}
}
}
}
trackTotal := len ( meta . Data [ 0 ] . Relationships . Tracks . Data )
arr := make ( [ ] int , trackTotal )
for i := 0 ; i < trackTotal ; i ++ {
arr [ i ] = i + 1
}
2025-08-24 18:19:49 -04:00
var selected [ ] int
2025-06-24 18:43:41 +08:00
if ! dl_select {
selected = arr
} else {
2025-08-24 18:19:49 -04:00
selected = playlist . ShowSelect ( )
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
for i := range playlist . Tracks {
i ++
if isInArray ( okDict [ playlistId ] , i ) {
2025-06-24 18:43:41 +08:00
counter . Total ++
counter . Success ++
continue
}
2025-08-24 18:19:49 -04:00
if isInArray ( selected , i ) {
ripTrack ( & playlist . Tracks [ i - 1 ] , token , mediaUserToken )
2025-06-24 18:43:41 +08:00
}
}
return nil
}
2025-08-24 18:19:49 -04:00
func writeMP4Tags ( track * task . Track , lrc string ) error {
2025-06-24 18:43:41 +08:00
t := & mp4tag . MP4Tags {
2025-08-24 18:19:49 -04:00
Title : track . Resp . Attributes . Name ,
TitleSort : track . Resp . Attributes . Name ,
Artist : track . Resp . Attributes . ArtistName ,
ArtistSort : track . Resp . Attributes . ArtistName ,
2025-06-24 18:43:41 +08:00
Custom : map [ string ] string {
2025-08-24 18:19:49 -04:00
"PERFORMER" : track . Resp . Attributes . ArtistName ,
"RELEASETIME" : track . Resp . Attributes . ReleaseDate ,
"ISRC" : track . Resp . Attributes . Isrc ,
"LABEL" : "" ,
"UPC" : "" ,
2025-06-24 18:43:41 +08:00
} ,
2025-08-24 18:19:49 -04:00
Composer : track . Resp . Attributes . ComposerName ,
ComposerSort : track . Resp . Attributes . ComposerName ,
CustomGenre : track . Resp . Attributes . GenreNames [ 0 ] ,
2025-06-24 18:43:41 +08:00
Lyrics : lrc ,
2025-08-24 18:19:49 -04:00
TrackNumber : int16 ( track . Resp . Attributes . TrackNumber ) ,
DiscNumber : int16 ( track . Resp . Attributes . DiscNumber ) ,
Album : track . Resp . Attributes . AlbumName ,
AlbumSort : track . Resp . Attributes . AlbumName ,
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
if track . PreType == "albums" {
albumID , err := strconv . ParseUint ( track . PreID , 10 , 32 )
2025-06-24 18:43:41 +08:00
if err != nil {
return err
}
t . ItunesAlbumID = int32 ( albumID )
}
2025-08-24 18:19:49 -04:00
if len ( track . Resp . Relationships . Artists . Data ) > 0 {
artistID , err := strconv . ParseUint ( track . Resp . Relationships . Artists . Data [ 0 ] . ID , 10 , 32 )
if err != nil {
return err
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
t . ItunesArtistID = int32 ( artistID )
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
if ( track . PreType == "playlists" || track . PreType == "stations" ) && ! Config . UseSongInfoForPlaylist {
2025-06-24 18:43:41 +08:00
t . DiscNumber = 1
t . DiscTotal = 1
2025-08-24 18:19:49 -04:00
t . TrackNumber = int16 ( track . TaskNum )
t . TrackTotal = int16 ( track . TaskTotal )
t . Album = track . PlaylistData . Attributes . Name
t . AlbumSort = track . PlaylistData . Attributes . Name
t . AlbumArtist = track . PlaylistData . Attributes . ArtistName
t . AlbumArtistSort = track . PlaylistData . Attributes . ArtistName
} else if ( track . PreType == "playlists" || track . PreType == "stations" ) && Config . UseSongInfoForPlaylist {
t . DiscTotal = int16 ( track . DiscTotal )
t . TrackTotal = int16 ( track . AlbumData . Attributes . TrackCount )
t . AlbumArtist = track . AlbumData . Attributes . ArtistName
t . AlbumArtistSort = track . AlbumData . Attributes . ArtistName
t . Custom [ "UPC" ] = track . AlbumData . Attributes . Upc
t . Custom [ "LABEL" ] = track . AlbumData . Attributes . RecordLabel
t . Date = track . AlbumData . Attributes . ReleaseDate
t . Copyright = track . AlbumData . Attributes . Copyright
t . Publisher = track . AlbumData . Attributes . RecordLabel
2025-06-24 18:43:41 +08:00
} else {
2025-08-24 18:19:49 -04:00
t . DiscTotal = int16 ( track . DiscTotal )
t . TrackTotal = int16 ( track . AlbumData . Attributes . TrackCount )
t . AlbumArtist = track . AlbumData . Attributes . ArtistName
t . AlbumArtistSort = track . AlbumData . Attributes . ArtistName
t . Custom [ "UPC" ] = track . AlbumData . Attributes . Upc
t . Date = track . AlbumData . Attributes . ReleaseDate
t . Copyright = track . AlbumData . Attributes . Copyright
t . Publisher = track . AlbumData . Attributes . RecordLabel
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
if track . Resp . Attributes . ContentRating == "explicit" {
2025-06-24 18:43:41 +08:00
t . ItunesAdvisory = mp4tag . ItunesAdvisoryExplicit
2025-08-24 18:19:49 -04:00
} else if track . Resp . Attributes . ContentRating == "clean" {
2025-06-24 18:43:41 +08:00
t . ItunesAdvisory = mp4tag . ItunesAdvisoryClean
} else {
t . ItunesAdvisory = mp4tag . ItunesAdvisoryNone
}
2025-08-24 18:19:49 -04:00
mp4 , err := mp4tag . Open ( track . SavePath )
2025-06-24 18:43:41 +08:00
if err != nil {
return err
}
defer mp4 . Close ( )
err = mp4 . Write ( t , [ ] string { } )
if err != nil {
return err
}
return nil
}
func main ( ) {
err := loadConfig ( )
if err != nil {
fmt . Printf ( "load Config failed: %v" , err )
return
}
2025-08-24 18:19:49 -04:00
token , err := ampapi . GetToken ( )
2025-06-24 18:43:41 +08:00
if err != nil {
if Config . AuthorizationToken != "" && Config . AuthorizationToken != "your-authorization-token" {
token = strings . Replace ( Config . AuthorizationToken , "Bearer " , "" , - 1 )
} else {
fmt . Println ( "Failed to get token." )
return
}
}
2025-08-24 18:19:49 -04:00
var search_type string
pflag . StringVar ( & search_type , "search" , "" , "Search for 'album', 'song', or 'artist'. Provide query after flags." )
2025-06-24 18:43:41 +08:00
pflag . BoolVar ( & dl_atmos , "atmos" , false , "Enable atmos download mode" )
pflag . BoolVar ( & dl_aac , "aac" , false , "Enable adm-aac download mode" )
pflag . BoolVar ( & dl_select , "select" , false , "Enable selective download" )
pflag . BoolVar ( & dl_song , "song" , false , "Enable single song download mode" )
pflag . BoolVar ( & artist_select , "all-album" , false , "Download all artist albums" )
pflag . BoolVar ( & debug_mode , "debug" , false , "Enable debug mode to show audio quality information" )
alac_max = pflag . Int ( "alac-max" , Config . AlacMax , "Specify the max quality for download alac" )
atmos_max = pflag . Int ( "atmos-max" , Config . AtmosMax , "Specify the max quality for download atmos" )
aac_type = pflag . String ( "aac-type" , Config . AacType , "Select AAC type, aac aac-binaural aac-downmix" )
mv_audio_type = pflag . String ( "mv-audio-type" , Config . MVAudioType , "Select MV audio type, atmos ac3 aac" )
mv_max = pflag . Int ( "mv-max" , Config . MVMax , "Specify the max quality for download MV" )
pflag . Usage = func ( ) {
2025-08-24 18:19:49 -04:00
fmt . Fprintf ( os . Stderr , "Usage: %s [options] [url1 url2 ...]\n" , "[main | main.exe | go run main.go]" )
fmt . Fprintf ( os . Stderr , "Search Usage: %s --search [album|song|artist] [query]\n" , "[main | main.exe | go run main.go]" )
fmt . Println ( "\nOptions:" )
2025-06-24 18:43:41 +08:00
pflag . PrintDefaults ( )
}
pflag . Parse ( )
Config . AlacMax = * alac_max
Config . AtmosMax = * atmos_max
Config . AacType = * aac_type
Config . MVAudioType = * mv_audio_type
Config . MVMax = * mv_max
args := pflag . Args ( )
2025-08-24 18:19:49 -04:00
if search_type != "" {
if len ( args ) == 0 {
fmt . Println ( "Error: --search flag requires a query." )
pflag . Usage ( )
return
}
selectedUrl , err := handleSearch ( search_type , args , token )
if err != nil {
fmt . Printf ( "\nSearch process failed: %v\n" , err )
return
}
if selectedUrl == "" {
fmt . Println ( "\nExiting." )
return
}
os . Args = [ ] string { selectedUrl }
} else {
if len ( args ) == 0 {
fmt . Println ( "No URLs provided. Please provide at least one URL." )
pflag . Usage ( )
return
}
os . Args = args
2025-06-24 18:43:41 +08:00
}
2025-08-24 18:19:49 -04:00
2025-06-24 18:43:41 +08:00
if strings . Contains ( os . Args [ 0 ] , "/artist/" ) {
urlArtistName , urlArtistID , err := getUrlArtistName ( os . Args [ 0 ] , token )
if err != nil {
fmt . Println ( "Failed to get artistname." )
return
}
Config . ArtistFolderFormat = strings . NewReplacer (
"{UrlArtistName}" , LimitString ( urlArtistName ) ,
"{ArtistId}" , urlArtistID ,
) . Replace ( Config . ArtistFolderFormat )
albumArgs , err := checkArtist ( os . Args [ 0 ] , token , "albums" )
if err != nil {
fmt . Println ( "Failed to get artist albums." )
return
}
mvArgs , err := checkArtist ( os . Args [ 0 ] , token , "music-videos" )
if err != nil {
fmt . Println ( "Failed to get artist music-videos." )
}
os . Args = append ( albumArgs , mvArgs ... )
}
albumTotal := len ( os . Args )
for {
for albumNum , urlRaw := range os . Args {
2025-08-24 18:19:49 -04:00
fmt . Printf ( "Queue %d of %d: " , albumNum + 1 , albumTotal )
2025-06-24 18:43:41 +08:00
var storefront , albumId string
2025-08-24 18:19:49 -04:00
2025-06-24 18:43:41 +08:00
if strings . Contains ( urlRaw , "/music-video/" ) {
2025-08-24 18:19:49 -04:00
fmt . Println ( "Music Video" )
2025-06-24 18:43:41 +08:00
if debug_mode {
continue
}
counter . Total ++
if len ( Config . MediaUserToken ) <= 50 {
2025-08-24 18:19:49 -04:00
fmt . Println ( ": meida-user-token is not set, skip MV dl" )
2025-06-24 18:43:41 +08:00
counter . Success ++
continue
}
if _ , err := exec . LookPath ( "mp4decrypt" ) ; err != nil {
2025-08-24 18:19:49 -04:00
fmt . Println ( ": mp4decrypt is not found, skip MV dl" )
2025-06-24 18:43:41 +08:00
counter . Success ++
continue
}
mvSaveDir := strings . NewReplacer (
"{ArtistName}" , "" ,
"{UrlArtistName}" , "" ,
"{ArtistId}" , "" ,
) . Replace ( Config . ArtistFolderFormat )
if mvSaveDir != "" {
mvSaveDir = filepath . Join ( Config . AlacSaveFolder , forbiddenNames . ReplaceAllString ( mvSaveDir , "_" ) )
} else {
mvSaveDir = Config . AlacSaveFolder
}
storefront , albumId = checkUrlMv ( urlRaw )
err := mvDownloader ( albumId , mvSaveDir , token , storefront , Config . MediaUserToken , nil )
if err != nil {
fmt . Println ( "\u26A0 Failed to dl MV:" , err )
counter . Error ++
continue
}
counter . Success ++
continue
}
if strings . Contains ( urlRaw , "/song/" ) {
2025-08-24 18:19:49 -04:00
fmt . Printf ( "Song->" )
storefront , songId := checkUrlSong ( urlRaw )
if storefront == "" || songId == "" {
fmt . Println ( "Invalid song URL format." )
continue
}
err := ripSong ( songId , token , storefront , Config . MediaUserToken )
2025-06-24 18:43:41 +08:00
if err != nil {
2025-08-24 18:19:49 -04:00
fmt . Println ( "Failed to rip song:" , err )
2025-06-24 18:43:41 +08:00
}
continue
}
parse , err := url . Parse ( urlRaw )
if err != nil {
log . Fatalf ( "Invalid URL: %v" , err )
}
var urlArg_i = parse . Query ( ) . Get ( "i" )
2025-08-24 18:19:49 -04:00
if strings . Contains ( urlRaw , "/album/" ) {
fmt . Println ( "Album" )
storefront , albumId = checkUrl ( urlRaw )
err := ripAlbum ( albumId , token , storefront , Config . MediaUserToken , urlArg_i )
if err != nil {
fmt . Println ( "Failed to rip album:" , err )
}
} else if strings . Contains ( urlRaw , "/playlist/" ) {
fmt . Println ( "Playlist" )
storefront , albumId = checkUrlPlaylist ( urlRaw )
err := ripPlaylist ( albumId , token , storefront , Config . MediaUserToken )
if err != nil {
fmt . Println ( "Failed to rip playlist:" , err )
}
} else if strings . Contains ( urlRaw , "/station/" ) {
fmt . Printf ( "Station" )
storefront , albumId = checkUrlStation ( urlRaw )
if len ( Config . MediaUserToken ) <= 50 {
fmt . Println ( ": meida-user-token is not set, skip station dl" )
continue
}
err := ripStation ( albumId , token , storefront , Config . MediaUserToken )
if err != nil {
fmt . Println ( "Failed to rip station:" , err )
}
} else {
fmt . Println ( "Invalid type" )
2025-06-24 18:43:41 +08:00
}
}
fmt . Printf ( "======= [\u2714 ] Completed: %d/%d | [\u26A0 ] Warnings: %d | [\u2716 ] Errors: %d =======\n" , counter . Success , counter . Total , counter . Unavailable + counter . NotSong , counter . Error )
if counter . Error == 0 {
break
}
fmt . Println ( "Error detected, press Enter to try again..." )
fmt . Scanln ( )
fmt . Println ( "Start trying again..." )
counter = structs . Counter { }
}
}
2025-08-24 18:19:49 -04:00
func mvDownloader ( adamID string , saveDir string , token string , storefront string , mediaUserToken string , track * task . Track ) error {
MVInfo , err := ampapi . GetMusicVideoResp ( storefront , adamID , Config . Language , token )
2025-06-24 18:43:41 +08:00
if err != nil {
fmt . Println ( "\u26A0 Failed to get MV manifest:" , err )
return nil
}
if strings . HasSuffix ( saveDir , "." ) {
saveDir = strings . ReplaceAll ( saveDir , "." , "" )
}
saveDir = strings . TrimSpace ( saveDir )
vidPath := filepath . Join ( saveDir , fmt . Sprintf ( "%s_vid.mp4" , adamID ) )
audPath := filepath . Join ( saveDir , fmt . Sprintf ( "%s_aud.mp4" , adamID ) )
mvSaveName := fmt . Sprintf ( "%s (%s)" , MVInfo . Data [ 0 ] . Attributes . Name , adamID )
2025-08-24 18:19:49 -04:00
if track != nil {
mvSaveName = fmt . Sprintf ( "%02d. %s" , track . TaskNum , MVInfo . Data [ 0 ] . Attributes . Name )
2025-06-24 18:43:41 +08:00
}
mvOutPath := filepath . Join ( saveDir , fmt . Sprintf ( "%s.mp4" , forbiddenNames . ReplaceAllString ( mvSaveName , "_" ) ) )
fmt . Println ( MVInfo . Data [ 0 ] . Attributes . Name )
exists , _ := fileExists ( mvOutPath )
if exists {
fmt . Println ( "MV already exists locally." )
return nil
}
2025-09-21 01:24:53 +08:00
mvm3u8url , _ , _ , _ := runv3 . GetWebplayback ( adamID , token , mediaUserToken , true )
2025-06-24 18:43:41 +08:00
if mvm3u8url == "" {
return errors . New ( "media-user-token may wrong or expired" )
}
os . MkdirAll ( saveDir , os . ModePerm )
videom3u8url , _ := extractVideo ( mvm3u8url )
2025-09-21 01:24:53 +08:00
videokeyAndUrls , _ := runv3 . Run ( adamID , videom3u8url , token , mediaUserToken , true , "" )
2025-06-24 18:43:41 +08:00
_ = runv3 . ExtMvData ( videokeyAndUrls , vidPath )
2025-10-18 17:36:18 +08:00
defer os . Remove ( vidPath )
2025-06-24 18:43:41 +08:00
audiom3u8url , _ := extractMvAudio ( mvm3u8url )
2025-09-21 01:24:53 +08:00
audiokeyAndUrls , _ := runv3 . Run ( adamID , audiom3u8url , token , mediaUserToken , true , "" )
2025-06-24 18:43:41 +08:00
_ = runv3 . ExtMvData ( audiokeyAndUrls , audPath )
2025-10-18 17:44:13 +08:00
defer os . Remove ( audPath )
2025-06-24 18:43:41 +08:00
tags := [ ] string {
"tool=" ,
fmt . Sprintf ( "artist=%s" , MVInfo . Data [ 0 ] . Attributes . ArtistName ) ,
fmt . Sprintf ( "title=%s" , MVInfo . Data [ 0 ] . Attributes . Name ) ,
fmt . Sprintf ( "genre=%s" , MVInfo . Data [ 0 ] . Attributes . GenreNames [ 0 ] ) ,
fmt . Sprintf ( "created=%s" , MVInfo . Data [ 0 ] . Attributes . ReleaseDate ) ,
fmt . Sprintf ( "ISRC=%s" , MVInfo . Data [ 0 ] . Attributes . Isrc ) ,
}
if MVInfo . Data [ 0 ] . Attributes . ContentRating == "explicit" {
tags = append ( tags , "rating=1" )
} else if MVInfo . Data [ 0 ] . Attributes . ContentRating == "clean" {
tags = append ( tags , "rating=2" )
} else {
tags = append ( tags , "rating=0" )
}
2025-08-24 18:19:49 -04:00
if track != nil {
if track . PreType == "playlists" && ! Config . UseSongInfoForPlaylist {
2025-06-24 18:43:41 +08:00
tags = append ( tags , "disk=1/1" )
2025-08-24 18:19:49 -04:00
tags = append ( tags , fmt . Sprintf ( "album=%s" , track . PlaylistData . Attributes . Name ) )
tags = append ( tags , fmt . Sprintf ( "track=%d" , track . TaskNum ) )
tags = append ( tags , fmt . Sprintf ( "tracknum=%d/%d" , track . TaskNum , track . TaskTotal ) )
tags = append ( tags , fmt . Sprintf ( "album_artist=%s" , track . PlaylistData . Attributes . ArtistName ) )
tags = append ( tags , fmt . Sprintf ( "performer=%s" , track . Resp . Attributes . ArtistName ) )
} else if track . PreType == "playlists" && Config . UseSongInfoForPlaylist {
tags = append ( tags , fmt . Sprintf ( "album=%s" , track . AlbumData . Attributes . Name ) )
tags = append ( tags , fmt . Sprintf ( "disk=%d/%d" , track . Resp . Attributes . DiscNumber , track . DiscTotal ) )
tags = append ( tags , fmt . Sprintf ( "track=%d" , track . Resp . Attributes . TrackNumber ) )
tags = append ( tags , fmt . Sprintf ( "tracknum=%d/%d" , track . Resp . Attributes . TrackNumber , track . AlbumData . Attributes . TrackCount ) )
tags = append ( tags , fmt . Sprintf ( "album_artist=%s" , track . AlbumData . Attributes . ArtistName ) )
tags = append ( tags , fmt . Sprintf ( "performer=%s" , track . Resp . Attributes . ArtistName ) )
tags = append ( tags , fmt . Sprintf ( "copyright=%s" , track . AlbumData . Attributes . Copyright ) )
tags = append ( tags , fmt . Sprintf ( "UPC=%s" , track . AlbumData . Attributes . Upc ) )
2025-06-24 18:43:41 +08:00
} else {
2025-08-24 18:19:49 -04:00
tags = append ( tags , fmt . Sprintf ( "album=%s" , track . AlbumData . Attributes . Name ) )
tags = append ( tags , fmt . Sprintf ( "disk=%d/%d" , track . Resp . Attributes . DiscNumber , track . DiscTotal ) )
tags = append ( tags , fmt . Sprintf ( "track=%d" , track . Resp . Attributes . TrackNumber ) )
tags = append ( tags , fmt . Sprintf ( "tracknum=%d/%d" , track . Resp . Attributes . TrackNumber , track . AlbumData . Attributes . TrackCount ) )
tags = append ( tags , fmt . Sprintf ( "album_artist=%s" , track . AlbumData . Attributes . ArtistName ) )
tags = append ( tags , fmt . Sprintf ( "performer=%s" , track . Resp . Attributes . ArtistName ) )
tags = append ( tags , fmt . Sprintf ( "copyright=%s" , track . AlbumData . Attributes . Copyright ) )
tags = append ( tags , fmt . Sprintf ( "UPC=%s" , track . AlbumData . Attributes . Upc ) )
2025-06-24 18:43:41 +08:00
}
} else {
tags = append ( tags , fmt . Sprintf ( "album=%s" , MVInfo . Data [ 0 ] . Attributes . AlbumName ) )
tags = append ( tags , fmt . Sprintf ( "disk=%d" , MVInfo . Data [ 0 ] . Attributes . DiscNumber ) )
tags = append ( tags , fmt . Sprintf ( "track=%d" , MVInfo . Data [ 0 ] . Attributes . TrackNumber ) )
tags = append ( tags , fmt . Sprintf ( "tracknum=%d" , MVInfo . Data [ 0 ] . Attributes . TrackNumber ) )
tags = append ( tags , fmt . Sprintf ( "performer=%s" , MVInfo . Data [ 0 ] . Attributes . ArtistName ) )
}
var covPath string
if true {
thumbURL := MVInfo . Data [ 0 ] . Attributes . Artwork . URL
baseThumbName := forbiddenNames . ReplaceAllString ( mvSaveName , "_" ) + "_thumbnail"
covPath , err = writeCover ( saveDir , baseThumbName , thumbURL )
if err != nil {
fmt . Println ( "Failed to save MV thumbnail:" , err )
} else {
tags = append ( tags , fmt . Sprintf ( "cover=%s" , covPath ) )
}
}
2025-10-18 17:36:18 +08:00
defer os . Remove ( covPath )
2025-06-24 18:43:41 +08:00
tagsString := strings . Join ( tags , ":" )
muxCmd := exec . Command ( "MP4Box" , "-itags" , tagsString , "-quiet" , "-add" , vidPath , "-add" , audPath , "-keep-utc" , "-new" , mvOutPath )
fmt . Printf ( "MV Remuxing..." )
if err := muxCmd . Run ( ) ; err != nil {
fmt . Printf ( "MV mux failed: %v\n" , err )
return err
}
fmt . Printf ( "\rMV Remuxed. \n" )
return nil
}
func extractMvAudio ( c string ) ( string , error ) {
MediaUrl , err := url . Parse ( c )
if err != nil {
return "" , err
}
resp , err := http . Get ( c )
if err != nil {
return "" , err
}
defer resp . Body . Close ( )
if resp . StatusCode != http . StatusOK {
return "" , errors . New ( resp . Status )
}
body , err := io . ReadAll ( resp . Body )
if err != nil {
return "" , err
}
audioString := string ( body )
from , listType , err := m3u8 . DecodeFrom ( strings . NewReader ( audioString ) , true )
if err != nil || listType != m3u8 . MASTER {
return "" , errors . New ( "m3u8 not of media type" )
}
audio := from . ( * m3u8 . MasterPlaylist )
var audioPriority = [ ] string { "audio-atmos" , "audio-ac3" , "audio-stereo-256" }
if Config . MVAudioType == "ac3" {
audioPriority = [ ] string { "audio-ac3" , "audio-stereo-256" }
} else if Config . MVAudioType == "aac" {
audioPriority = [ ] string { "audio-stereo-256" }
}
re := regexp . MustCompile ( ` _gr(\d+)_ ` )
type AudioStream struct {
URL string
Rank int
GroupID string
}
var audioStreams [ ] AudioStream
for _ , variant := range audio . Variants {
for _ , audiov := range variant . Alternatives {
if audiov . URI != "" {
for _ , priority := range audioPriority {
if audiov . GroupId == priority {
matches := re . FindStringSubmatch ( audiov . URI )
if len ( matches ) == 2 {
var rank int
fmt . Sscanf ( matches [ 1 ] , "%d" , & rank )
streamUrl , _ := MediaUrl . Parse ( audiov . URI )
audioStreams = append ( audioStreams , AudioStream {
URL : streamUrl . String ( ) ,
Rank : rank ,
GroupID : audiov . GroupId ,
} )
}
}
}
}
}
}
if len ( audioStreams ) == 0 {
return "" , errors . New ( "no suitable audio stream found" )
}
sort . Slice ( audioStreams , func ( i , j int ) bool {
return audioStreams [ i ] . Rank > audioStreams [ j ] . Rank
} )
fmt . Println ( "Audio: " + audioStreams [ 0 ] . GroupID )
return audioStreams [ 0 ] . URL , nil
}
func checkM3u8 ( b string , f string ) ( string , error ) {
var EnhancedHls string
if Config . GetM3u8FromDevice {
adamID := b
conn , err := net . Dial ( "tcp" , Config . GetM3u8Port )
if err != nil {
fmt . Println ( "Error connecting to device:" , err )
return "none" , err
}
defer conn . Close ( )
if f == "song" {
fmt . Println ( "Connected to device" )
}
adamIDBuffer := [ ] byte ( adamID )
lengthBuffer := [ ] byte { byte ( len ( adamIDBuffer ) ) }
_ , err = conn . Write ( lengthBuffer )
if err != nil {
fmt . Println ( "Error writing length to device:" , err )
return "none" , err
}
_ , err = conn . Write ( adamIDBuffer )
if err != nil {
fmt . Println ( "Error writing adamID to device:" , err )
return "none" , err
}
response , err := bufio . NewReader ( conn ) . ReadBytes ( '\n' )
if err != nil {
fmt . Println ( "Error reading response from device:" , err )
return "none" , err
}
response = bytes . TrimSpace ( response )
if len ( response ) > 0 {
if f == "song" {
fmt . Println ( "Received URL:" , string ( response ) )
}
EnhancedHls = string ( response )
} else {
fmt . Println ( "Received an empty response" )
}
}
return EnhancedHls , nil
}
func formatAvailability ( available bool , quality string ) string {
if ! available {
return "Not Available"
}
return quality
}
func extractMedia ( b string , more_mode bool ) ( string , string , error ) {
masterUrl , err := url . Parse ( b )
if err != nil {
return "" , "" , err
}
resp , err := http . Get ( b )
if err != nil {
return "" , "" , err
}
defer resp . Body . Close ( )
if resp . StatusCode != http . StatusOK {
return "" , "" , errors . New ( resp . Status )
}
body , err := io . ReadAll ( resp . Body )
if err != nil {
return "" , "" , err
}
masterString := string ( body )
from , listType , err := m3u8 . DecodeFrom ( strings . NewReader ( masterString ) , true )
if err != nil || listType != m3u8 . MASTER {
return "" , "" , errors . New ( "m3u8 not of master type" )
}
master := from . ( * m3u8 . MasterPlaylist )
var streamUrl * url . URL
sort . Slice ( master . Variants , func ( i , j int ) bool {
return master . Variants [ i ] . AverageBandwidth > master . Variants [ j ] . AverageBandwidth
} )
if debug_mode && more_mode {
fmt . Println ( "\nDebug: All Available Variants:" )
var data [ ] [ ] string
for _ , variant := range master . Variants {
data = append ( data , [ ] string { variant . Codecs , variant . Audio , fmt . Sprint ( variant . Bandwidth ) } )
}
table := tablewriter . NewWriter ( os . Stdout )
table . SetHeader ( [ ] string { "Codec" , "Audio" , "Bandwidth" } )
table . SetAutoMergeCells ( true )
table . SetRowLine ( true )
table . AppendBulk ( data )
table . Render ( )
var hasAAC , hasLossless , hasHiRes , hasAtmos , hasDolbyAudio bool
var aacQuality , losslessQuality , hiResQuality , atmosQuality , dolbyAudioQuality string
for _ , variant := range master . Variants {
if variant . Codecs == "mp4a.40.2" { // AAC
hasAAC = true
split := strings . Split ( variant . Audio , "-" )
if len ( split ) >= 3 {
bitrate , _ := strconv . Atoi ( split [ 2 ] )
currentBitrate := 0
if aacQuality != "" {
current := strings . Split ( aacQuality , " | " ) [ 2 ]
current = strings . Split ( current , " " ) [ 0 ]
currentBitrate , _ = strconv . Atoi ( current )
}
if bitrate > currentBitrate {
2025-08-24 18:19:49 -04:00
aacQuality = fmt . Sprintf ( "AAC | 2 Channel | %d Kbps" , bitrate )
2025-06-24 18:43:41 +08:00
}
}
} else if variant . Codecs == "ec-3" && strings . Contains ( variant . Audio , "atmos" ) { // Dolby Atmos
hasAtmos = true
split := strings . Split ( variant . Audio , "-" )
if len ( split ) > 0 {
bitrateStr := split [ len ( split ) - 1 ]
if len ( bitrateStr ) == 4 && bitrateStr [ 0 ] == '2' {
bitrateStr = bitrateStr [ 1 : ]
}
bitrate , _ := strconv . Atoi ( bitrateStr )
currentBitrate := 0
if atmosQuality != "" {
current := strings . Split ( strings . Split ( atmosQuality , " | " ) [ 2 ] , " " ) [ 0 ]
currentBitrate , _ = strconv . Atoi ( current )
}
if bitrate > currentBitrate {
2025-08-24 18:19:49 -04:00
atmosQuality = fmt . Sprintf ( "E-AC-3 | 16 Channel | %d Kbps" , bitrate )
2025-06-24 18:43:41 +08:00
}
}
} else if variant . Codecs == "alac" { // ALAC (Lossless or Hi-Res)
split := strings . Split ( variant . Audio , "-" )
if len ( split ) >= 3 {
bitDepth := split [ len ( split ) - 1 ]
sampleRate := split [ len ( split ) - 2 ]
sampleRateInt , _ := strconv . Atoi ( sampleRate )
if sampleRateInt > 48000 { // Hi-Res
hasHiRes = true
hiResQuality = fmt . Sprintf ( "ALAC | 2 Channel | %s-bit/%d kHz" , bitDepth , sampleRateInt / 1000 )
} else { // Standard Lossless
hasLossless = true
losslessQuality = fmt . Sprintf ( "ALAC | 2 Channel | %s-bit/%d kHz" , bitDepth , sampleRateInt / 1000 )
}
}
} else if variant . Codecs == "ac-3" { // Dolby Audio
hasDolbyAudio = true
split := strings . Split ( variant . Audio , "-" )
if len ( split ) > 0 {
bitrate , _ := strconv . Atoi ( split [ len ( split ) - 1 ] )
2025-08-24 18:19:49 -04:00
dolbyAudioQuality = fmt . Sprintf ( "AC-3 | 16 Channel | %d Kbps" , bitrate )
2025-06-24 18:43:41 +08:00
}
}
}
fmt . Println ( "Available Audio Formats:" )
fmt . Println ( "------------------------" )
fmt . Printf ( "AAC : %s\n" , formatAvailability ( hasAAC , aacQuality ) )
fmt . Printf ( "Lossless : %s\n" , formatAvailability ( hasLossless , losslessQuality ) )
fmt . Printf ( "Hi-Res Lossless : %s\n" , formatAvailability ( hasHiRes , hiResQuality ) )
fmt . Printf ( "Dolby Atmos : %s\n" , formatAvailability ( hasAtmos , atmosQuality ) )
fmt . Printf ( "Dolby Audio : %s\n" , formatAvailability ( hasDolbyAudio , dolbyAudioQuality ) )
fmt . Println ( "------------------------" )
return "" , "" , nil
}
var Quality string
for _ , variant := range master . Variants {
if dl_atmos {
if variant . Codecs == "ec-3" && strings . Contains ( variant . Audio , "atmos" ) {
if debug_mode && ! more_mode {
2025-08-24 18:19:49 -04:00
fmt . Printf ( "Debug: Found Dolby Atmos variant - %s (Bitrate: %d Kbps)\n" ,
2025-06-24 18:43:41 +08:00
variant . Audio , variant . Bandwidth / 1000 )
}
split := strings . Split ( variant . Audio , "-" )
length := len ( split )
length_int , err := strconv . Atoi ( split [ length - 1 ] )
if err != nil {
return "" , "" , err
}
if length_int <= Config . AtmosMax {
if ! debug_mode && ! more_mode {
fmt . Printf ( "%s\n" , variant . Audio )
}
streamUrlTemp , err := masterUrl . Parse ( variant . URI )
if err != nil {
return "" , "" , err
}
streamUrl = streamUrlTemp
2025-08-24 18:19:49 -04:00
Quality = fmt . Sprintf ( "%s Kbps" , split [ len ( split ) - 1 ] )
2025-06-24 18:43:41 +08:00
break
}
} else if variant . Codecs == "ac-3" { // Add Dolby Audio support
if debug_mode && ! more_mode {
2025-08-24 18:19:49 -04:00
fmt . Printf ( "Debug: Found Dolby Audio variant - %s (Bitrate: %d Kbps)\n" ,
2025-06-24 18:43:41 +08:00
variant . Audio , variant . Bandwidth / 1000 )
}
streamUrlTemp , err := masterUrl . Parse ( variant . URI )
if err != nil {
return "" , "" , err
}
streamUrl = streamUrlTemp
split := strings . Split ( variant . Audio , "-" )
2025-08-24 18:19:49 -04:00
Quality = fmt . Sprintf ( "%s Kbps" , split [ len ( split ) - 1 ] )
2025-06-24 18:43:41 +08:00
break
}
} else if dl_aac {
if variant . Codecs == "mp4a.40.2" {
if debug_mode && ! more_mode {
fmt . Printf ( "Debug: Found AAC variant - %s (Bitrate: %d)\n" , variant . Audio , variant . Bandwidth )
}
aacregex := regexp . MustCompile ( ` audio-stereo-\d+ ` )
replaced := aacregex . ReplaceAllString ( variant . Audio , "aac" )
if replaced == Config . AacType {
if ! debug_mode && ! more_mode {
fmt . Printf ( "%s\n" , variant . Audio )
}
streamUrlTemp , err := masterUrl . Parse ( variant . URI )
if err != nil {
panic ( err )
}
streamUrl = streamUrlTemp
split := strings . Split ( variant . Audio , "-" )
2025-08-24 18:19:49 -04:00
Quality = fmt . Sprintf ( "%s Kbps" , split [ 2 ] )
2025-06-24 18:43:41 +08:00
break
}
}
} else {
if variant . Codecs == "alac" {
split := strings . Split ( variant . Audio , "-" )
length := len ( split )
length_int , err := strconv . Atoi ( split [ length - 2 ] )
if err != nil {
return "" , "" , err
}
if length_int <= Config . AlacMax {
if ! debug_mode && ! more_mode {
fmt . Printf ( "%s-bit / %s Hz\n" , split [ length - 1 ] , split [ length - 2 ] )
}
streamUrlTemp , err := masterUrl . Parse ( variant . URI )
if err != nil {
panic ( err )
}
streamUrl = streamUrlTemp
KHZ := float64 ( length_int ) / 1000.0
Quality = fmt . Sprintf ( "%sB-%.1fkHz" , split [ length - 1 ] , KHZ )
break
}
}
}
}
if streamUrl == nil {
return "" , "" , errors . New ( "no codec found" )
}
return streamUrl . String ( ) , Quality , nil
}
func extractVideo ( c string ) ( string , error ) {
MediaUrl , err := url . Parse ( c )
if err != nil {
return "" , err
}
resp , err := http . Get ( c )
if err != nil {
return "" , err
}
defer resp . Body . Close ( )
if resp . StatusCode != http . StatusOK {
return "" , errors . New ( resp . Status )
}
body , err := io . ReadAll ( resp . Body )
if err != nil {
return "" , err
}
videoString := string ( body )
from , listType , err := m3u8 . DecodeFrom ( strings . NewReader ( videoString ) , true )
if err != nil || listType != m3u8 . MASTER {
return "" , errors . New ( "m3u8 not of media type" )
}
video := from . ( * m3u8 . MasterPlaylist )
re := regexp . MustCompile ( ` _(\d+)x(\d+) ` )
var streamUrl * url . URL
sort . Slice ( video . Variants , func ( i , j int ) bool {
return video . Variants [ i ] . AverageBandwidth > video . Variants [ j ] . AverageBandwidth
} )
maxHeight := Config . MVMax
for _ , variant := range video . Variants {
matches := re . FindStringSubmatch ( variant . URI )
if len ( matches ) == 3 {
height := matches [ 2 ]
var h int
_ , err := fmt . Sscanf ( height , "%d" , & h )
if err != nil {
continue
}
if h <= maxHeight {
streamUrl , err = MediaUrl . Parse ( variant . URI )
if err != nil {
return "" , err
}
fmt . Println ( "Video: " + variant . Resolution + "-" + variant . VideoRange )
break
}
}
}
if streamUrl == nil {
return "" , errors . New ( "no suitable video stream found" )
}
return streamUrl . String ( ) , nil
}
2025-08-24 18:19:49 -04:00
func ripSong ( songId string , token string , storefront string , mediaUserToken string ) error {
// Get song info to find album ID
manifest , err := ampapi . GetSongResp ( storefront , songId , Config . Language , token )
2025-06-24 18:43:41 +08:00
if err != nil {
2025-08-24 18:19:49 -04:00
fmt . Println ( "Failed to get song response." )
return err
2025-06-24 18:43:41 +08:00
}
2025-08-28 19:22:32 +08:00
2025-08-24 18:19:49 -04:00
songData := manifest . Data [ 0 ]
albumId := songData . Relationships . Albums . Data [ 0 ] . ID
2025-08-28 19:22:32 +08:00
2025-08-24 18:19:49 -04:00
// Use album approach but only download the specific song
dl_song = true
err = ripAlbum ( albumId , token , storefront , mediaUserToken , songId )
2025-06-24 18:43:41 +08:00
if err != nil {
2025-08-24 18:19:49 -04:00
fmt . Println ( "Failed to rip song:" , err )
return err
2025-06-24 18:43:41 +08:00
}
2025-08-28 19:22:32 +08:00
2025-08-24 18:19:49 -04:00
return nil
2025-06-24 18:43:41 +08:00
}