diff --git a/app.go b/app.go index 1853ca6..eeda379 100644 --- a/app.go +++ b/app.go @@ -1157,6 +1157,21 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul return backend.ConvertAudio(backendReq) } +type ResampleAudioRequest struct { + InputFiles []string `json:"input_files"` + SampleRate string `json:"sample_rate"` + BitDepth string `json:"bit_depth"` +} + +func (a *App) ResampleAudio(req ResampleAudioRequest) ([]backend.ResampleResult, error) { + backendReq := backend.ResampleRequest{ + InputFiles: req.InputFiles, + SampleRate: req.SampleRate, + BitDepth: req.BitDepth, + } + return backend.ResampleAudio(backendReq) +} + func (a *App) SelectAudioFiles() ([]string, error) { files, err := backend.SelectMultipleFiles(a.ctx) if err != nil { @@ -1165,6 +1180,10 @@ func (a *App) SelectAudioFiles() ([]string, error) { return files, nil } +func (a *App) GetFlacInfoBatch(paths []string) []backend.FlacInfo { + return backend.GetFlacInfoBatch(paths) +} + func (a *App) GetFileSizes(files []string) map[string]int64 { return backend.GetFileSizes(files) } diff --git a/backend/resample.go b/backend/resample.go new file mode 100644 index 0000000..3913f4f --- /dev/null +++ b/backend/resample.go @@ -0,0 +1,226 @@ +package backend + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// FlacInfo holds basic audio properties of a FLAC file. +type FlacInfo struct { + Path string `json:"path"` + SampleRate uint32 `json:"sample_rate"` // e.g. 44100 + BitsPerSample uint8 `json:"bits_per_sample"` // e.g. 16, 24 +} + +// GetFlacInfoBatch reads sample rate and bit depth for multiple files in parallel. +func GetFlacInfoBatch(paths []string) []FlacInfo { + results := make([]FlacInfo, len(paths)) + var wg sync.WaitGroup + + for i, path := range paths { + wg.Add(1) + go func(idx int, p string) { + defer wg.Done() + info := FlacInfo{Path: p} + + ffprobePath, err := GetFFprobePath() + if err != nil { + results[idx] = info + return + } + + args := []string{ + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample", + "-of", "default=noprint_wrappers=0", + p, + } + cmd := exec.Command(ffprobePath, args...) + setHideWindow(cmd) + out, err := cmd.CombinedOutput() + if err != nil { + results[idx] = info + return + } + + kvMap := make(map[string]string) + for _, line := range strings.Split(string(out), "\n") { + if parts := strings.SplitN(line, "=", 2); len(parts) == 2 { + kvMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + + if v, ok := kvMap["sample_rate"]; ok { + if s, err := strconv.Atoi(v); err == nil { + info.SampleRate = uint32(s) + } + } + + bits := 0 + if v, ok := kvMap["bits_per_raw_sample"]; ok && v != "N/A" && v != "" { + bits, _ = strconv.Atoi(v) + } + if bits == 0 { + if v, ok := kvMap["bits_per_sample"]; ok && v != "N/A" && v != "" { + bits, _ = strconv.Atoi(v) + } + } + info.BitsPerSample = uint8(bits) + + results[idx] = info + }(i, path) + } + + wg.Wait() + return results +} + +type ResampleRequest struct { + InputFiles []string `json:"input_files"` + SampleRate string `json:"sample_rate"` + BitDepth string `json:"bit_depth"` +} + +type ResampleResult struct { + InputFile string `json:"input_file"` + OutputFile string `json:"output_file"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +func buildFolderLabel(sampleRate, bitDepth string) string { + var parts []string + + if bitDepth != "" { + parts = append(parts, bitDepth+"bit") + } + + switch sampleRate { + case "44100": + parts = append(parts, "44.1kHz") + case "48000": + parts = append(parts, "48kHz") + case "96000": + parts = append(parts, "96kHz") + case "192000": + parts = append(parts, "192kHz") + default: + if sampleRate != "" { + parts = append(parts, sampleRate+"Hz") + } + } + + if len(parts) == 0 { + return "Resampled" + } + return strings.Join(parts, " ") +} + +func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) { + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return nil, fmt.Errorf("failed to get ffmpeg path: %w", err) + } + + if err := ValidateExecutable(ffmpegPath); err != nil { + return nil, fmt.Errorf("invalid ffmpeg executable: %w", err) + } + + installed, err := IsFFmpegInstalled() + if err != nil || !installed { + return nil, fmt.Errorf("ffmpeg is not installed") + } + + if req.SampleRate == "" && req.BitDepth == "" { + return nil, fmt.Errorf("at least one of sample rate or bit depth must be specified") + } + + results := make([]ResampleResult, len(req.InputFiles)) + var wg sync.WaitGroup + var mu sync.Mutex + + folderLabel := buildFolderLabel(req.SampleRate, req.BitDepth) + + for i, inputFile := range req.InputFiles { + wg.Add(1) + go func(idx int, inputFile string) { + defer wg.Done() + + result := ResampleResult{ + InputFile: inputFile, + } + + inputExt := strings.ToLower(filepath.Ext(inputFile)) + baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt) + inputDir := filepath.Dir(inputFile) + + outputDir := filepath.Join(inputDir, folderLabel) + if err := os.MkdirAll(outputDir, 0755); err != nil { + result.Error = fmt.Sprintf("failed to create output directory: %v", err) + result.Success = false + mu.Lock() + results[idx] = result + mu.Unlock() + return + } + + // Output is always FLAC (lossless resampling). + outputFile := filepath.Join(outputDir, baseName+".flac") + result.OutputFile = outputFile + + args := []string{ + "-i", inputFile, + "-y", + } + + if req.BitDepth != "" { + switch req.BitDepth { + case "16": + args = append(args, "-c:a", "flac", "-sample_fmt", "s16") + case "24": + args = append(args, "-c:a", "flac", "-sample_fmt", "s32", "-bits_per_raw_sample", "24") + default: + args = append(args, "-c:a", "flac") + } + } else { + args = append(args, "-c:a", "flac") + } + + if req.SampleRate != "" { + args = append(args, "-ar", req.SampleRate) + } + + args = append(args, "-map_metadata", "0") + args = append(args, outputFile) + + fmt.Printf("[Resample] %s -> %s\n", inputFile, outputFile) + + cmd := exec.Command(ffmpegPath, args...) + setHideWindow(cmd) + output, err := cmd.CombinedOutput() + if err != nil { + result.Error = fmt.Sprintf("resampling failed: %s - %s", err.Error(), string(output)) + result.Success = false + mu.Lock() + results[idx] = result + mu.Unlock() + return + } + + result.Success = true + fmt.Printf("[Resample] Done: %s\n", outputFile) + mu.Lock() + results[idx] = result + mu.Unlock() + }(i, inputFile) + } + + wg.Wait() + return results, nil +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b43315d..cfa9dc6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { DownloadQueue } from "@/components/DownloadQueue"; import { DownloadProgressToast } from "@/components/DownloadProgressToast"; import { AudioAnalysisPage } from "@/components/AudioAnalysisPage"; import { AudioConverterPage } from "@/components/AudioConverterPage"; +import { AudioResamplerPage } from "@/components/AudioResamplerPage"; import { FileManagerPage } from "@/components/FileManagerPage"; import { SettingsPage } from "@/components/SettingsPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage"; @@ -428,6 +429,8 @@ function App() { return ; case "audio-converter": return ; + case "audio-resampler": + return ; case "file-manager": return ; default: diff --git a/frontend/src/components/AudioResamplerPage.tsx b/frontend/src/components/AudioResamplerPage.tsx new file mode 100644 index 0000000..31a1861 --- /dev/null +++ b/frontend/src/components/AudioResamplerPage.tsx @@ -0,0 +1,472 @@ +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } from "../../wailsjs/go/main/App"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; +import { AudioLinesIcon } from "@/components/ui/audio-lines"; + +interface AudioFile { + path: string; + name: string; + format: string; + size: number; + status: "pending" | "resampling" | "success" | "error"; + error?: string; + outputPath?: string; + srcSampleRate?: number; + srcBitDepth?: number; +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) + return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} + +function formatSampleRate(sr: number): string { + if (!sr) + return ""; + if (sr === 44100) + return "44.1kHz"; + if (sr >= 1000) + return `${sr / 1000}kHz`; + return `${sr}Hz`; +} + +const SAMPLE_RATE_OPTIONS = [ + { value: "44100", label: "44.1kHz" }, + { value: "48000", label: "48kHz" }, + { value: "96000", label: "96kHz" }, + { value: "192000", label: "192kHz" }, +]; + +const BIT_DEPTH_OPTIONS = [ + { value: "16", label: "16-bit" }, + { value: "24", label: "24-bit" }, +]; + +const STORAGE_KEY = "spotiflac_audio_resampler_state"; + +export function AudioResamplerPage() { + const [files, setFiles] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) { + return parsed.files; + } + } + } + catch (err) { + console.error("Failed to load saved state:", err); + } + return []; + }); + const [sampleRate, setSampleRate] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.sampleRate) + return parsed.sampleRate; + } + } + catch (err) { + } + return "44100"; + }); + const [bitDepth, setBitDepth] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.bitDepth) + return parsed.bitDepth; + } + } + catch (err) { + } + return "16"; + }); + const [resampling, setResampling] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const saveState = useCallback((stateToSave: { + files: AudioFile[]; + sampleRate: string; + bitDepth: string; + }) => { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); + } + catch (err) { + console.error("Failed to save state:", err); + } + }, []); + useEffect(() => { + saveState({ files, sampleRate, bitDepth }); + }, [files, sampleRate, bitDepth, saveState]); + useEffect(() => { + const checkFullscreen = () => { + const isMaximized = window.innerHeight >= window.screen.height * 0.9; + setIsFullscreen(isMaximized); + }; + checkFullscreen(); + window.addEventListener("resize", checkFullscreen); + window.addEventListener("focus", checkFullscreen); + return () => { + window.removeEventListener("resize", checkFullscreen); + window.removeEventListener("focus", checkFullscreen); + }; + }, []); + const fetchAudioInfo = useCallback(async (paths: string[]) => { + if (paths.length === 0) + return; + try { + const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"]; + const infos: Array<{ path: string; sample_rate: number; bits_per_sample: number; }> = + await GetFlacInfoBatch(paths); + setFiles((prev) => prev.map((f) => { + const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase()); + if (info) { + return { + ...f, + srcSampleRate: info.sample_rate || undefined, + srcBitDepth: info.bits_per_sample || undefined, + }; + } + return f; + })); + } + catch (err) { + console.error("Failed to fetch audio info:", err); + } + }, []); + const handleSelectFiles = async () => { + try { + const selectedFiles = await SelectAudioFiles(); + if (selectedFiles && selectedFiles.length > 0) { + addFiles(selectedFiles); + } + } + catch (err) { + toast.error("File Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select files", + }); + } + }; + const handleSelectFolder = async () => { + try { + const selectedFolder = await SelectFolder(""); + if (selectedFolder) { + const folderFiles = await ListAudioFilesInDir(selectedFolder); + if (folderFiles && folderFiles.length > 0) { + addFiles(folderFiles.map((f) => f.path)); + } + else { + toast.info("No audio files found", { + description: "No FLAC files found in the selected folder.", + }); + } + } + } + catch (err) { + toast.error("Folder Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select folder", + }); + } + }; + const addFiles = useCallback(async (paths: string[]) => { + const validExtensions = [".flac"]; + const invalidFiles = paths.filter((path) => { + const ext = path.toLowerCase().slice(path.lastIndexOf(".")); + return !validExtensions.includes(ext); + }); + if (invalidFiles.length > 0) { + toast.error("Unsupported format", { + description: "Only FLAC files are supported for resampling.", + }); + } + const GetFileSizes = (files: string[]): Promise> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files); + const validPaths = paths.filter((path) => { + const ext = path.toLowerCase().slice(path.lastIndexOf(".")); + return validExtensions.includes(ext); + }); + const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {}; + let newlyAddedPaths: string[] = []; + setFiles((prev) => { + const newFiles: AudioFile[] = validPaths + .filter((path) => !prev.some((f) => f.path === path)) + .map((path) => { + const name = path.split(/[/\\]/).pop() || path; + const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase(); + return { + path, + name, + format: ext, + size: fileSizes[path] || 0, + status: "pending" as const, + }; + }); + newlyAddedPaths = newFiles.map((f) => f.path); + if (newFiles.length > 0) { + if (paths.length > newFiles.length + invalidFiles.length) { + const skipped = paths.length - newFiles.length - invalidFiles.length; + toast.info("Some files skipped", { + description: `${skipped} file(s) were already added`, + }); + } + return [...prev, ...newFiles]; + } + if (validPaths.length > 0 && newFiles.length === 0) { + toast.info("No new files added", { + description: "All valid files were already added", + }); + } + return prev; + }); + setTimeout(() => { + if (newlyAddedPaths.length > 0) { + fetchAudioInfo(newlyAddedPaths); + } + }, 50); + }, [fetchAudioInfo]); + const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => { + setIsDragging(false); + if (paths.length === 0) + return; + addFiles(paths); + }, [addFiles]); + useEffect(() => { + OnFileDrop((x, y, paths) => { + handleFileDrop(x, y, paths); + }, true); + return () => { + OnFileDropOff(); + }; + }, [handleFileDrop]); + const removeFile = (path: string) => { + setFiles((prev) => prev.filter((f) => f.path !== path)); + }; + const clearFiles = () => { + setFiles([]); + }; + const handleResample = async () => { + if (files.length === 0) { + toast.error("No files selected", { + description: "Please add FLAC files to resample", + }); + return; + } + setResampling(true); + try { + const inputPaths = files.map((f) => f.path); + setFiles((prev) => prev.map((f) => { + if (inputPaths.includes(f.path)) { + return { ...f, status: "resampling" as const, error: undefined }; + } + return f; + })); + const results = await ResampleAudio({ + input_files: inputPaths, + sample_rate: sampleRate, + bit_depth: bitDepth, + }); + setFiles((prev) => prev.map((f) => { + const result = results.find((r: any) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase()); + if (result) { + return { + ...f, + status: result.success ? "success" : "error", + error: result.error, + outputPath: result.output_file, + }; + } + return f; + })); + const successCount = results.filter((r: any) => r.success).length; + const failCount = results.filter((r: any) => !r.success).length; + if (successCount > 0) { + toast.success("Resampling Complete", { + description: `Successfully resampled ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, + }); + } + else if (failCount > 0) { + toast.error("Resampling Failed", { + description: `All ${failCount} file(s) failed to resample`, + }); + } + } + catch (err) { + toast.error("Resampling Error", { + description: err instanceof Error ? err.message : "Unknown error", + }); + setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Resampling failed" }))); + } + finally { + setResampling(false); + } + }; + const getStatusIcon = (status: AudioFile["status"]) => { + switch (status) { + case "resampling": + return ; + case "success": + return ; + case "error": + return ; + default: + return ; + } + }; + const resampleableCount = files.filter((f) => f.status === "pending" || f.status === "success").length; + const successCount = files.filter((f) => f.status === "success").length; + return (
+ +
+

Audio Resampler

+ {files.length > 0 && (
+ + + +
)} +
+ +
{ + e.preventDefault(); + setIsDragging(true); + }} onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}> + {files.length === 0 ? (<> +
+ +
+

+ {isDragging + ? "Drop your FLAC files here" + : "Drag and drop FLAC files here, or click the button below to select"} +

+
+ + +
+

+ Supported format: FLAC +

+ ) : (
+
+
+
+ + { + if (value) + setBitDepth(value); + }}> + {BIT_DEPTH_OPTIONS.map((option) => ( + {option.label} + ))} + +
+ +
+ + { + if (value) + setSampleRate(value); + }}> + {SAMPLE_RATE_OPTIONS.map((option) => ( + {option.label} + ))} + +
+
+
+ +
+
+ {files.length} file(s) • {successCount} resampled +
+
+ +
+ {files.map((file) => { + const srcParts: string[] = []; + if (file.srcBitDepth) + srcParts.push(`${file.srcBitDepth}-bit`); + if (file.srcSampleRate) + srcParts.push(formatSampleRate(file.srcSampleRate)); + const srcSpec = srcParts.join(" / "); + return (
+ {getStatusIcon(file.status)} +
+

{file.name}

+ {file.error && (

+ {file.error} +

)} +
+ + {srcSpec ? ( + {srcSpec} + ) : file.status === "pending" ? ( + reading... + ) : null} + + + {formatFileSize(file.size)} + + + {file.format} + + {file.status !== "resampling" && ()} +
); + })} +
+ +
+ +
+
)} +
+
); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 71ce65d..aa9cd58 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -10,6 +10,7 @@ import { CoffeeIcon } from "@/components/ui/coffee"; import { BadgeAlertIcon } from "@/components/ui/badge-alert"; import { GithubIcon } from "@/components/ui/github"; import { BlocksIcon } from "@/components/ui/blocks-icon"; +import { AudioLinesIcon } from "@/components/ui/audio-lines"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Checkbox } from "@/components/ui/checkbox"; @@ -17,7 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; -export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history"; +export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history"; interface SidebarProps { currentPage: PageType; onPageChange: (page: PageType) => void; @@ -85,7 +86,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - @@ -103,6 +104,10 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { Audio Converter + onPageChange("audio-resampler")} className="gap-3 cursor-pointer py-2 px-3"> + + Audio Resampler + onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3"> File Manager diff --git a/frontend/src/components/ui/audio-lines.tsx b/frontend/src/components/ui/audio-lines.tsx new file mode 100644 index 0000000..b94126c --- /dev/null +++ b/frontend/src/components/ui/audio-lines.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; + +import { cn } from "@/lib/utils"; + +export interface AudioLinesIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface AudioLinesIconProps extends HTMLAttributes { + size?: number; +} + +const AudioLinesIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (isControlledRef.current) { + onMouseEnter?.(e); + } else { + controls.start("animate"); + } + }, + [controls, onMouseEnter] + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (isControlledRef.current) { + onMouseLeave?.(e); + } else { + controls.start("normal"); + } + }, + [controls, onMouseLeave] + ); + + return ( +
+ + + + + + + + +
+ ); + } +); + +AudioLinesIcon.displayName = "AudioLinesIcon"; + +export { AudioLinesIcon }; diff --git a/ref/isrcfinder.py b/ref/isrcfinder.py new file mode 100644 index 0000000..97a6297 --- /dev/null +++ b/ref/isrcfinder.py @@ -0,0 +1,245 @@ +import requests +import re +from bs4 import BeautifulSoup + +URL = "https://www.isrcfinder.com/" +SPOTIFY_URI = "https://open.spotify.com/track/1CPZ5BxNNd0n0nF4Orb9JS" + +HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://www.isrcfinder.com/", + "Origin": "https://www.isrcfinder.com", +} + +# ───────────────────────────────────────────── +# PRIMARY: isrcfinder.com +# ───────────────────────────────────────────── + +def get_csrf_token(session): + """Ambil CSRF token secara dinamis dari halaman GET.""" + print("[*] Mengambil CSRF token dari halaman...") + response = session.get(URL, headers=HEADERS) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + csrf_input = soup.find("input", {"name": "csrfmiddlewaretoken"}) + if csrf_input: + token = csrf_input["value"] + print(f" CSRF token (HTML) : {token}") + return token + + token = session.cookies.get("csrftoken") + if token: + print(f" CSRF token (cookie): {token}") + return token + + raise ValueError("CSRF token tidak ditemukan!") + + +def find_isrc_primary(session, csrf_token): + """Kirim POST request ke isrcfinder.com dan ekstrak ISRC.""" + print(f"\n[*] [PRIMARY] isrcfinder.com — URI: {SPOTIFY_URI}") + + headers = { + **HEADERS, + "Content-Type": "application/x-www-form-urlencoded", + } + payload = { + "csrfmiddlewaretoken": csrf_token, + "URI": SPOTIFY_URI, + } + + response = session.post(URL, headers=headers, data=payload) + print(f" Status POST : {response.status_code}") + print(f" URL akhir : {response.url}") + + isrc_pattern = re.compile(r'[A-Z]{2}[A-Z0-9]{3}\d{7}') + isrc_matches = list(set(isrc_pattern.findall(response.text))) + + if isrc_matches: + print(f"[+] [PRIMARY] ISRC ditemukan: {isrc_matches}") + else: + print("[-] [PRIMARY] ISRC tidak ditemukan.") + + return isrc_matches + + +# ───────────────────────────────────────────── +# FALLBACK 1: phpstack (Cloudways) +# ───────────────────────────────────────────── + +def find_isrc_fallback1(): + """ + GET https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php + Response JSON: { "isrc": "...", "name": "...", ... } + """ + print("\n[*] [FALLBACK 1] phpstack Cloudways API...") + + encoded_uri = requests.utils.quote(SPOTIFY_URI, safe="") + url = f"https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php?q={encoded_uri}" + + headers = { + "User-Agent": HEADERS["User-Agent"], + "Referer": "https://phpstack-822472-6184058.cloudwaysapps.com/?", + } + + try: + response = requests.get(url, headers=headers, timeout=10) + print(f" Status GET : {response.status_code}") + data = response.json() + + isrc = data.get("isrc") + if isrc: + print(f"[+] [FALLBACK 1] ISRC ditemukan: {isrc}") + return [isrc] + + print(f"[-] [FALLBACK 1] ISRC tidak ada di response: {data}") + return [] + + except Exception as e: + print(f"[!] [FALLBACK 1] Error: {e}") + return [] + + +# ───────────────────────────────────────────── +# FALLBACK 2: findmyisrc.com (AWS API Gateway) +# ───────────────────────────────────────────── + +def find_isrc_fallback2(): + """ + POST https://lxtzsnh4l3.execute-api.ap-southeast-2.amazonaws.com/prod/find-my-isrc + Payload: { "uris": [""] } + Response: [{ "type": "track", "data": { "isrc": "..." } }] + """ + print("\n[*] [FALLBACK 2] findmyisrc.com (AWS API Gateway)...") + + url = "https://lxtzsnh4l3.execute-api.ap-southeast-2.amazonaws.com/prod/find-my-isrc" + headers = { + "User-Agent": HEADERS["User-Agent"], + "Content-Type": "application/json", + "Origin": "https://www.findmyisrc.com", + "Referer": "https://www.findmyisrc.com/", + } + payload = {"uris": [SPOTIFY_URI]} + + try: + response = requests.post(url, headers=headers, json=payload, timeout=10) + print(f" Status POST : {response.status_code}") + data = response.json() + + isrc_list = [] + for item in data: + isrc = item.get("data", {}).get("isrc") + if isrc: + isrc_list.append(isrc) + + if isrc_list: + print(f"[+] [FALLBACK 2] ISRC ditemukan: {isrc_list}") + else: + print(f"[-] [FALLBACK 2] ISRC tidak ada di response: {data}") + + return isrc_list + + except Exception as e: + print(f"[!] [FALLBACK 2] Error: {e}") + return [] + + +# ───────────────────────────────────────────── +# FALLBACK 3: mixviberecords.com +# ───────────────────────────────────────────── + +def find_isrc_fallback3(): + """ + POST https://tools.mixviberecords.com/api/find-isrc + Payload: { "url": "" } + Response JSON contains: { "external_ids": { "isrc": "..." } } + """ + print("\n[*] [FALLBACK 3] mixviberecords.com...") + + url = "https://tools.mixviberecords.com/api/find-isrc" + headers = { + "User-Agent": HEADERS["User-Agent"], + "Content-Type": "application/json", + "Origin": "https://tools.mixviberecords.com", + "Referer": "https://tools.mixviberecords.com/isrc-finder", + } + payload = {"url": SPOTIFY_URI} + + try: + response = requests.post(url, headers=headers, json=payload, timeout=10) + print(f" Status POST : {response.status_code}") + data = response.json() + + # Navigasi ke external_ids.isrc (bisa nested dalam berbagai struktur) + isrc = None + if isinstance(data, dict): + isrc = ( + data.get("external_ids", {}).get("isrc") + or data.get("isrc") + ) + # Coba cari rekursif satu level lebih dalam + if not isrc: + for val in data.values(): + if isinstance(val, dict): + isrc = val.get("external_ids", {}).get("isrc") or val.get("isrc") + if isrc: + break + + if not isrc: + # Fallback regex pada raw text jika JSON tidak sesuai ekspektasi + isrc_pattern = re.compile(r'[A-Z]{2}[A-Z0-9]{3}\d{7}') + matches = list(set(isrc_pattern.findall(response.text))) + if matches: + isrc = matches[0] + + if isrc: + print(f"[+] [FALLBACK 3] ISRC ditemukan: {isrc}") + return [isrc] + + print(f"[-] [FALLBACK 3] ISRC tidak ada di response: {data}") + return [] + + except Exception as e: + print(f"[!] [FALLBACK 3] Error: {e}") + return [] + + +# ───────────────────────────────────────────── +# MAIN — jalankan berurutan, berhenti jika berhasil +# ───────────────────────────────────────────── + +def main(): + isrc_list = [] + + # PRIMARY + with requests.Session() as session: + try: + csrf_token = get_csrf_token(session) + isrc_list = find_isrc_primary(session, csrf_token) + except Exception as e: + print(f"[!] [PRIMARY] Gagal: {e}") + + # FALLBACK 1 + if not isrc_list: + isrc_list = find_isrc_fallback1() + + # FALLBACK 2 + if not isrc_list: + isrc_list = find_isrc_fallback2() + + # FALLBACK 3 + if not isrc_list: + isrc_list = find_isrc_fallback3() + + # Hasil akhir + print(f"\n{'='*40}") + if isrc_list: + print(f" Hasil ISRC: {', '.join(isrc_list)}") + else: + print(" Semua sumber gagal. ISRC tidak ditemukan.") + print(f"{'='*40}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ref/songstats.py b/ref/songstats.py new file mode 100644 index 0000000..88f5f19 --- /dev/null +++ b/ref/songstats.py @@ -0,0 +1,94 @@ +import requests +import re +import json +from bs4 import BeautifulSoup + +ISRCFINDER_URL = "https://www.isrcfinder.com/" +SONGSTATS_BASE = "https://songstats.com" +SPOTIFY_URI = "https://open.spotify.com/track/1CPZ5BxNNd0n0nF4Orb9JS" + +HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://www.isrcfinder.com/", + "Origin": "https://www.isrcfinder.com", +} + +def get_csrf_token(session): + print("[1] Mengambil CSRF token ...") + response = session.get(ISRCFINDER_URL, headers=HEADERS) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + csrf_input = soup.find("input", {"name": "csrfmiddlewaretoken"}) + if csrf_input: + token = csrf_input["value"] + print(f" Token : {token}") + return token + + token = session.cookies.get("csrftoken") + if token: + print(f" Token : {token}") + return token + + raise ValueError("CSRF token tidak ditemukan!") + +def get_isrc(session, csrf_token): + print(f"\n[2] Mencari ISRC untuk: {SPOTIFY_URI}") + headers = {**HEADERS, "Content-Type": "application/x-www-form-urlencoded"} + payload = {"csrfmiddlewaretoken": csrf_token, "URI": SPOTIFY_URI} + + response = session.post(ISRCFINDER_URL, headers=headers, data=payload) + matches = list(set(re.findall(r'\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b', response.text))) + + if not matches: + raise ValueError("ISRC tidak ditemukan di response.") + + isrc = matches[0] + print(f" ISRC : {isrc}") + return isrc + +def get_platform_links(session, isrc): + url = f"{SONGSTATS_BASE}/{isrc}?ref=ISRCFinder" + print(f"\n[3] Mengambil link dari songstats.com ...") + + response = session.get(url, headers={"User-Agent": HEADERS["User-Agent"]}, allow_redirects=True) + soup = BeautifulSoup(response.text, "html.parser") + + tidal_link = None + amazon_link = None + deezer_link = None + + for script in soup.find_all("script", {"type": "application/ld+json"}): + try: + data = json.loads(script.string) + graph = data.get("@graph", [data]) + + for node in graph: + if node.get("@type") == "MusicRecording": + for link in node.get("sameAs", []): + if "listen.tidal.com/track" in link: + tidal_link = link + elif "music.amazon.com" in link: + amazon_link = link + elif "deezer.com" in link: + deezer_link = link + except (json.JSONDecodeError, AttributeError): + continue + + return tidal_link, amazon_link, deezer_link + +def main(): + with requests.Session() as session: + csrf_token = get_csrf_token(session) + isrc = get_isrc(session, csrf_token) + tidal, amazon, deezer = get_platform_links(session, isrc) + + print(f"\n{'='*50}") + print(f" ISRC : {isrc}") + print(f" Tidal : {tidal or 'Tidak ditemukan'}") + print(f" Amazon Music : {amazon or 'Tidak ditemukan'}") + print(f" Deezer : {deezer or 'Tidak ditemukan'}") + print(f"{'='*50}") + +if __name__ == "__main__": + main() \ No newline at end of file