├── .env ├── .gitignore ├── LICENSE ├── README.md ├── app ├── [catchall] │ └── page.tsx ├── api │ ├── download-music │ │ └── route.ts │ ├── get-album │ │ └── route.ts │ ├── get-artist │ │ └── route.ts │ ├── get-music │ │ └── route.ts │ └── get-releases │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── page.tsx └── search-view.tsx ├── components.json ├── components ├── artist-dialog.tsx ├── download-album-button.tsx ├── mode-toggle.tsx ├── particles.tsx ├── release-card.tsx ├── search-bar.tsx ├── status-bar │ ├── container.tsx │ ├── queue-dialog.tsx │ ├── queue-view.tsx │ └── status-bar.tsx ├── theme-provider.tsx └── ui │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── progress.tsx │ ├── scroll-area.tsx │ ├── separator.tsx │ ├── settings-form.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── toast.tsx │ └── toaster.tsx ├── eslint.config.mjs ├── hooks └── use-toast.ts ├── lib ├── background-provider.tsx ├── download-job.tsx ├── ffmpeg-functions.tsx ├── ffmpeg-provider.tsx ├── qobuz-dl.tsx ├── settings-provider.tsx ├── status-bar │ ├── context.tsx │ └── jobs.tsx └── utils.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── flac │ ├── EmsArgs.js │ ├── EmsWorkerProxy.js │ ├── FlacEncoder.js │ ├── asm │ │ ├── flac.encoder.js │ │ ├── flac.encoder.mem.png │ │ ├── flac.js │ │ └── flac.mem.png │ └── wasm │ │ ├── flac.encoder.js │ │ ├── flac.encoder.wasm.png │ │ ├── flac.js │ │ └── flac.wasm.png └── logo │ ├── qobuz-banner.png │ ├── qobuz-web-dark.png │ └── qobuz-web-light.png ├── tailwind.config.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | QOBUZ_API_BASE = https://www.qobuz.com/api.json/0.2/ 2 | QOBUZ_APP_ID = 3 | QOBUZ_SECRET = 4 | QOBUZ_AUTH_TOKENS = [] 5 | 6 | NEXT_PUBLIC_APPLICATION_NAME = Qobuz-DL 7 | 8 | #Leave these empty unless you know what you're doing 9 | CORS_PROXY = 10 | SOCKS5_PROXY = -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | .env.local -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 FarmzDev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Qobuz-DL 2 | ![Qobuz-DL](https://github.com/user-attachments/assets/45896382-1764-4339-824a-b31f32991480) 3 | 4 | --- 5 | 6 | > [!IMPORTANT] 7 | > This repository does not contain any copyrighted material, or code to illegaly download music. Downloads are provided by the Qobuz API and should only be initiated by the API token owner. The author is **not responsible for the usage of this repository nor endorses it**, nor is the author responsible for any copies, forks, re-uploads made by other users, or anything else related to Qobuz-DL. Any live demo found online of this project is not associated with the authors of this repo. This is the author's only account and repository. 8 | 9 | Qobuz-DL provides a fast and easy way to download music using Qobuz in a variety of codecs and formats entirely from the browser. 10 | 11 | ## Features 12 | - Download any song or album from Qobuz. 13 | - Re-encode audio provided by Qobuz to a variety of different lossless and lossy codecs using FFmpeg. 14 | - Apply metadata to downloaded songs. 15 | 16 | ## Table of Contents 17 | - [Installation](#installation) 18 | - [Contributing](#contributing) 19 | - [License](#license) 20 | 21 | 22 | ## Installation 23 | 24 | Before you begin, ensure you have the following installed: 25 | 26 | - **Node.js** (LTS version recommended) 27 | Download from: [https://nodejs.org/](https://nodejs.org/) 28 | 29 | - **npm** (comes with Node.js) 30 | To check if npm is installed, run: 31 | ```bash 32 | npm -v 33 | ``` 34 | 35 | ## Getting Started 36 | 37 | ### 1. Clone the repo 38 | ```bash 39 | git clone https://github.com/QobuzDL/Qobuz-DL.git 40 | ``` 41 | 42 | ### 2. Navigate to the project directory 43 | ```bash 44 | cd Qobuz-DL 45 | ``` 46 | ### 3. Install Dependencies 47 | ```bash 48 | npm i 49 | ``` 50 | ### 4. Run the development server 51 | ```bash 52 | npm run dev 53 | ``` 54 | 55 | ### Setup .env (IMPORTANT) 56 | Before you can use Qobuz-DL, you need to change the .env file in the root directory. The default configuration will NOT work. QOBUZ_APP_ID and QOBUZ_SECRET must be set to the correct values. To find these you can use [this tool](https://github.com/QobuzDL/Qobuz-AppID-Secret-Tool). 57 | Additionally, in order to download files longer than 30 seconds, a valid Qobuz token is needed. This can be found in the localuser.token key of localstorage on the [official Qobuz website](https://play.qobuz.com/) for any paying members. 58 | 59 | ## Contributing 60 | 1. Fork the repository. 61 | 2. Create a new branch: `git checkout -b feature-name`. 62 | 3. Make your changes. 63 | 4. Push your branch: `git push origin feature-name`. 64 | 5. Create a pull request. 65 | 66 | 67 | ## License 68 | This project is licensed under the [MIT License](LICENSE). 69 | -------------------------------------------------------------------------------- /app/[catchall]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | const Page = () => { 4 | redirect('/') 5 | } 6 | 7 | export default Page -------------------------------------------------------------------------------- /app/api/download-music/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { getDownloadURL } from "@/lib/qobuz-dl"; 3 | import z from "zod"; 4 | 5 | const downloadParamsSchema = z.object({ 6 | track_id: z.preprocess( 7 | (a) => parseInt(a as string), 8 | z.number().min(0, "ID must be 0 or greater").default(1) 9 | ), 10 | quality: z.enum(["27", "7", "6", "5"]).default("27") 11 | }) 12 | 13 | export async function GET(request: NextRequest) { 14 | const params = Object.fromEntries(new URL(request.url).searchParams.entries()); 15 | try { 16 | const { track_id, quality } = downloadParamsSchema.parse(params); 17 | const url = await getDownloadURL(track_id, quality); 18 | return new NextResponse(JSON.stringify({ success: true, data: { url } }), { status: 200 }); 19 | } catch (error: any) { 20 | return new NextResponse(JSON.stringify({ success: false, error: error?.errors || error.message || "An error occurred parsing the request." }), { status: 400 }); 21 | } 22 | } -------------------------------------------------------------------------------- /app/api/get-album/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { getAlbumInfo } from "@/lib/qobuz-dl"; 3 | import z from "zod"; 4 | 5 | const albumInfoParamsSchema = z.object({ 6 | album_id: z.string().min(1, "ID is required") 7 | }) 8 | 9 | export async function GET(request: NextRequest) { 10 | const params = Object.fromEntries(new URL(request.url).searchParams.entries()); 11 | try { 12 | const { album_id } = albumInfoParamsSchema.parse(params); 13 | const data = await getAlbumInfo(album_id); 14 | return new NextResponse(JSON.stringify({ success: true, data }), { status: 200 }); 15 | } catch (error: any) { 16 | return new NextResponse(JSON.stringify({ success: false, error: error?.errors || error.message || "An error occurred parsing the request." }), { status: 400 }); 17 | } 18 | } -------------------------------------------------------------------------------- /app/api/get-artist/route.ts: -------------------------------------------------------------------------------- 1 | import { getArtist } from "@/lib/qobuz-dl"; 2 | import z from "zod"; 3 | 4 | const artistReleasesParamsSchema = z.object({ 5 | artist_id: z.string().min(1, "ID is required") 6 | }) 7 | 8 | export async function GET(request: Request) { 9 | const params = Object.fromEntries(new URL(request.url).searchParams.entries()); 10 | try { 11 | const { artist_id } = artistReleasesParamsSchema.parse(params); 12 | const artist = await getArtist(artist_id); 13 | return new Response(JSON.stringify({ success: true, data: { artist } }), { status: 200 }); 14 | } catch (error: any) { 15 | return new Response(JSON.stringify({ success: false, error: error?.errors || error.message || "An error occurred parsing the request." }), { status: 400 }); 16 | } 17 | } -------------------------------------------------------------------------------- /app/api/get-music/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { search } from "@/lib/qobuz-dl"; 3 | import z from "zod"; 4 | 5 | const searchParamsSchema = z.object({ 6 | q: z.string().min(1, "Query is required"), 7 | offset: z.preprocess( 8 | (a) => parseInt(a as string), 9 | z.number().max(1000, "Offset must be less than 1000").min(0, "Offset must be 0 or greater").default(0) 10 | ) 11 | }) 12 | 13 | export async function GET(request: NextRequest) { 14 | const params = Object.fromEntries(new URL(request.url).searchParams.entries()); 15 | try { 16 | const { q, offset } = searchParamsSchema.parse(params); 17 | const searchResults = await search(q, 10, offset); 18 | return new NextResponse(JSON.stringify({success: true, data: searchResults}), { status: 200 }); 19 | } catch (error: any) { 20 | return new NextResponse(JSON.stringify({ success: false, error: error?.errors || error.message || "An error occurred parsing the request." }), { status: 400 }); 21 | } 22 | } -------------------------------------------------------------------------------- /app/api/get-releases/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { getArtistReleases } from "@/lib/qobuz-dl"; 3 | import z from "zod"; 4 | 5 | const releasesParamsSchema = z.object({ 6 | artist_id: z.string().min(1, "ID is required"), 7 | release_type: z.enum(["album", "live", "compilation", "epSingle", "download"]).default("album"), 8 | track_size: z.number().positive().default(1000), 9 | offset: z.preprocess((a) => parseInt(a as string), z.number().positive().default(0)), 10 | limit: z.preprocess((a) => parseInt(a as string), z.number().positive().default(10)) 11 | }) 12 | 13 | export async function GET(request: NextRequest) { 14 | const params = Object.fromEntries(new URL(request.url).searchParams.entries()); 15 | try { 16 | const { artist_id, release_type, track_size, offset, limit } = releasesParamsSchema.parse(params); 17 | const data = await getArtistReleases(artist_id, release_type, limit, offset, track_size); 18 | return new NextResponse(JSON.stringify({ success: true, data }), { status: 200 }); 19 | } catch (error: any) { 20 | return new NextResponse(JSON.stringify({ success: false, error: error?.errors || error.message || "An error occurred parsing the request." }), { status: 400 }); 21 | } 22 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QobuzDL/Qobuz-DL/d8b33e79c465a7e754afba8c3084cf9c578bfd40/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 0 0% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 0 0% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 0 0% 3.9%; 17 | --primary: 0 0% 9%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | --muted: 0 0% 96.1%; 22 | --muted-foreground: 0 0% 45.1%; 23 | --muted-background: 0 0% 70%; 24 | --accent: 0 0% 96.1%; 25 | --accent-foreground: 0 0% 9%; 26 | --accent-background: 0 0% 70%; 27 | --destructive: 0 84.2% 60.2%; 28 | --destructive-foreground: 0 0% 98%; 29 | --border: 0 0% 89.8%; 30 | --input: 0 0% 89.8%; 31 | --ring: 0 0% 3.9%; 32 | --chart-1: 12 76% 61%; 33 | --chart-2: 173 58% 39%; 34 | --chart-3: 197 37% 24%; 35 | --chart-4: 43 74% 66%; 36 | --chart-5: 27 87% 67%; 37 | --radius: 0.5rem; 38 | } 39 | 40 | .dark { 41 | --background: 0 0% 3.9%; 42 | --foreground: 0 0% 98%; 43 | --card: 0 0% 3.9%; 44 | --card-foreground: 0 0% 98%; 45 | --popover: 0 0% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | --primary: 0 0% 98%; 48 | --primary-foreground: 0 0% 9%; 49 | --secondary: 0 0% 14.9%; 50 | --secondary-foreground: 0 0% 98%; 51 | --muted: 0 0% 14.9%; 52 | --muted-foreground: 0 0% 63.9%; 53 | --muted-background: 0 0% 35.9%; 54 | --accent: 0 0% 14.9%; 55 | --accent-foreground: 0 0% 98%; 56 | --accent-background: 0 0% 20%; 57 | --destructive: 0 62.8% 30.6%; 58 | --destructive-foreground: 0 0% 98%; 59 | --border: 0 0% 14.9%; 60 | --input: 0 0% 14.9%; 61 | --ring: 0 0% 83.1%; 62 | --chart-1: 220 70% 50%; 63 | --chart-2: 160 60% 45%; 64 | --chart-3: 30 80% 55%; 65 | --chart-4: 280 65% 60%; 66 | --chart-5: 340 75% 55%; 67 | } 68 | 69 | .purple { 70 | --background: 266 50% 5%; 71 | --foreground: 266 5% 90%; 72 | --card: 266 50% 0%; 73 | --card-foreground: 266 5% 90%; 74 | --popover: 266 50% 5%; 75 | --popover-foreground: 266 5% 90%; 76 | --primary: 266 100% 67%; 77 | --primary-foreground: 0 0% 0%; 78 | --secondary: 266 30% 10%; 79 | --secondary-foreground: 0 0% 100%; 80 | --muted: 228 30% 15%; 81 | --muted-foreground: 266 5% 60%; 82 | --accent: 228 30% 15%; 83 | --accent-foreground: 266 5% 90%; 84 | --destructive: 0 100% 30%; 85 | --destructive-foreground: 266 5% 90%; 86 | --border: 266 30% 18%; 87 | --input: 266 30% 18%; 88 | --ring: 266 100% 67%; 89 | --radius: 0.5rem; 90 | } 91 | 92 | .pink { 93 | --background: 311 50% 5%; 94 | --foreground: 311 5% 90%; 95 | --card: 311 50% 0%; 96 | --card-foreground: 311 5% 90%; 97 | --popover: 311 50% 5%; 98 | --popover-foreground: 311 5% 90%; 99 | --primary: 311 100% 67%; 100 | --primary-foreground: 0 0% 0%; 101 | --secondary: 311 30% 10%; 102 | --secondary-foreground: 0 0% 100%; 103 | --muted: 273 30% 15%; 104 | --muted-foreground: 311 5% 60%; 105 | --accent: 273 30% 15%; 106 | --accent-foreground: 311 5% 90%; 107 | --destructive: 0 100% 30%; 108 | --destructive-foreground: 311 5% 90%; 109 | --border: 311 30% 18%; 110 | --input: 311 30% 18%; 111 | --ring: 311 100% 67%; 112 | --radius: 0.5rem; 113 | } 114 | 115 | .blue { 116 | --background: 212 50% 5%; 117 | --foreground: 212 5% 90%; 118 | --card: 212 50% 0%; 119 | --card-foreground: 212 5% 90%; 120 | --popover: 212 50% 5%; 121 | --popover-foreground: 212 5% 90%; 122 | --primary: 212 100% 67%; 123 | --primary-foreground: 0 0% 0%; 124 | --secondary: 212 30% 10%; 125 | --secondary-foreground: 0 0% 100%; 126 | --muted: 174 30% 15%; 127 | --muted-foreground: 212 5% 60%; 128 | --accent: 174 30% 15%; 129 | --accent-foreground: 212 5% 90%; 130 | --destructive: 0 100% 30%; 131 | --destructive-foreground: 212 5% 90%; 132 | --border: 212 30% 18%; 133 | --input: 212 30% 18%; 134 | --ring: 212 100% 67%; 135 | --radius: 0.5rem; 136 | } 137 | 138 | .green { 139 | --background: 142 50% 5%; 140 | --foreground: 142 5% 90%; 141 | --card: 142 50% 0%; 142 | --card-foreground: 142 5% 90%; 143 | --popover: 142 50% 5%; 144 | --popover-foreground: 142 5% 90%; 145 | --primary: 142 100% 67%; 146 | --primary-foreground: 0 0% 0%; 147 | --secondary: 142 30% 10%; 148 | --secondary-foreground: 0 0% 100%; 149 | --muted: 104 30% 15%; 150 | --muted-foreground: 142 5% 60%; 151 | --accent: 104 30% 15%; 152 | --accent-foreground: 142 5% 90%; 153 | --destructive: 0 100% 30%; 154 | --destructive-foreground: 142 5% 90%; 155 | --border: 142 30% 18%; 156 | --input: 142 30% 18%; 157 | --ring: 142 100% 67%; 158 | --radius: 0.5rem; 159 | } 160 | 161 | .red { 162 | --background: 0 50% 5%; 163 | --foreground: 0 5% 90%; 164 | --card: 0 50% 0%; 165 | --card-foreground: 0 5% 90%; 166 | --popover: 0 50% 5%; 167 | --popover-foreground: 0 5% 90%; 168 | --primary: 0 100% 67%; 169 | --primary-foreground: 0 0% 0%; 170 | --secondary: 0 30% 10%; 171 | --secondary-foreground: 0 0% 100%; 172 | --muted: -38 30% 15%; 173 | --muted-foreground: 0 5% 60%; 174 | --accent: -38 30% 15%; 175 | --accent-foreground: 0 5% 90%; 176 | --destructive: 0 100% 30%; 177 | --destructive-foreground: 0 5% 90%; 178 | --border: 0 30% 18%; 179 | --input: 0 30% 18%; 180 | --ring: 0 100% 67%; 181 | --radius: 0.5rem; 182 | } 183 | 184 | .orange { 185 | --background: 31 50% 5%; 186 | --foreground: 31 5% 90%; 187 | --card: 31 50% 0%; 188 | --card-foreground: 31 5% 90%; 189 | --popover: 31 50% 5%; 190 | --popover-foreground: 31 5% 90%; 191 | --primary: 31 100% 67%; 192 | --primary-foreground: 0 0% 0%; 193 | --secondary: 31 30% 10%; 194 | --secondary-foreground: 0 0% 100%; 195 | --muted: -7 30% 15%; 196 | --muted-foreground: 31 5% 60%; 197 | --accent: -7 30% 15%; 198 | --accent-foreground: 31 5% 90%; 199 | --destructive: 0 100% 30%; 200 | --destructive-foreground: 31 5% 90%; 201 | --border: 31 30% 18%; 202 | --input: 31 30% 18%; 203 | --ring: 31 100% 67%; 204 | --radius: 0.5rem; 205 | } 206 | 207 | .yellow { 208 | --background: 58 50% 5%; 209 | --foreground: 58 5% 90%; 210 | --card: 58 50% 0%; 211 | --card-foreground: 58 5% 90%; 212 | --popover: 58 50% 5%; 213 | --popover-foreground: 58 5% 90%; 214 | --primary: 58 100% 62%; 215 | --primary-foreground: 0 0% 0%; 216 | --secondary: 58 30% 10%; 217 | --secondary-foreground: 0 0% 100%; 218 | --muted: 20 30% 15%; 219 | --muted-foreground: 58 5% 60%; 220 | --accent: 20 30% 15%; 221 | --accent-foreground: 58 5% 90%; 222 | --destructive: 0 100% 30%; 223 | --destructive-foreground: 58 5% 90%; 224 | --border: 58 30% 18%; 225 | --input: 58 30% 18%; 226 | --ring: 58 100% 62%; 227 | --radius: 0.5rem; 228 | } 229 | } 230 | 231 | @layer base { 232 | * { 233 | @apply border-border; 234 | } 235 | 236 | body { 237 | @apply bg-background text-foreground; 238 | } 239 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from 'next/font/google' 3 | import "./globals.css"; 4 | import { ThemeProvider } from "@/components/theme-provider" 5 | import ParticlesComponent from "@/components/particles"; 6 | import { StatusBarProvider } from "@/lib/status-bar/context"; 7 | import StatusBarContainer from "@/components/status-bar/container"; 8 | import { FFmpegProvider } from "@/lib/ffmpeg-provider"; 9 | import SettingsForm from "@/components/ui/settings-form"; 10 | import { SettingsProvider } from "@/lib/settings-provider"; 11 | import { BackgroundProvider } from "@/lib/background-provider"; 12 | import { Toaster } from "@/components/ui/toaster" 13 | import { DropdownMenu, DropdownMenuItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; 14 | import { Button } from "@/components/ui/button"; 15 | import { FaDiscord } from "@react-icons/all-files/fa/FaDiscord"; 16 | import { FaGithub } from "@react-icons/all-files/fa/FaGithub"; 17 | 18 | const inter = Inter({ 19 | subsets: ['latin'], 20 | display: 'swap', 21 | }) 22 | 23 | export const metadata: Metadata = { 24 | metadataBase: new URL('https://www.qobuz-dl.com/'), // Site URL 25 | title: { 26 | default: process.env.NEXT_PUBLIC_APPLICATION_NAME + " - A frontend browser client for downloading music for Qobuz.", 27 | template: process.env.NEXT_PUBLIC_APPLICATION_NAME! 28 | }, 29 | description: "A frontend browser client for downloading music for Qobuz.", 30 | openGraph: { 31 | images: process.env.NEXT_PUBLIC_APPLICATION_NAME!.toLowerCase() === "qobuz-dl" 32 | ? [{ url: '/logo/qobuz-banner.png', width: 650, height: 195, alt: 'Qobuz Logo' }] 33 | : [], 34 | }, 35 | keywords: [ 36 | `${process.env.NEXT_PUBLIC_APPLICATION_NAME!}`, 37 | "music", 38 | "downloader", 39 | "hi-res", 40 | "qobuz", 41 | "flac", 42 | "alac", 43 | "mp3", 44 | "aac", 45 | "opus", 46 | "wav" 47 | ] 48 | }; 49 | 50 | export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | 63 | 64 |
65 | 66 |
67 | 68 | 69 | 72 | 73 | 74 | 75 | Qobuz-DL Discord 76 | 77 | 78 | Squidboard Discord 79 | 80 | 81 | 82 | 83 | 86 | 87 |
88 |
89 |
90 |
91 | {children} 92 |
93 | 94 | 95 |
96 |
97 |
98 |
99 |
100 | 101 | 102 |
103 | 104 | 105 | ); 106 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import SearchView from "./search-view"; 2 | 3 | export default function Home() { 4 | return ( 5 | 6 | ); 7 | } -------------------------------------------------------------------------------- /app/search-view.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; 4 | import axios from 'axios'; 5 | import { useInView } from "react-intersection-observer"; 6 | import { useTheme } from 'next-themes'; 7 | import SearchBar from '@/components/search-bar'; 8 | import ReleaseCard from '@/components/release-card'; 9 | import { Button } from '@/components/ui/button'; 10 | import { Skeleton } from '@/components/ui/skeleton'; 11 | import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu"; 12 | import { Disc3Icon, DiscAlbumIcon, UsersIcon } from 'lucide-react'; 13 | import { FilterDataType, filterExplicit, QobuzAlbum, QobuzArtist, QobuzSearchFilters, QobuzSearchResults, QobuzTrack } from '@/lib/qobuz-dl'; 14 | import { getTailwindBreakpoint } from '@/lib/utils'; 15 | import { useSettings } from '@/lib/settings-provider'; 16 | import { motion, useAnimation } from 'motion/react'; 17 | 18 | export const filterData: FilterDataType = [ 19 | { 20 | label: "Albums", 21 | value: 'albums', 22 | icon: DiscAlbumIcon 23 | }, 24 | { 25 | label: "Tracks", 26 | value: 'tracks', 27 | icon: Disc3Icon 28 | }, 29 | { 30 | label: "Artists", 31 | value: 'artists', 32 | icon: UsersIcon 33 | } 34 | ] 35 | 36 | const SearchView = () => { 37 | const { resolvedTheme } = useTheme(); 38 | const [results, setResults] = useState(null); 39 | const [searchField, setSearchField] = useState('albums'); 40 | const [query, setQuery] = useState(''); 41 | const [loading, setLoading] = useState(false); 42 | const [searching, setSearching] = useState(false); 43 | const [searchError, setSearchError] = useState(''); 44 | const { settings } = useSettings(); 45 | 46 | const FilterIcon = filterData.find((fd) => fd.value == searchField)?.icon || Disc3Icon; 47 | 48 | const [scrollTrigger, isInView] = useInView(); 49 | 50 | const fetchMore = () => { 51 | if (loading) return; 52 | setLoading(true); 53 | const filter = filterData.find((fd) => fd.value == searchField) || filterData[0]; 54 | if (filter.searchRoute) { 55 | axios.get("/api/" + filter.searchRoute + `?q=${query}&offset=${results![searchField].items.length}`) 56 | .then((response) => { 57 | if (response.status === 200) { 58 | response.data.data[searchField].items.length = Math.max(response.data.data[searchField].items.length, Math.min(response.data.data[searchField].limit, response.data.data[searchField].total - response.data.data[searchField].offset)); 59 | response.data.data[searchField].items.fill(null, response.data.data[searchField].items.length); 60 | const newResults = { ...results!, [searchField]: { ...results![searchField], items: [...results![searchField].items, ...response.data.data[searchField].items] } } 61 | setLoading(false); 62 | if (query === response.data.data.query) setResults(newResults); 63 | }}) 64 | } else { 65 | axios.get(`/api/get-music?q=${query}&offset=${results![searchField].items.length}`) 66 | .then((response) => { 67 | if (response.status === 200) { 68 | let newResults = { ...results!, [searchField]: { ...results!.albums, items: [...results!.albums.items, ...response.data.data.albums.items] } } 69 | filterData.map((filter) => { 70 | response.data.data[filter.value].items.length = Math.max(response.data.data[filter.value].items.length, Math.min(response.data.data[filter.value].limit, response.data.data[filter.value].total - response.data.data[filter.value].offset)); 71 | response.data.data[filter.value].items.fill(null, response.data.data[filter.value].items.length); 72 | newResults = { ...newResults, [filter.value]: { ...results![filter.value as QobuzSearchFilters], items: [...results![filter.value as QobuzSearchFilters].items, ...response.data.data[filter.value].items] } } 73 | }) 74 | setLoading(false); 75 | if (query === response.data.data.query) setResults(newResults); 76 | } 77 | }); 78 | } 79 | } 80 | 81 | useEffect(() => { 82 | if (searching) return; 83 | if (isInView && results![searchField].total > results![searchField].items.length && !loading) fetchMore(); 84 | }, [isInView, results]); 85 | 86 | const cardRef = useRef(null); 87 | const [cardHeight, setCardHeight] = useState(0); 88 | 89 | useEffect(() => { 90 | const element = cardRef.current; 91 | 92 | if (!element) { 93 | return; 94 | } 95 | 96 | const resizeObserver = new ResizeObserver((entries) => { 97 | for (const entry of entries) { 98 | if (entry.target === element) { 99 | setCardHeight(entry.contentRect.height); 100 | } 101 | } 102 | }); 103 | 104 | resizeObserver.observe(element); 105 | 106 | return () => { 107 | resizeObserver.disconnect(); 108 | }; 109 | }, [results, settings.explicitContent, searchField]); 110 | 111 | useLayoutEffect(() => { 112 | const handleResize = () => { 113 | if (typeof window !== 'undefined') { 114 | setNumRows(rowsMap[getTailwindBreakpoint(window.innerWidth)]); 115 | } 116 | }; 117 | 118 | handleResize(); 119 | 120 | window.addEventListener('resize', handleResize); 121 | 122 | return () => { 123 | window.removeEventListener('resize', handleResize); 124 | }; 125 | }, []); 126 | 127 | const rowsMap = { 128 | "sm": 3, 129 | "md": 5, 130 | "lg": 6, 131 | "xl": 7, 132 | "2xl": 7, 133 | "base": 2 134 | } 135 | 136 | const [numRows, setNumRows] = useState(0); 137 | 138 | const logoAnimationControls = useAnimation(); 139 | useEffect(() => { 140 | logoAnimationControls.start({ 141 | opacity: 1, 142 | y: 0, 143 | transition: { duration: 0.5, type: "spring" }, 144 | }); 145 | }, [logoAnimationControls]); 146 | 147 | return ( 148 | <> 149 |
150 | { 153 | logoAnimationControls.start({ 154 | scale: [1, 1.1, 1], 155 | transition: { duration: 0.4, ease: "easeInOut" }, 156 | }); 157 | setQuery(''); 158 | setResults(null); 159 | setSearchField('albums'); 160 | }} 161 | initial={{ opacity: 0, y: -25 }} 162 | animate={logoAnimationControls} 163 | transition={{ duration: 0.5 }} 164 | > 165 | {process.env.NEXT_PUBLIC_APPLICATION_NAME!.toLowerCase() === "qobuz-dl" ? ( 166 | {process.env.NEXT_PUBLIC_APPLICATION_NAME!} 167 | ) : ( 168 | <> 169 |

{process.env.NEXT_PUBLIC_APPLICATION_NAME}

170 |

The simplest music downloader

171 | 172 | )} 173 |
174 |
175 | { 177 | setQuery(query); 178 | setSearchError(''); 179 | const filter = filterData.find((filter) => filter.value === searchFieldInput) || filterData[0]; 180 | try { 181 | const response = await axios.get(`/api/${filter.searchRoute ? filter.searchRoute : 'get-music'}?q=${query}&offset=0`); 182 | if (response.status === 200) { 183 | setLoading(false); 184 | if (searchField !== searchFieldInput) setSearchField(searchFieldInput as QobuzSearchFilters); 185 | 186 | let newResults = {...response.data.data }; 187 | filterData.map((filter) => { 188 | if (!newResults[filter.value]) newResults = { ...newResults, [filter.value]: { total: undefined, offset: undefined, limit: undefined, items: [] } } 189 | }) 190 | setResults(newResults); 191 | } 192 | } catch (error: any) { 193 | setSearchError(error?.response.data?.error || error.message || 'An error occurred.'); 194 | } 195 | setSearching(false); 196 | }} 197 | searching={searching} 198 | setSearching={setSearching} 199 | query={query} 200 | /> 201 | 202 | 203 | 204 | 208 | 209 | 210 | >}> 211 | {filterData.map((type, index) => ( 212 | 216 | {type.label} 217 | 218 | ))} 219 | 220 | 221 | 222 | {searchError &&

{searchError}

} 223 |
224 |
225 | 226 |
227 | {results &&
228 |
232 | {filterExplicit(results, settings.explicitContent)[searchField].items.map((result: QobuzAlbum | QobuzTrack | QobuzArtist, index: number) => { 233 | if (!result) return null; 234 | return ( 235 | 241 | ); 242 | })} 243 | {results![searchField].items.length < results![searchField].total && [...Array(results![searchField].total > results![searchField].items.length + 30 ? 30 : results![searchField].total - results![searchField].items.length)].map((_, index) => { 244 | return ( 245 |
246 | 247 |
248 |
249 | ); 250 | })} 251 |
252 | {results![searchField].items.length >= results![searchField].total &&
No more {searchField} to show.
} 253 |
} 254 |
255 | 256 | ) 257 | } 258 | 259 | export default SearchView -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/artist-dialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog' 3 | import { parseArtistAlbumData, parseArtistData, QobuzArtist, QobuzArtistResults } from '@/lib/qobuz-dl' 4 | import { Skeleton } from './ui/skeleton' 5 | import { Disc3Icon, DiscAlbumIcon, DownloadIcon, LucideIcon, RadioTowerIcon, UsersIcon } from 'lucide-react' 6 | import { Button } from './ui/button' 7 | import { ScrollArea, ScrollBar } from './ui/scroll-area' 8 | import ReleaseCard from './release-card' 9 | import { useTheme } from 'next-themes' 10 | import axios from 'axios' 11 | import { useInView } from 'react-intersection-observer' 12 | import { motion } from 'motion/react' 13 | import { downloadArtistDiscography } from '@/lib/download-job' 14 | import { useStatusBar } from '@/lib/status-bar/context' 15 | import { useToast } from '@/hooks/use-toast' 16 | import { useSettings } from '@/lib/settings-provider' 17 | import { useFFmpeg } from '@/lib/ffmpeg-provider' 18 | import Image from 'next/image' 19 | 20 | export type CategoryType = { 21 | label: string, 22 | value: "album" | "epSingle" | "live" | "compilation", 23 | icon: LucideIcon 24 | } 25 | 26 | export const artistReleaseCategories: CategoryType[] = [ 27 | { 28 | label: "albums", 29 | value: 'album', 30 | icon: DiscAlbumIcon 31 | }, 32 | { 33 | label: "EPs & singles", 34 | value: 'epSingle', 35 | icon: Disc3Icon 36 | }, 37 | { 38 | label: "live albums", 39 | value: 'live', 40 | icon: RadioTowerIcon 41 | }, 42 | { 43 | label: "compilations", 44 | value: 'compilation', 45 | icon: DiscAlbumIcon 46 | } 47 | ] 48 | 49 | const ArtistDialog = ({ open, setOpen, artist }: { open: boolean, setOpen: (open: boolean) => void, artist: QobuzArtist }) => { 50 | const [artistResults, setArtistResults] = useState(null); 51 | const [, setSearching] = useState(false); 52 | 53 | const getArtistData = async () => { 54 | if (artistResults) return; 55 | const response = await axios.get(`/api/get-artist`, { params: { artist_id: artist.id } }); 56 | setArtistResults(parseArtistData(response.data.data)); 57 | } 58 | 59 | const fetchMore = async (searchField: "album" | "epSingle" | "live" | "compilation", artistResults: QobuzArtistResults) => { 60 | setSearching(true); 61 | const response = await axios.get(`/api/get-releases`, { params: { artist_id: artist.id, offset: artistResults!.artist.releases[searchField]!.items.length, limit: 10, release_type: searchField } }); 62 | const newReleases = [...artistResults!.artist.releases[searchField].items, ...response.data.data.items.map((release: any) => parseArtistAlbumData(release))]; 63 | setArtistResults({ ...artistResults!, artist: { ...artistResults!.artist, releases: { ...artistResults!.artist.releases, [searchField]: { ...artistResults!.artist.releases[searchField], items: newReleases, has_more: response.data.data.has_more } } } }); 64 | setSearching(false); 65 | } 66 | 67 | useEffect(() => { 68 | if (open) getArtistData(); 69 | }, [open]) 70 | 71 | const { setStatusBar } = useStatusBar(); 72 | const { toast } = useToast(); 73 | const { settings } = useSettings(); 74 | const { ffmpegState } = useFFmpeg(); 75 | return ( 76 | 77 | 78 |
79 |
80 | {(artist.image?.small || artistResults?.artist.images.portrait) && } 81 | {(artist.image?.small || artistResults?.artist.images.portrait) ? {artist.name} :
} 82 |
83 | 84 |
85 |
86 | {artist.name} 87 | {artist.albums_count && {artist.albums_count} {artist.albums_count > 1 ? "releases" : "release"}} 88 |
89 |
90 | {artistResults && } 95 |
96 |
97 |
98 | 99 | {artistResults && 100 |
101 | {artistReleaseCategories.map((category) => )} 102 |
103 |
} 104 | 105 |
106 |
107 |
108 | ) 109 | } 110 | 111 | const ArtistReleaseSection = ({ artist, artistResults, setArtistResults, category }: { artist: QobuzArtist, artistResults: QobuzArtistResults | null, setArtistResults: React.Dispatch>, category: CategoryType }) => { 112 | const { resolvedTheme } = useTheme(); 113 | const [searching, setSearching] = useState(false); 114 | const [scrollTrigger, isInView] = useInView(); 115 | 116 | const fetchMore = async (searchField: "album" | "epSingle" | "live" | "compilation", artistResults: QobuzArtistResults) => { 117 | setSearching(true); 118 | const response = await axios.get(`/api/get-releases`, { params: { artist_id: artist.id, offset: artistResults!.artist.releases[searchField]!.items.length, limit: 10, release_type: category.value } }); 119 | const newReleases = [...artistResults!.artist.releases[searchField].items, ...response.data.data.items.map((release: any) => parseArtistAlbumData(release))]; 120 | setArtistResults({ ...artistResults!, artist: { ...artistResults!.artist, releases: { ...artistResults!.artist.releases, [searchField]: { ...artistResults!.artist.releases[searchField], items: newReleases, has_more: response.data.data.has_more } } } }); 121 | setSearching(false); 122 | } 123 | 124 | useEffect(() => { 125 | if (isInView && !searching) fetchMore(category.value, artistResults!); 126 | }, [isInView]); 127 | 128 | const { setStatusBar } = useStatusBar(); 129 | const { toast } = useToast(); 130 | const { settings } = useSettings(); 131 | const { ffmpegState } = useFFmpeg(); 132 | return ( 133 | <> 134 | {artistResults && artistResults.artist.releases[category.value] && artistResults.artist.releases[category.value]!.items.length > 0 && 135 |
136 |
137 | 138 |

{category.label}

139 | 145 |
146 | 147 |
148 | {artistResults && artistResults.artist.releases[category.value]!.items.map((_, i) =>
149 | 150 |
)} 151 |
152 | {artistResults?.artist.releases[category.value]!.has_more && Array(5).fill(0).map((_, index) => { 153 | return ( 154 |
155 | 156 |
157 |
158 | ); 159 | }) 160 | } 161 |
162 |
163 |
164 | 165 |
166 |
167 |
168 | } 169 | 170 | ) 171 | } 172 | 173 | export default ArtistDialog -------------------------------------------------------------------------------- /components/download-album-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Button, ButtonProps } from './ui/button' 3 | import { DownloadIcon, FileArchiveIcon, MusicIcon } from 'lucide-react' 4 | import { StatusBarProps } from './status-bar/status-bar' 5 | import { FFmpegType } from '@/lib/ffmpeg-functions' 6 | import { SettingsProps } from '@/lib/settings-provider' 7 | import { FetchedQobuzAlbum, formatTitle, getFullAlbumInfo, QobuzAlbum } from '@/lib/qobuz-dl' 8 | import { createDownloadJob } from '@/lib/download-job' 9 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu' 10 | 11 | export interface DownloadAlbumButtonProps extends ButtonProps { 12 | result: QobuzAlbum, 13 | setStatusBar: React.Dispatch>, 14 | ffmpegState: FFmpegType, 15 | settings: SettingsProps, 16 | fetchedAlbumData: FetchedQobuzAlbum | null, 17 | setFetchedAlbumData: React.Dispatch>, 18 | onOpen?: () => void, 19 | onClose?: () => void, 20 | toast: (toast: any) => void, 21 | } 22 | 23 | const DownloadButton = React.forwardRef( 24 | ({ className, variant, size, asChild = false, onOpen, onClose, result, setStatusBar, ffmpegState, settings, toast, fetchedAlbumData, setFetchedAlbumData, ...props }, ref) => { 25 | const [open, setOpen] = useState(false); 26 | useEffect(() => { 27 | if (open) onOpen?.() 28 | else onClose?.() 29 | }) 30 | return ( 31 | <> 32 | 33 | 34 | 44 | 45 | 46 | { 47 | createDownloadJob(result, setStatusBar, ffmpegState, settings, toast, fetchedAlbumData, setFetchedAlbumData) 48 | toast({ title: `Added '${formatTitle(result)}'`, description: "The album has been added to the queue" }) 49 | }} className='flex items-center gap-2'> 50 | 51 |

ZIP Archive

52 |
53 | { 54 | const albumData = await getFullAlbumInfo(fetchedAlbumData, setFetchedAlbumData, result); 55 | for (const track of albumData.tracks.items) { 56 | if (track.streamable) { 57 | await createDownloadJob({ ...track, album: albumData }, setStatusBar, ffmpegState, settings, toast, albumData, setFetchedAlbumData); 58 | await new Promise(resolve => setTimeout(resolve, 100)); 59 | } 60 | } 61 | toast({ title: `Added '${formatTitle(result)}'`, description: "The album has been added to the queue" }) 62 | }} className='flex items-center gap-2'> 63 | 64 |

No ZIP Archive

65 |
66 |
67 |
68 | 69 | ) 70 | } 71 | ) 72 | DownloadButton.displayName = "DownloadAlbumButton"; 73 | 74 | export default DownloadButton -------------------------------------------------------------------------------- /components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useTheme } from "next-themes" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuLabel, 11 | DropdownMenuRadioGroup, 12 | DropdownMenuRadioItem, 13 | DropdownMenuTrigger, 14 | } from "@/components/ui/dropdown-menu" 15 | import { ChevronDownIcon } from "lucide-react" 16 | import { useEffect, useState } from "react" 17 | 18 | export const getHex = (themeValue: string, resolvedTheme: string | undefined): string => { 19 | if (themeValue === "auto") { 20 | return resolvedTheme === "light" ? "#000000" : "#FFFFFF"; 21 | } 22 | 23 | return ( 24 | themes 25 | .flatMap((group) => group.themes) 26 | .find((theme) => theme.value.toLowerCase() === themeValue.toLowerCase())?.hex || "#FFFFFFF" 27 | ); 28 | } 29 | 30 | export const themes = [ 31 | { 32 | group: "Base", 33 | themes: [ 34 | { 35 | value: "light", 36 | label: "Light", 37 | hex: "#000000", 38 | }, 39 | { 40 | value: "dark", 41 | label: "Dark", 42 | hex: "#FFFFFF", 43 | }, 44 | { 45 | value: "system", 46 | label: "System", 47 | hex: "auto" 48 | }, 49 | ], 50 | }, 51 | { 52 | group: "Colored", 53 | themes: [ 54 | { 55 | value: "purple", 56 | label: "Purple", 57 | hex: "#8b5cf6", 58 | }, 59 | { 60 | value: "pink", 61 | label: "Pink", 62 | hex: "#ec4899", 63 | }, 64 | { 65 | value: "blue", 66 | label: "Blue", 67 | hex: "#3b82f6", 68 | }, 69 | { 70 | value: "green", 71 | label: "Green", 72 | hex: "#16a34a", 73 | }, 74 | { 75 | value: "red", 76 | label: "Red", 77 | hex: "#f43f5e", 78 | }, 79 | { 80 | value: "orange", 81 | label: "Orange", 82 | hex: "#f97316", 83 | }, 84 | { 85 | value: "yellow", 86 | label: "Yellow", 87 | hex: "#fbbf24", 88 | } 89 | ], 90 | }, 91 | ] 92 | 93 | export function ModeToggle() { 94 | const { setTheme, theme } = useTheme() 95 | const [position, setPosition] = useState(theme!); 96 | 97 | useEffect(() => { 98 | document.documentElement.classList.remove(...Array.from(document.documentElement.classList)); 99 | document.documentElement.classList.add(theme!); 100 | }, [theme]) 101 | 102 | return ( 103 | 104 | 105 | 109 | 110 | 111 | { 112 | setPosition(position); 113 | setTheme(position); 114 | }}> 115 | {themes.map((theme) => ( 116 | 117 | {theme.group} 118 | {theme.themes.map((theme) => ( 119 | { 124 | setPosition(theme.value) 125 | setTheme(theme.value) 126 | }} 127 | > 128 | {theme.label} 129 | 130 | ))} 131 | 132 | ))} 133 | 134 | 135 | 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /components/particles.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Particles, { initParticlesEngine } from "@tsparticles/react"; 4 | import { useEffect, useMemo, useState } from "react"; 5 | import { loadSlim } from "@tsparticles/slim"; 6 | import { useTheme } from "next-themes"; 7 | import { useBackground } from "@/lib/background-provider"; 8 | import { cn } from "@/lib/utils"; 9 | import { AnimatePresence, motion } from "motion/react"; import { getHex } from "./mode-toggle"; 10 | 11 | const ParticlesComponent = ({ className }: { className: string }) => { 12 | const { resolvedTheme } = useTheme(); 13 | const [, setInit] = useState(false); 14 | const { background } = useBackground(); 15 | 16 | useEffect(() => { 17 | initParticlesEngine(async (engine) => { 18 | await loadSlim(engine); 19 | }).then(() => { 20 | setInit(true); 21 | }); 22 | }, []); 23 | 24 | const baseColor = resolvedTheme != "light" ? "" : "#FFFFFF"; 25 | const foregroundColor = resolvedTheme != "light" ? getHex(String(resolvedTheme), resolvedTheme) : "#000000"; 26 | 27 | const options = useMemo( 28 | () => ({ 29 | background: { 30 | color: { 31 | value: baseColor, 32 | }, 33 | }, 34 | fpsLimit: 120, 35 | interactivity: { 36 | events: { 37 | onClick: { 38 | enable: true, 39 | mode: "repulse", 40 | }, 41 | onHover: { 42 | enable: true, 43 | mode: 'grab', 44 | }, 45 | }, 46 | modes: { 47 | repulse: { 48 | distance: 200, 49 | duration: 0.5, 50 | }, 51 | grab: { 52 | distance: 150, 53 | line_linked: { 54 | opacity: 20 55 | } 56 | }, 57 | }, 58 | }, 59 | particles: { 60 | color: { 61 | value: foregroundColor, 62 | }, 63 | links: { 64 | color: foregroundColor, 65 | enable: false, 66 | }, 67 | move: { 68 | direction: "none" as const, 69 | enable: true, 70 | outModes: { 71 | default: "bounce" as const, 72 | }, 73 | random: true, 74 | speed: 1, 75 | straight: false, 76 | }, 77 | number: { 78 | density: { 79 | enable: true, 80 | }, 81 | value: 150, 82 | }, 83 | opacity: { 84 | value: 1.0, 85 | }, 86 | shape: { 87 | type: "circle", 88 | }, 89 | size: { 90 | value: { min: 1, max: 3 }, 91 | }, 92 | }, 93 | detectRetina: true, 94 | }), 95 | [resolvedTheme], 96 | ); 97 | 98 | return <> 99 | 100 | {background === "particles" ? 101 | 107 | 108 | :
} 109 |
110 | 111 | }; 112 | 113 | export default ParticlesComponent; -------------------------------------------------------------------------------- /components/search-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useRef, useEffect } from "react"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Button } from "./ui/button"; 6 | import { ArrowRightIcon, Loader2Icon, SearchIcon } from "lucide-react"; 7 | import { Label } from "./ui/label"; 8 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; 9 | import axios from "axios"; 10 | import { getAlbum, QobuzAlbum, QobuzSearchResults, QobuzTrack } from "@/lib/qobuz-dl"; 11 | import { Skeleton } from "./ui/skeleton"; 12 | import { AnimatePresence, motion } from "framer-motion"; 13 | 14 | const SearchBar = ({ onSearch, searching, setSearching, query }: { onSearch: (query: string, searchFieldInput?: "albums" | "tracks") => void; searching: boolean; setSearching: React.Dispatch>, query: string }) => { 15 | const [searchInput, setSearchInput] = useState(query); 16 | const [results, setResults] = useState(null); 17 | const [loading, setLoading] = useState(false); 18 | const [showCard, setShowCard] = useState(false); 19 | const [controller, setController] = useState(new AbortController()); 20 | 21 | const inputRef = useRef(null); 22 | const cardRef = useRef(null); 23 | 24 | const limit = 5; 25 | 26 | useEffect(() => { 27 | setSearchInput(query); 28 | }, [query]) 29 | 30 | useEffect(() => { 31 | if (inputRef.current) setSearchInput(inputRef.current.value); 32 | 33 | const handleKeydown = (event: KeyboardEvent) => { 34 | if (event.ctrlKey && event.key.toLowerCase() === "k") { 35 | event.preventDefault(); 36 | inputRef.current?.focus(); 37 | } 38 | }; 39 | window.addEventListener("keydown", handleKeydown); 40 | 41 | return () => { 42 | window.removeEventListener("keydown", handleKeydown); 43 | }; 44 | }, []); 45 | 46 | useEffect(() => { 47 | if (searching) controller.abort(); 48 | }, [searching]); 49 | 50 | const fetchResults = async () => { 51 | controller.abort(); 52 | if (searchInput.trim().length === 0) { 53 | return; 54 | }; 55 | 56 | setLoading(true); 57 | 58 | const newController = new AbortController(); 59 | setController(newController); 60 | 61 | try { 62 | setTimeout(async () => { 63 | try { 64 | const response = await axios.get(`/api/get-music?q=${searchInput}&offset=0`, { signal: newController.signal }); 65 | if (response.status === 200) setResults(response.data.data); 66 | } catch { } 67 | }, 200); 68 | } catch { } 69 | 70 | setLoading(false); 71 | }; 72 | 73 | useEffect(() => { 74 | fetchResults(); 75 | }, [searchInput]); 76 | 77 | return ( 78 |
79 |
inputRef.current?.focus()} 81 | className="bg-background border relative sm:w-[600px] w-full tracking-wide font-semibold rounded-md flex gap-0.5 items-center py-1 px-3" 82 | > 83 | 86 | ) => { 94 | setShowCard(true) 95 | if (event.currentTarget.value.trim().length > 0) fetchResults(); 96 | }} 97 | onBlur={() => setTimeout(() => setShowCard(false), 50)} 98 | onKeyDown={(event: React.KeyboardEvent) => { 99 | const target = event.currentTarget as HTMLInputElement; 100 | if (event.key === "Enter") { 101 | setShowCard(false); 102 | if (target.value.trim().length > 0 && !searching) { 103 | setSearching(true); 104 | onSearch(target.value.trim()); 105 | } 106 | } 107 | }} 108 | onChange={(event) => { 109 | setSearchInput(event.currentTarget.value); 110 | }} 111 | /> 112 |
113 | 127 | 128 | 129 | {(showCard && searchInput.trim().length > 0 && !searching && results && searchInput.trim() === results.query.trim()) && ( 130 | 154 | 158 | 159 | 160 | Quick Search 161 | 162 | 163 | 164 | 179 |
180 | {["albums", "tracks"].map((key, index) => ( 181 | 189 |
190 |

{key}

191 |

Showing {results?.[key as "albums" | "tracks"].items.slice(0, limit).length} of {results?.[key as "albums" | "tracks"].total}

192 |
193 | {results?.[key as "albums" | "tracks"].items.slice(0, limit).map((result: QobuzAlbum | QobuzTrack, index) => { 194 | const value = `${result.title} - ${getAlbum(result).artist.name}`; 195 | 196 | return loading ? ( 197 | 201 | ) : ( 202 | { 205 | setSearching(true); 206 | onSearch(value, key as "albums" | "tracks"); 207 | }} 208 | variants={{ 209 | hidden: { opacity: 0, y: 5 }, 210 | visible: { opacity: 1, y: 0 }, 211 | }} 212 | className="text-xs sm:text-sm hover:underline underline-offset-2 decoration-1 h-fit w-full truncate cursor-pointer justify-start text-muted-foreground" 213 | title={result.title} 214 | > 215 | {result.title} 216 | 217 | ); 218 | })} 219 | {results?.[key as "albums" | "tracks"]?.items.length === 0 && ( 220 |

221 | No Results Found 222 |

223 | )} 224 |
225 | ))} 226 |
227 |
228 |
229 |
230 |
231 | )} 232 |
233 |
234 | ); 235 | }; 236 | 237 | export default SearchBar; -------------------------------------------------------------------------------- /components/status-bar/container.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useState } from 'react' 3 | import StatusBar from './status-bar' 4 | import { cn } from '@/lib/utils'; 5 | 6 | const StatusBarContainer = () => { 7 | const [isMounted, setIsMounted] = useState(false); 8 | 9 | useEffect(() => { 10 | setIsMounted(true); 11 | }, []); 12 | 13 | if (!isMounted) return null; 14 | return ( 15 |
16 |
17 | 18 |
19 |
20 | ) 21 | } 22 | 23 | export default StatusBarContainer -------------------------------------------------------------------------------- /components/status-bar/queue-dialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog' 3 | import QueueView from './queue-view' 4 | import type { QueueProps } from './status-bar' 5 | 6 | const QueueDialog = ({ open, setOpen, queueItems }: { open: boolean, setOpen: (open: boolean) => void, queueItems: QueueProps[] | [] }) => { 7 | return ( 8 | 9 | 10 | 11 | Queue 12 | 13 | {queueItems.length > 0 14 | ? `${queueItems.length} ${queueItems.length > 1 ? 'items' : 'item'} in queue` 15 | : 'No items in the queue' 16 | } 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | export default QueueDialog -------------------------------------------------------------------------------- /components/status-bar/queue-view.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import type { QueueProps } from './status-bar' 3 | import { Card, CardHeader, CardTitle } from '../ui/card' 4 | import { Button } from '../ui/button' 5 | import { X } from 'lucide-react' 6 | import { Input } from '../ui/input' 7 | import { useStatusBar } from '@/lib/status-bar/context' 8 | import { ScrollArea } from '../ui/scroll-area' 9 | import { cn } from '@/lib/utils' 10 | import { ActivityIcon } from 'lucide-react' 11 | import { Progress } from '../ui/progress' 12 | 13 | const QueueView = ({ queueItems }: { queueItems: QueueProps[] }) => { 14 | const { statusBar, setStatusBar } = useStatusBar(); 15 | const [items, setItems] = useState(queueItems) 16 | const [search, setSearch] = useState('') 17 | 18 | useEffect(() => { 19 | const filteredItems = queueItems.filter((item) => 20 | item.title.toLowerCase().includes(search.toLowerCase()) 21 | ); 22 | setItems(filteredItems); 23 | }, [search, queueItems]); 24 | 25 | return ( 26 |
27 |
28 | setSearch(event.target.value)} 32 | /> 33 |
34 |
35 | 36 |
37 | {statusBar.processing && 38 | ( 39 | 40 | 41 | 42 | 43 |
44 | {statusBar.title} 45 | 46 |
47 |
48 | 51 |
52 |
53 | )} 54 | {items.map((item, index) => ( 55 | 56 | 57 | 58 | {item.icon != undefined && ( 59 | 60 | )} 61 | {item.title} 62 | 63 | 78 | 79 | 80 | ))} 81 |
82 |
83 | 84 | {(items.length === 0 && !statusBar.processing) && ( 85 |
86 |

No items found.

87 |
88 | )} 89 |
90 |
91 | ) 92 | } 93 | 94 | export default QueueView -------------------------------------------------------------------------------- /components/status-bar/status-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { useState } from 'react' 3 | import { ChevronUp, ChevronDown, List as QueueIcon, LucideIcon, X, DotIcon } from 'lucide-react' 4 | import { motion } from "motion/react" 5 | import { AnimatePresence } from 'motion/react' 6 | import { Button } from '../ui/button' 7 | import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card' 8 | import { Progress } from '../ui/progress' 9 | import QueueDialog from './queue-dialog' 10 | import { useStatusBar } from '@/lib/status-bar/context' 11 | 12 | export type QueueProps = { 13 | title: string, 14 | icon?: LucideIcon | null, 15 | UUID: string, 16 | remove?: () => void 17 | } 18 | 19 | export type StatusBarProps = { 20 | open: boolean, 21 | openPreference: boolean, 22 | title: string, 23 | description: string, 24 | progress: number, 25 | processing: boolean 26 | queue?: QueueProps[], 27 | onCancel?: () => void, 28 | } 29 | 30 | const StatusBar = () => { 31 | const { statusBar, setStatusBar } = useStatusBar(); 32 | const [queueOpen, setQueueOpen] = useState(false); 33 | 34 | return ( 35 | <> 36 | 37 | {statusBar.open && 38 | ( 39 | 47 | 48 | 49 | 58 |
59 | {statusBar.title || "No items in the queue"} 60 | 61 | {statusBar.description && 62 | 67 | {statusBar.description} 68 | } 69 | 70 |
71 | 79 |
80 | 81 | 82 | 91 | 92 |
93 |
94 | ) 95 | } 96 |
97 | {!statusBar.open && ( 98 | 105 | 123 | 124 | )} 125 | 126 | 131 | 132 | ) 133 | } 134 | 135 | export default StatusBar -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 74 | 75 | )) 76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 77 | 78 | const DropdownMenuItem = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef & { 81 | inset?: boolean 82 | } 83 | >(({ className, inset, ...props }, ref) => ( 84 | svg]:size-4 [&>svg]:shrink-0", 88 | inset && "pl-8", 89 | className 90 | )} 91 | {...props} 92 | /> 93 | )) 94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 95 | 96 | const DropdownMenuCheckboxItem = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, children, checked, ...props }, ref) => ( 100 | 109 | 110 | 111 | 112 | 113 | 114 | {children} 115 | 116 | )) 117 | DropdownMenuCheckboxItem.displayName = 118 | DropdownMenuPrimitive.CheckboxItem.displayName 119 | 120 | const DropdownMenuRadioItem = React.forwardRef< 121 | React.ElementRef, 122 | React.ComponentPropsWithoutRef 123 | >(({ className, children, ...props }, ref) => ( 124 | 132 | 133 | 134 | 135 | 136 | 137 | {children} 138 | 139 | )) 140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 141 | 142 | const DropdownMenuLabel = React.forwardRef< 143 | React.ElementRef, 144 | React.ComponentPropsWithoutRef & { 145 | inset?: boolean 146 | } 147 | >(({ className, inset, ...props }, ref) => ( 148 | 157 | )) 158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 159 | 160 | const DropdownMenuSeparator = React.forwardRef< 161 | React.ElementRef, 162 | React.ComponentPropsWithoutRef 163 | >(({ className, ...props }, ref) => ( 164 | 169 | )) 170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 171 | 172 | const DropdownMenuShortcut = ({ 173 | className, 174 | ...props 175 | }: React.HTMLAttributes) => { 176 | return ( 177 | 181 | ) 182 | } 183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 184 | 185 | export { 186 | DropdownMenu, 187 | DropdownMenuTrigger, 188 | DropdownMenuContent, 189 | DropdownMenuItem, 190 | DropdownMenuCheckboxItem, 191 | DropdownMenuRadioItem, 192 | DropdownMenuLabel, 193 | DropdownMenuSeparator, 194 | DropdownMenuShortcut, 195 | DropdownMenuGroup, 196 | DropdownMenuPortal, 197 | DropdownMenuSub, 198 | DropdownMenuSubContent, 199 | DropdownMenuSubTrigger, 200 | DropdownMenuRadioGroup, 201 | } 202 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )) 26 | Progress.displayName = ProgressPrimitive.Root.displayName 27 | 28 | export { Progress } 29 | -------------------------------------------------------------------------------- /components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/settings-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { useEffect, useRef, useState } from 'react' 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuRadioGroup, 7 | DropdownMenuRadioItem, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu" 10 | import { Button } from './button'; 11 | import { ChevronDownIcon, DotIcon, SettingsIcon } from 'lucide-react'; 12 | import { SettingsProps, useSettings } from '@/lib/settings-provider'; 13 | import { useBackground } from '@/lib/background-provider'; 14 | import { Checkbox } from "@/components/ui/checkbox" 15 | 16 | import { 17 | Sheet, 18 | SheetContent, 19 | SheetDescription, 20 | SheetHeader, 21 | SheetTitle, 22 | } from "@/components/ui/sheet" 23 | import { ModeToggle } from '../mode-toggle'; 24 | import { Separator } from './separator'; 25 | import { Input } from './input'; 26 | import { cn } from '@/lib/utils'; 27 | 28 | const losslessCodecs = ['FLAC', 'ALAC', 'WAV']; 29 | 30 | const qualityMap = { 31 | "27": [24, 192], 32 | "7": [24, 96], 33 | "6": [16, 44.1] 34 | } 35 | 36 | const SettingsForm = () => { 37 | const { settings, setSettings } = useSettings(); 38 | const { background, setBackground } = useBackground(); 39 | 40 | const [open, setOpen] = useState(false); 41 | 42 | const bitrateInput = useRef(null); 43 | 44 | useEffect(() => { 45 | if ((!open) && bitrateInput.current) { 46 | let numberInput = parseInt(bitrateInput.current.value); 47 | if (isNaN(numberInput)) numberInput = 320; 48 | if (numberInput > 320) numberInput = 320; 49 | if (numberInput < 24) numberInput = 320; 50 | setSettings(prev => ({ ...prev, bitrate: numberInput || 320 })); 51 | } 52 | }, [open]) 53 | 54 | return ( 55 | 56 | 59 | 60 |
61 | 62 |
63 | Theme 64 | 65 | Change the way {process.env.NEXT_PUBLIC_APPLICATION_NAME} looks 66 | 67 |
68 | 69 |
70 | 71 | 72 |
73 | Background 74 | 75 | Change the background of {process.env.NEXT_PUBLIC_APPLICATION_NAME} 76 | 77 |
78 | 79 | 80 | 84 | 85 | 86 | 87 | Particles 88 | Solid Color 89 | 90 | 91 | 92 |
93 | 94 | 95 |
96 | Output Settings 97 | 98 | Change the way your music is saved 99 | 100 |
101 |
102 |

Output Codec

103 | 104 | 105 | 109 | 110 | 111 | { 112 | setSettings(settings => ({ ...settings, outputCodec: codec as SettingsProps['outputCodec'] })); 113 | if (!losslessCodecs.includes(codec)) { 114 | setSettings(settings => ({ ...settings, outputQuality: settings.outputCodec === "OPUS" ? "6" as const : "5" as const, bitrate: settings.bitrate || 320 })); 115 | } else { 116 | setSettings(settings => { 117 | if (settings.outputQuality === "5") return { ...settings, outputQuality: "27" as const, bitrate: undefined }; 118 | else return { ...settings, bitrate: undefined }; 119 | }); 120 | } 121 | }}> 122 | FLAC (recommended) 123 | WAV 124 | ALAC 125 | MP3 126 | AAC 127 | OPUS 128 | 129 | 130 | 131 |
132 | {losslessCodecs.includes(settings.outputCodec) ? ( 133 |
134 |

Max Download Quality

135 | 136 | 137 | 141 | 142 | 143 | { 144 | setSettings(settings => ({ ...settings, outputQuality: quality as SettingsProps['outputQuality'] })); 145 | }}> 146 | 147 |

24-bit

148 | 149 |

192kHz

150 |
151 | 152 |

24-bit

153 | 154 |

96kHz

155 |
156 | 157 |

16-bit

158 | 159 |

44.1kHz

160 |
161 |
162 |
163 |
164 |
165 | ) : ( 166 | <> 167 |

Lossy codec selected. All music will be downloaded at 320kbps. You can specify a bitrate to rencode to below.

168 |
169 | 170 |

kbps

171 |
172 | 173 | )} 174 |
175 |
176 |

Apply metadata

177 |

If enabled (default), songs will be tagged with cover art, album information, etc.

178 |
179 | setSettings(settings => ({ ...settings, applyMetadata: checked }))} disabled={settings.outputCodec === "WAV"} /> 180 |
181 | {settings.outputCodec === "OPUS" &&

WARNING: OGG (OPUS) files do not support album art.

} 182 | {settings.outputCodec === "WAV" &&

WAV files do not support metadata / tags.

} 183 |
184 | 185 | 186 |
187 |
188 |

Fix MD5 Hash

189 |

If enabled (default), MD5 hashes will be fixed, improving compatiablity with old software. This will take longer to download.

190 |
191 | setSettings(settings => ({ ...settings, fixMD5: checked }))} /> 192 |
193 |
194 | 195 | 196 |
197 |
198 |

Allow Explicit content

199 |

If enabled (default), explicit songs will be shown when searching.

200 |
201 | setSettings(settings => ({ ...settings, explicitContent: checked }))} /> 202 |
203 |
204 |
205 |
206 |
207 | ) 208 | } 209 | 210 | 211 | export const parseQualityHTML = (quality: string) => { 212 | try { 213 | return ( 214 |
215 |

{qualityMap[quality as keyof typeof qualityMap][0]}-bit

216 | 217 |

{qualityMap[quality as keyof typeof qualityMap][1]} kHz

218 |
219 | ); 220 | } catch { 221 | return quality; 222 | } 223 | } 224 | 225 | export default SettingsForm -------------------------------------------------------------------------------- /components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const Sheet = SheetPrimitive.Root 11 | 12 | const SheetTrigger = SheetPrimitive.Trigger 13 | 14 | const SheetClose = SheetPrimitive.Close 15 | 16 | const SheetPortal = SheetPrimitive.Portal 17 | 18 | const SheetOverlay = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 30 | )) 31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 32 | 33 | const sheetVariants = cva( 34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", 35 | { 36 | variants: { 37 | side: { 38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 39 | bottom: 40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 42 | right: 43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 44 | }, 45 | }, 46 | defaultVariants: { 47 | side: "right", 48 | }, 49 | } 50 | ) 51 | 52 | interface SheetContentProps 53 | extends React.ComponentPropsWithoutRef, 54 | VariantProps {} 55 | 56 | const SheetContent = React.forwardRef< 57 | React.ElementRef, 58 | SheetContentProps 59 | >(({ side = "right", className, children, ...props }, ref) => ( 60 | 61 | 62 | 67 | 68 | 69 | Close 70 | 71 | {children} 72 | 73 | 74 | )) 75 | SheetContent.displayName = SheetPrimitive.Content.displayName 76 | 77 | const SheetHeader = ({ 78 | className, 79 | ...props 80 | }: React.HTMLAttributes) => ( 81 |
88 | ) 89 | SheetHeader.displayName = "SheetHeader" 90 | 91 | const SheetFooter = ({ 92 | className, 93 | ...props 94 | }: React.HTMLAttributes) => ( 95 |
102 | ) 103 | SheetFooter.displayName = "SheetFooter" 104 | 105 | const SheetTitle = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SheetTitle.displayName = SheetPrimitive.Title.displayName 116 | 117 | const SheetDescription = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, ...props }, ref) => ( 121 | 126 | )) 127 | SheetDescription.displayName = SheetPrimitive.Description.displayName 128 | 129 | export { 130 | Sheet, 131 | SheetPortal, 132 | SheetOverlay, 133 | SheetTrigger, 134 | SheetClose, 135 | SheetContent, 136 | SheetHeader, 137 | SheetFooter, 138 | SheetTitle, 139 | SheetDescription, 140 | } 141 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { forwardRef } from "react" 3 | 4 | const Skeleton = forwardRef>( 5 | ({ className, ...props }, ref) => { 6 | return ( 7 |
12 | ); 13 | } 14 | ); 15 | 16 | Skeleton.displayName = 'Skeleton'; 17 | 18 | export { Skeleton } 19 | -------------------------------------------------------------------------------- /components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ToastPrimitives from "@radix-ui/react-toast" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const ToastProvider = ToastPrimitives.Provider 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: 34 | "destructive group border-destructive bg-destructive text-destructive-foreground", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | } 41 | ) 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & 46 | VariantProps 47 | >(({ className, variant, ...props }, ref) => { 48 | return ( 49 | 54 | ) 55 | }) 56 | Toast.displayName = ToastPrimitives.Root.displayName 57 | 58 | const ToastAction = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )) 71 | ToastAction.displayName = ToastPrimitives.Action.displayName 72 | 73 | const ToastClose = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, ...props }, ref) => ( 77 | 86 | 87 | 88 | )) 89 | ToastClose.displayName = ToastPrimitives.Close.displayName 90 | 91 | const ToastTitle = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName 102 | 103 | const ToastDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName 114 | 115 | type ToastProps = React.ComponentPropsWithoutRef 116 | 117 | type ToastActionElement = React.ReactElement 118 | 119 | export { 120 | type ToastProps, 121 | type ToastActionElement, 122 | ToastProvider, 123 | ToastViewport, 124 | Toast, 125 | ToastTitle, 126 | ToastDescription, 127 | ToastClose, 128 | ToastAction, 129 | } 130 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | rules: { 16 | "@typescript-eslint/no-explicit-any": "off", 17 | "@next/next/no-img-element": "off", 18 | "@next/next/no-sync-scripts": "off", 19 | "react-hooks/exhaustive-deps": "off", 20 | }, 21 | }, 22 | ]; 23 | 24 | export default eslintConfig; 25 | -------------------------------------------------------------------------------- /hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react" 5 | 6 | import type { 7 | ToastActionElement, 8 | ToastProps, 9 | } from "@/components/ui/toast" 10 | 11 | const TOAST_LIMIT = 1 12 | const TOAST_REMOVE_DELAY = 1000000 13 | 14 | type ToasterToast = ToastProps & { 15 | id: string 16 | title?: React.ReactNode 17 | description?: React.ReactNode 18 | action?: ToastActionElement 19 | } 20 | 21 | const actionTypes = { 22 | ADD_TOAST: "ADD_TOAST", 23 | UPDATE_TOAST: "UPDATE_TOAST", 24 | DISMISS_TOAST: "DISMISS_TOAST", 25 | REMOVE_TOAST: "REMOVE_TOAST", 26 | } as const 27 | 28 | let count = 0 29 | 30 | function genId() { 31 | count = (count + 1) % Number.MAX_SAFE_INTEGER 32 | return count.toString() 33 | } 34 | 35 | type ActionType = typeof actionTypes 36 | 37 | type Action = 38 | | { 39 | type: ActionType["ADD_TOAST"] 40 | toast: ToasterToast 41 | } 42 | | { 43 | type: ActionType["UPDATE_TOAST"] 44 | toast: Partial 45 | } 46 | | { 47 | type: ActionType["DISMISS_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | | { 51 | type: ActionType["REMOVE_TOAST"] 52 | toastId?: ToasterToast["id"] 53 | } 54 | 55 | interface State { 56 | toasts: ToasterToast[] 57 | } 58 | 59 | const toastTimeouts = new Map>() 60 | 61 | const addToRemoveQueue = (toastId: string) => { 62 | if (toastTimeouts.has(toastId)) { 63 | return 64 | } 65 | 66 | const timeout = setTimeout(() => { 67 | toastTimeouts.delete(toastId) 68 | dispatch({ 69 | type: "REMOVE_TOAST", 70 | toastId: toastId, 71 | }) 72 | }, TOAST_REMOVE_DELAY) 73 | 74 | toastTimeouts.set(toastId, timeout) 75 | } 76 | 77 | export const reducer = (state: State, action: Action): State => { 78 | switch (action.type) { 79 | case "ADD_TOAST": 80 | return { 81 | ...state, 82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 83 | } 84 | 85 | case "UPDATE_TOAST": 86 | return { 87 | ...state, 88 | toasts: state.toasts.map((t) => 89 | t.id === action.toast.id ? { ...t, ...action.toast } : t 90 | ), 91 | } 92 | 93 | case "DISMISS_TOAST": { 94 | const { toastId } = action 95 | 96 | // ! Side effects ! - This could be extracted into a dismissToast() action, 97 | // but I'll keep it here for simplicity 98 | if (toastId) { 99 | addToRemoveQueue(toastId) 100 | } else { 101 | state.toasts.forEach((toast) => { 102 | addToRemoveQueue(toast.id) 103 | }) 104 | } 105 | 106 | return { 107 | ...state, 108 | toasts: state.toasts.map((t) => 109 | t.id === toastId || toastId === undefined 110 | ? { 111 | ...t, 112 | open: false, 113 | } 114 | : t 115 | ), 116 | } 117 | } 118 | case "REMOVE_TOAST": 119 | if (action.toastId === undefined) { 120 | return { 121 | ...state, 122 | toasts: [], 123 | } 124 | } 125 | return { 126 | ...state, 127 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 128 | } 129 | } 130 | } 131 | 132 | const listeners: Array<(state: State) => void> = [] 133 | 134 | let memoryState: State = { toasts: [] } 135 | 136 | function dispatch(action: Action) { 137 | memoryState = reducer(memoryState, action) 138 | listeners.forEach((listener) => { 139 | listener(memoryState) 140 | }) 141 | } 142 | 143 | type Toast = Omit 144 | 145 | function toast({ ...props }: Toast) { 146 | const id = genId() 147 | 148 | const update = (props: ToasterToast) => 149 | dispatch({ 150 | type: "UPDATE_TOAST", 151 | toast: { ...props, id }, 152 | }) 153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 154 | 155 | dispatch({ 156 | type: "ADD_TOAST", 157 | toast: { 158 | ...props, 159 | id, 160 | open: true, 161 | onOpenChange: (open) => { 162 | if (!open) dismiss() 163 | }, 164 | }, 165 | }) 166 | 167 | return { 168 | id: id, 169 | dismiss, 170 | update, 171 | } 172 | } 173 | 174 | function useToast() { 175 | const [state, setState] = React.useState(memoryState) 176 | 177 | React.useEffect(() => { 178 | listeners.push(setState) 179 | return () => { 180 | const index = listeners.indexOf(setState) 181 | if (index > -1) { 182 | listeners.splice(index, 1) 183 | } 184 | } 185 | }, [state]) 186 | 187 | return { 188 | ...state, 189 | toast, 190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 191 | } 192 | } 193 | 194 | export { useToast, toast } 195 | -------------------------------------------------------------------------------- /lib/background-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { createContext, ReactNode, useContext, useEffect, useState } from "react"; 3 | 4 | const BackgroundContext = createContext<{ 5 | background: string; 6 | setBackground: React.Dispatch>; 7 | } | undefined>(undefined); 8 | 9 | export const useBackground = () => { 10 | const context = useContext(BackgroundContext); 11 | 12 | if (!context) { 13 | throw new Error('useBackground must be used within a SettingsProvider'); 14 | } 15 | 16 | return context; 17 | } 18 | 19 | export const BackgroundProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 20 | const [background, setBackground] = useState("particles"); 21 | 22 | useEffect(() => { 23 | const savedBackground = localStorage.getItem('background'); 24 | if (savedBackground && ["particles", "solid color"].includes(savedBackground)) { 25 | setBackground(savedBackground); 26 | } 27 | }, []) 28 | 29 | useEffect(() => { 30 | localStorage.setItem("background", background); 31 | }, [background]); 32 | 33 | return ( 34 | 35 | {children} 36 | 37 | ); 38 | }; -------------------------------------------------------------------------------- /lib/download-job.tsx: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from "axios"; 2 | import { applyMetadata, codecMap, FFmpegType, fixMD5Hash, loadFFmpeg } from "./ffmpeg-functions"; 3 | import { FetchedQobuzAlbum, formatArtists, formatTitle, getFullResImage, QobuzAlbum, QobuzArtistResults, QobuzTrack } from "./qobuz-dl"; 4 | import { createJob } from "./status-bar/jobs"; 5 | import { StatusBarProps } from "@/components/status-bar/status-bar"; 6 | import saveAs from "file-saver"; 7 | import { cleanFileName, formatBytes } from "./utils"; 8 | import { Disc3Icon, DiscAlbumIcon } from "lucide-react"; 9 | import { SettingsProps } from "./settings-provider"; 10 | import { ToastAction } from "@/components/ui/toast"; 11 | import { zipSync } from "fflate"; 12 | import { artistReleaseCategories } from "@/components/artist-dialog"; 13 | 14 | export const createDownloadJob = async (result: QobuzAlbum | QobuzTrack, setStatusBar: React.Dispatch>, ffmpegState: FFmpegType, settings: SettingsProps, toast: (toast: any) => void, fetchedAlbumData?: FetchedQobuzAlbum | null, setFetchedAlbumData?: React.Dispatch>) => { 15 | if ((result as QobuzTrack).album) { 16 | const formattedTitle = formatArtists(result) + " - " + formatTitle(result) 17 | await createJob(setStatusBar, formattedTitle, Disc3Icon, async () => { 18 | return new Promise(async (resolve) => { 19 | try { 20 | const controller = new AbortController(); 21 | const signal = controller.signal; 22 | let cancelled = false; 23 | setStatusBar(prev => ({ 24 | ...prev, progress: 0, title: `Downloading ${formatTitle(result)}`, description: `Loading FFmpeg`, onCancel: () => { 25 | cancelled = true; 26 | controller.abort(); 27 | } 28 | })) 29 | if (settings.applyMetadata || !((settings.outputQuality === "27" && settings.outputCodec === "FLAC") || (settings.bitrate === 320 && settings.outputCodec === "MP3"))) await loadFFmpeg(ffmpegState, signal); 30 | setStatusBar(prev => ({ ...prev, description: "Fetching track size..." })) 31 | const APIResponse = await axios.get("/api/download-music", { params: { track_id: (result as QobuzTrack).id, quality: settings.outputQuality }, signal }); 32 | const trackURL = APIResponse.data.data.url; 33 | const fileSizeResponse = await axios.head(trackURL, { signal }); 34 | const fileSize = fileSizeResponse.headers["content-length"]; 35 | const response = await axios.get(trackURL, { 36 | responseType: 'arraybuffer', 37 | onDownloadProgress: (progressEvent) => { 38 | setStatusBar(statusbar => { 39 | if (statusbar.processing && !cancelled) return { ...statusbar, progress: Math.floor(progressEvent.loaded / fileSize * 100), description: `${formatBytes(progressEvent.loaded)} / ${formatBytes(fileSize)}` } 40 | else return statusbar; 41 | }) 42 | }, 43 | signal 44 | }); 45 | setStatusBar(prev => ({ ...prev, description: `Applying metadata...`, progress: 100 })) 46 | const inputFile = response.data; 47 | let outputFile = await applyMetadata(inputFile, result as QobuzTrack, ffmpegState, settings, setStatusBar); 48 | if (settings.outputCodec === "FLAC" && settings.fixMD5) outputFile = await fixMD5Hash(outputFile, setStatusBar); 49 | const objectURL = URL.createObjectURL(new Blob([outputFile])); 50 | saveAs(objectURL, formattedTitle + "." + codecMap[settings.outputCodec].extension); 51 | setTimeout(() => { 52 | URL.revokeObjectURL(objectURL); 53 | }, 100) 54 | resolve(); 55 | } catch (e) { 56 | if (e instanceof AxiosError && e.code === 'ERR_CANCELED') resolve(); 57 | else { 58 | toast({ 59 | title: "Error", 60 | description: e instanceof Error ? e.message : 'An unknown error occurred', 61 | action: navigator.clipboard.writeText((e as Error).stack!)}>Copy Stack, 62 | }) 63 | resolve() 64 | } 65 | } 66 | }) 67 | }) 68 | } else { 69 | const formattedTitle = formatArtists(result) + " - " + formatTitle(result) 70 | await createJob(setStatusBar, formattedTitle, DiscAlbumIcon, async () => { 71 | return new Promise(async (resolve) => { 72 | try { 73 | const controller = new AbortController(); 74 | const signal = controller.signal; 75 | let cancelled = false; 76 | setStatusBar(prev => ({ 77 | ...prev, progress: 0, title: `Downloading ${formatTitle(result)}`, description: `Loading FFmpeg...`, onCancel: () => { 78 | cancelled = true; 79 | controller.abort(); 80 | } 81 | })) 82 | if (settings.applyMetadata || !((settings.outputQuality === "27" && settings.outputCodec === "FLAC") || (settings.bitrate === 320 && settings.outputCodec === "MP3"))) await loadFFmpeg(ffmpegState, signal); 83 | setStatusBar(prev => ({ ...prev, description: "Fetching album data..." })); 84 | if (!fetchedAlbumData) { 85 | const albumDataResponse = await axios.get("/api/get-album", { params: { album_id: (result as QobuzAlbum).id }, signal }); 86 | if (setFetchedAlbumData) { 87 | setFetchedAlbumData(albumDataResponse.data.data); 88 | } 89 | fetchedAlbumData = albumDataResponse.data.data 90 | } 91 | const albumTracks = fetchedAlbumData!.tracks.items.map((track: QobuzTrack) => ({ ...track, album: fetchedAlbumData })) as QobuzTrack[]; 92 | let totalAlbumSize = 0; 93 | const albumUrls = [] as string[]; 94 | setStatusBar(prev => ({ ...prev, description: "Fetching album size..." })); 95 | let currentDisk = 1; 96 | let trackOffset = 0; 97 | for (const [index, track] of albumTracks.entries()) { 98 | if (track.streamable) { 99 | const fileURLResponse = await axios.get("/api/download-music", { params: { track_id: track.id, quality: settings.outputQuality }, signal }); 100 | const trackURL = fileURLResponse.data.data.url; 101 | if (!(currentDisk === track.media_number)) { 102 | trackOffset = albumUrls.length; 103 | currentDisk = track.media_number; 104 | albumUrls.push(trackURL); 105 | } else albumUrls[track.track_number + trackOffset - 1] = trackURL; 106 | const fileSizeResponse = await axios.head(trackURL, { signal }); 107 | setStatusBar(statusBar => ({ ...statusBar, progress: (100 / albumTracks.length) * (index + 1) })); 108 | const fileSize = parseInt(fileSizeResponse.headers["content-length"]); 109 | totalAlbumSize += fileSize; 110 | } 111 | } 112 | const trackBuffers = [] as ArrayBuffer[]; 113 | let totalBytesDownloaded = 0; 114 | setStatusBar(statusBar => ({ ...statusBar, progress: 0, description: `Fetching album art...` })); 115 | const albumArtURL = await getFullResImage(fetchedAlbumData!); 116 | const albumArt = albumArtURL ? (await axios.get(albumArtURL, { responseType: 'arraybuffer' })).data : false; 117 | for (const [index, url] of albumUrls.entries()) { 118 | if (url) { 119 | const response = await axios.get(url, { 120 | responseType: 'arraybuffer', 121 | onDownloadProgress: (progressEvent) => { 122 | if (totalBytesDownloaded + progressEvent.loaded < totalAlbumSize) setStatusBar(statusBar => { 123 | if (statusBar.processing && !cancelled) return { ...statusBar, progress: Math.floor((totalBytesDownloaded + progressEvent.loaded) / totalAlbumSize * 100), description: `${formatBytes(totalBytesDownloaded + progressEvent.loaded)} / ${formatBytes(totalAlbumSize)}` } 124 | else return statusBar; 125 | }); 126 | }, 127 | signal 128 | }) 129 | await new Promise(resolve => setTimeout(resolve, 100)); 130 | totalBytesDownloaded += response.data.byteLength; 131 | const inputFile = response.data; 132 | let outputFile = await applyMetadata(inputFile, albumTracks[index], ffmpegState, settings, undefined, albumArt, fetchedAlbumData!.upc); 133 | if (settings.outputCodec === "FLAC" && settings.fixMD5) outputFile = await (await fixMD5Hash(outputFile)).arrayBuffer(); 134 | trackBuffers[index] = outputFile; 135 | } 136 | } 137 | setStatusBar(statusBar => ({ ...statusBar, progress: 0, description: `Zipping album...` })); 138 | await new Promise(resolve => setTimeout(resolve, 500)); 139 | const zipFiles = { 140 | "cover.jpg": new Uint8Array(albumArt), 141 | ...trackBuffers.reduce((acc, buffer, index) => { 142 | if (buffer) { 143 | const fileName = `${(index + 1).toString().padStart(Math.max(String(albumTracks.length - 1).length, 2), '0')} ${formatTitle(albumTracks[index])}.${codecMap[settings.outputCodec].extension}`; 144 | acc[cleanFileName(fileName)] = new Uint8Array(buffer); 145 | } 146 | return acc; 147 | }, {} as { [key: string]: Uint8Array }) 148 | } as { [key: string]: Uint8Array }; 149 | if (albumArt === false) delete zipFiles["cover.jpg"]; 150 | const zippedFile = zipSync(zipFiles, { level: 0 }); 151 | const zipBlob = new Blob([zippedFile], { type: 'application/zip' }); 152 | setStatusBar(prev => ({ ...prev, progress: 100 })); 153 | const objectURL = URL.createObjectURL(zipBlob); 154 | saveAs(objectURL, formattedTitle + ".zip"); 155 | setTimeout(() => { 156 | URL.revokeObjectURL(objectURL); 157 | }, 100); 158 | resolve(); 159 | } catch (e) { 160 | if (e instanceof AxiosError && e.code === 'ERR_CANCELED') resolve(); 161 | else { 162 | toast({ 163 | title: "Error", 164 | description: e instanceof Error ? e.message : 'An unknown error occurred', 165 | action: navigator.clipboard.writeText((e as Error).stack!)}>Copy Stack, 166 | }) 167 | resolve() 168 | } 169 | } 170 | }) 171 | }) 172 | } 173 | } 174 | 175 | export async function downloadArtistDiscography(artistResults: QobuzArtistResults, setArtistResults: React.Dispatch>, fetchMore: (searchField: any, artistResults: QobuzArtistResults) => Promise, type: "album" | "epSingle" | "live" | "compilation" | "all", setStatusBar: React.Dispatch>, settings: SettingsProps, toast: (toast: any) => void, ffmpegState: FFmpegType) { 176 | let types: ("album" | "epSingle" | "live" | "compilation")[] = []; 177 | if (type === "all") types = ["album", "epSingle", "live", "compilation"] 178 | else types = [type]; 179 | for (const type of types) { 180 | while (artistResults.artist.releases[type].has_more) { 181 | await fetchMore(type, artistResults); 182 | artistResults = await loadArtistResults(setArtistResults) as QobuzArtistResults; 183 | } 184 | for (const release of artistResults.artist.releases[type].items) { 185 | await createDownloadJob(release, setStatusBar, ffmpegState, settings, toast); 186 | } 187 | } 188 | toast({ title: `Added all ${artistReleaseCategories.find(category => category.value === type)?.label ?? "releases"} by '${artistResults.artist.name.display}'`, description: "All releases have been added to the queue" }); 189 | } 190 | 191 | export async function loadArtistResults(setArtistResults: React.Dispatch>): Promise { 192 | return new Promise((resolve) => { 193 | setArtistResults((prev: QobuzArtistResults | null) => (resolve(prev), prev)) 194 | }); 195 | } -------------------------------------------------------------------------------- /lib/ffmpeg-functions.tsx: -------------------------------------------------------------------------------- 1 | import { formatArtists, formatTitle, getAlbum, getFullResImage, QobuzTrack } from "./qobuz-dl"; 2 | import axios from "axios"; 3 | import { SettingsProps } from "./settings-provider"; 4 | import { StatusBarProps } from "@/components/status-bar/status-bar"; 5 | 6 | declare const FFmpeg: { createFFmpeg: any, fetchFile: any }; 7 | 8 | export type FFmpegType = { 9 | FS: (action: string, filename: string, fileData?: Uint8Array) => Promise; 10 | run: (...args: string[]) => Promise; 11 | isLoaded: () => boolean; 12 | load: ({ signal }: { signal: AbortSignal }) => Promise; 13 | } 14 | 15 | export const codecMap = { 16 | FLAC: { 17 | extension: "flac", 18 | codec: "flac" 19 | }, 20 | WAV: { 21 | extension: "wav", 22 | codec: "pcm_s16le" 23 | }, 24 | ALAC: { 25 | extension: "m4a", 26 | codec: "alac" 27 | }, 28 | MP3: { 29 | extension: "mp3", 30 | codec: "libmp3lame" 31 | }, 32 | AAC: { 33 | extension: "m4a", 34 | codec: "aac" 35 | }, 36 | OPUS: { 37 | extension: "opus", 38 | codec: "libopus" 39 | } 40 | } 41 | 42 | export async function applyMetadata(trackBuffer: ArrayBuffer, resultData: QobuzTrack, ffmpeg: FFmpegType, settings: SettingsProps, setStatusBar?: React.Dispatch>, albumArt?: ArrayBuffer | false, upc?: string) { 43 | const skipRencode = (settings.outputQuality != "5" && settings.outputCodec === "FLAC") || (settings.outputQuality === "5" && settings.outputCodec === "MP3" && settings.bitrate === 320); 44 | if (skipRencode && !settings.applyMetadata) return trackBuffer; 45 | const extension = codecMap[settings.outputCodec].extension; 46 | if (!skipRencode) { 47 | const inputExtension = settings.outputQuality === "5" ? "mp3" : "flac"; 48 | if (setStatusBar) setStatusBar(prev => { 49 | if (prev.processing) { 50 | return { ...prev, description: "Re-encoding track..." } 51 | } else return prev; 52 | }) 53 | await ffmpeg.FS("writeFile", "input." + inputExtension, new Uint8Array(trackBuffer)); 54 | await ffmpeg.run("-i", "input." + inputExtension, "-c:a", codecMap[settings.outputCodec].codec, settings.bitrate ? "-b:a" : "", settings.bitrate ? settings.bitrate + "k" : "", ["OPUS"].includes(settings.outputCodec) ? "-vbr" : "", ["OPUS"].includes(settings.outputCodec) ? "on" : "", "output." + extension); 55 | trackBuffer = await ffmpeg.FS("readFile", "output." + extension); 56 | await ffmpeg.FS("unlink", "input." + inputExtension); 57 | await ffmpeg.FS("unlink", "output." + extension); 58 | } 59 | if (!settings.applyMetadata) return trackBuffer; 60 | if (settings.outputCodec === "WAV") return trackBuffer; 61 | if (setStatusBar) setStatusBar(prev => ({ ...prev, description: "Applying metadata..." })) 62 | const artists = resultData.album.artists === undefined ? [resultData.performer] : resultData.album.artists; 63 | let metadata = `;FFMETADATA1` 64 | metadata += `\ntitle=${formatTitle(resultData)}`; 65 | if (artists.length > 0) { 66 | metadata += `\nartist=${formatArtists(resultData)}`; 67 | metadata += `\nalbum_artist=${formatArtists(resultData)}` 68 | } else { 69 | metadata += `\nartist=Various Artists`; 70 | metadata += `\nalbum_artist=Various Artists`; 71 | } 72 | metadata += `\nalbum_artist=${artists[0]?.name || resultData.performer?.name || "Various Artists"}` 73 | metadata += `\nalbum=${formatTitle(resultData.album)}` 74 | metadata += `\ngenre=${resultData.album.genre.name}` 75 | metadata += `\ndate=${resultData.album.release_date_original}` 76 | metadata += `\nyear=${new Date(resultData.album.release_date_original).getFullYear()}` 77 | metadata += `\nlabel=${getAlbum(resultData).label.name}` 78 | metadata += `\ncopyright=${resultData.copyright}` 79 | if (resultData.isrc) metadata += `\nisrc=${resultData.isrc}`; 80 | if (upc) metadata += `\nbarcode=${upc}`; 81 | if (resultData.track_number) metadata += `\ntrack=${resultData.track_number}`; 82 | await ffmpeg.FS("writeFile", "input." + extension, new Uint8Array(trackBuffer)); 83 | const encoder = new TextEncoder(); 84 | await ffmpeg.FS("writeFile", "metadata.txt", encoder.encode(metadata)); 85 | if (!(albumArt === false)) { 86 | if (!albumArt) { 87 | const albumArtURL = await getFullResImage(resultData); 88 | if (albumArtURL) { 89 | albumArt = (await axios.get(await getFullResImage(resultData) as string, { responseType: 'arraybuffer' })).data; 90 | } else albumArt = false 91 | } 92 | if (albumArt) await ffmpeg.FS("writeFile", "albumArt.jpg", new Uint8Array(albumArt ? albumArt : (await axios.get(await getFullResImage(resultData) as string, { responseType: 'arraybuffer' })).data)) 93 | }; 94 | 95 | await ffmpeg.run( 96 | "-i", "input." + extension, 97 | "-i", "metadata.txt", 98 | "-map_metadata", "1", 99 | "-codec", "copy", 100 | "secondInput." + extension 101 | ); 102 | if (["WAV", "OPUS"].includes(settings.outputCodec) || (albumArt === false)) { 103 | const output = await ffmpeg.FS("readFile", "secondInput." + extension); 104 | ffmpeg.FS("unlink", "input." + extension); 105 | ffmpeg.FS("unlink", "metadata.txt"); 106 | ffmpeg.FS("unlink", "secondInput." + extension); 107 | return output; 108 | }; 109 | await ffmpeg.run( 110 | '-i', 'secondInput.' + extension, 111 | '-i', 'albumArt.jpg', 112 | '-c', 'copy', 113 | '-map', '0', 114 | '-map', '1', 115 | '-disposition:v:0', 'attached_pic', 116 | 'output.' + extension 117 | ); 118 | const output = await ffmpeg.FS("readFile", "output." + extension); 119 | ffmpeg.FS("unlink", "input." + extension); 120 | ffmpeg.FS("unlink", "metadata.txt"); 121 | ffmpeg.FS("unlink", "secondInput." + extension); 122 | ffmpeg.FS("unlink", "albumArt.jpg"); 123 | return output; 124 | } 125 | 126 | export async function fixMD5Hash(trackBuffer: ArrayBuffer, setStatusBar?: React.Dispatch>): Promise { 127 | return new Promise((resolve) => { 128 | setStatusBar?.(prev => ({ ...prev, description: "Fixing MD5 hash...", progress: 0 })) 129 | const worker = new Worker('flac/EmsWorkerProxy.js'); 130 | worker.onmessage = function (e) { 131 | if (e.data && e.data.reply === 'progress') { 132 | const vals = e.data.values; 133 | if (vals[1]) { 134 | setStatusBar?.(prev => ({...prev, progress: Math.floor(vals[0] / vals[1] * 100)})) 135 | } 136 | } else if (e.data && e.data.reply === 'done') { 137 | for (const fileName in e.data.values) { 138 | resolve(e.data.values[fileName].blob); 139 | } 140 | } 141 | }; 142 | worker.postMessage({ 143 | command: 'encode', 144 | args: ["input.flac", "-o", "output.flac"], 145 | outData: { 146 | "output.flac": { 147 | MIME: "audio/flac", 148 | }, 149 | }, 150 | fileData: { 151 | "input.flac": new Uint8Array(trackBuffer) 152 | } 153 | }); 154 | }) 155 | } 156 | 157 | export function createFFmpeg() { 158 | if (typeof FFmpeg === 'undefined') return null; 159 | const { createFFmpeg } = FFmpeg; 160 | const ffmpeg = createFFmpeg({ log: false }); 161 | return ffmpeg; 162 | } 163 | 164 | export async function loadFFmpeg(ffmpeg: FFmpegType, signal: AbortSignal) { 165 | if (!ffmpeg.isLoaded()) { 166 | await ffmpeg.load({ signal }); 167 | return ffmpeg; 168 | } 169 | } -------------------------------------------------------------------------------- /lib/ffmpeg-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { createContext, useContext, useState, ReactNode } from 'react'; 3 | import { createFFmpeg, FFmpegType } from './ffmpeg-functions'; 4 | 5 | const FFmpegContext = createContext<{ 6 | ffmpegState: FFmpegType; 7 | setFFmpeg: React.Dispatch>; 8 | } | undefined>(undefined); 9 | 10 | export const FFmpegProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 11 | const [ffmpegState, setFFmpeg] = useState(() => 12 | typeof window !== 'undefined' ? createFFmpeg() : null 13 | ); 14 | 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | }; 21 | 22 | export const useFFmpeg = () => { 23 | const context = useContext(FFmpegContext); 24 | 25 | if (!context) { 26 | throw new Error('useFFmpeg must be used within a FFmpegProvider'); 27 | } 28 | 29 | return context; 30 | }; -------------------------------------------------------------------------------- /lib/qobuz-dl.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { LucideIcon } from "lucide-react"; 3 | 4 | let crypto: any; 5 | let SocksProxyAgent: any; 6 | if (typeof window === "undefined") { 7 | crypto = await import('node:crypto'); 8 | SocksProxyAgent = (await import('socks-proxy-agent'))['SocksProxyAgent']; 9 | } 10 | 11 | export type QobuzGenre = { 12 | path: number[], 13 | color: string, 14 | name: string, 15 | id: number 16 | } 17 | 18 | export type QobuzLabel = { 19 | name: string, 20 | id: number, 21 | albums_count: number 22 | } 23 | 24 | export type QobuzArtist = { 25 | image: { 26 | small: string, 27 | medium: string, 28 | large: string, 29 | extralarge: string, 30 | mega: string 31 | } | null, 32 | name: string, 33 | id: number, 34 | albums_count: number 35 | } 36 | 37 | export type QobuzTrack = { 38 | isrc: string | null, 39 | copyright: string, 40 | maximum_bit_depth: number, 41 | maximum_sampling_rate: number, 42 | performer: { 43 | name: string, 44 | id: number 45 | }, 46 | album: QobuzAlbum, 47 | track_number: number, 48 | released_at: number, 49 | title: string, 50 | version: string | null, 51 | duration: number, 52 | parental_warning: boolean, 53 | id: number, 54 | hires: boolean, 55 | streamable: boolean, 56 | media_number: number 57 | } 58 | 59 | export type FetchedQobuzAlbum = QobuzAlbum & { 60 | tracks: { 61 | offset: number, 62 | limit: number, 63 | total: number, 64 | items: QobuzTrack[] 65 | } 66 | } 67 | 68 | export type QobuzAlbum = { 69 | maximum_bit_depth: number, 70 | image: { 71 | small: string, 72 | thumbnail: string, 73 | large: string, 74 | back: string | null 75 | }, 76 | artist: QobuzArtist, 77 | artists: { 78 | id: number, 79 | name: string, 80 | roles: string[] 81 | }[], 82 | released_at: number, 83 | label: QobuzLabel, 84 | title: string, 85 | qobuz_id: number, 86 | version: string | null, 87 | duration: number, 88 | parental_warning: boolean, 89 | tracks_count: number, 90 | genre: QobuzGenre, 91 | id: string, 92 | maximum_sampling_rate: number, 93 | release_date_original: string, 94 | hires: boolean, 95 | upc: string, 96 | streamable: boolean 97 | } 98 | 99 | export type QobuzSearchResults = { 100 | query: string, 101 | albums: { 102 | limit: number, 103 | offset: number, 104 | total: number, 105 | items: QobuzAlbum[] 106 | }, 107 | tracks: { 108 | limit: number, 109 | offset: number, 110 | total: number, 111 | items: QobuzTrack[] 112 | }, 113 | artists: { 114 | limit: number, 115 | offset: number, 116 | total: number, 117 | items: QobuzArtist[] 118 | } 119 | } 120 | 121 | export type QobuzArtistResults = { 122 | artist: { 123 | id: string, 124 | name: { 125 | display: string, 126 | }, 127 | artist_category: string, 128 | biography: { 129 | content: string, 130 | source: null, 131 | language: string 132 | }, 133 | images: { 134 | portrait: { 135 | hash: string, 136 | format: string 137 | } 138 | }, 139 | top_tracks: QobuzTrack[], 140 | releases: { 141 | album: { 142 | has_more: boolean, 143 | items: QobuzAlbum[] 144 | }, 145 | live: { 146 | has_more: boolean, 147 | items: QobuzAlbum[] 148 | }, 149 | compilation: { 150 | has_more: boolean, 151 | items: QobuzAlbum[] 152 | }, 153 | epSingle: { 154 | has_more: boolean, 155 | items: QobuzAlbum[] 156 | } 157 | } 158 | } 159 | } 160 | 161 | export type FilterDataType = { 162 | label: string, 163 | value: string, 164 | searchRoute?: string, 165 | icon: LucideIcon 166 | }[] 167 | 168 | export type QobuzSearchFilters = "albums" | "tracks" | "artists"; 169 | 170 | export function getAlbum(input: QobuzAlbum | QobuzTrack | QobuzArtist) { 171 | return ((input as QobuzAlbum).image ? input : (input as QobuzTrack).album) as QobuzAlbum; 172 | } 173 | 174 | export function formatTitle(input: QobuzAlbum | QobuzTrack | QobuzArtist) { 175 | return `${(input as QobuzAlbum | QobuzTrack).title ?? (input as QobuzArtist).name}${(input as QobuzAlbum | QobuzTrack).version ? " (" + (input as QobuzAlbum | QobuzTrack).version + ")" : ""}`.trim(); 176 | } 177 | 178 | export function getFullResImageUrl(input: QobuzAlbum | QobuzTrack) { 179 | return getAlbum(input).image.large.substring(0, (getAlbum(input)).image.large.length - 7) + "org.jpg"; 180 | } 181 | 182 | export function formatArtists(input: QobuzAlbum | QobuzTrack, separator: string = ", ") { 183 | return (getAlbum(input) as QobuzAlbum).artists && (getAlbum(input) as QobuzAlbum).artists.length > 0 ? (getAlbum(input) as QobuzAlbum).artists.map((artist) => artist.name).join(separator) : (input as QobuzTrack).performer?.name || "Various Artists" 184 | } 185 | 186 | export function getRandomToken() { 187 | return JSON.parse(process.env.QOBUZ_AUTH_TOKENS!)[Math.floor(Math.random() * JSON.parse(process.env.QOBUZ_AUTH_TOKENS!).length)] as string; 188 | } 189 | 190 | export function filterExplicit(results: QobuzSearchResults, explicit: boolean = true) { 191 | return { 192 | ...results, 193 | albums: { 194 | ...results.albums, 195 | items: results.albums.items.filter(album => explicit ? true : !album.parental_warning) 196 | }, 197 | tracks: { 198 | ...results.tracks, 199 | items: results.tracks.items.filter(track => explicit ? true : !track.parental_warning) 200 | } 201 | } 202 | } 203 | 204 | export async function search(query: string, limit: number = 10, offset: number = 0) { 205 | testForRequirements(); 206 | const url = new URL(process.env.QOBUZ_API_BASE + "catalog/search") 207 | url.searchParams.append("query", query) 208 | url.searchParams.append("limit", limit.toString()); 209 | url.searchParams.append("offset", offset.toString()); 210 | let proxyAgent = undefined; 211 | if (process.env.SOCKS5_PROXY) { 212 | proxyAgent = new SocksProxyAgent("socks5://" + process.env.SOCKS5_PROXY); 213 | } 214 | const response = await axios.get(process.env.CORS_PROXY ? process.env.CORS_PROXY + encodeURIComponent(url.toString()) : url.toString(), { 215 | headers: { 216 | "x-app-id": process.env.QOBUZ_APP_ID!, 217 | "x-user-auth-token": getRandomToken(), 218 | "User-Agent": process.env.CORS_PROXY ? "Qobuz-DL" : undefined 219 | }, 220 | httpAgent: proxyAgent, 221 | httpsAgent: proxyAgent 222 | }); 223 | return response!.data as QobuzSearchResults; 224 | } 225 | 226 | export async function getDownloadURL(trackID: number, quality: string) { 227 | testForRequirements(); 228 | const timestamp = Math.floor(new Date().getTime() / 1000); 229 | const r_sig = `trackgetFileUrlformat_id${quality}intentstreamtrack_id${trackID}${timestamp}${process.env.QOBUZ_SECRET}`; 230 | const r_sig_hashed = crypto.createHash('md5').update(r_sig).digest('hex'); 231 | const url = new URL(process.env.QOBUZ_API_BASE + 'track/getFileUrl'); 232 | url.searchParams.append("format_id", quality); 233 | url.searchParams.append("intent", "stream"); 234 | url.searchParams.append("track_id", trackID.toString()); 235 | url.searchParams.append("request_ts", timestamp.toString()); 236 | url.searchParams.append("request_sig", r_sig_hashed); 237 | const headers = new Headers(); 238 | headers.append('X-App-Id', process.env.QOBUZ_APP_ID!); 239 | headers.append("X-User-Auth-Token", getRandomToken()); 240 | let proxyAgent = undefined; 241 | if (process.env.SOCKS5_PROXY) { 242 | proxyAgent = new SocksProxyAgent("socks5://" + process.env.SOCKS5_PROXY); 243 | } 244 | const response = await axios.get(process.env.CORS_PROXY ? process.env.CORS_PROXY + encodeURIComponent(url.toString()) : url.toString(), { 245 | headers: { 246 | "x-app-id": process.env.QOBUZ_APP_ID!, 247 | "x-user-auth-token": getRandomToken(), 248 | "User-Agent": process.env.CORS_PROXY ? "Qobuz-DL" : undefined 249 | }, 250 | httpAgent: proxyAgent, 251 | httpsAgent: proxyAgent 252 | }) 253 | return response.data.url; 254 | } 255 | 256 | export async function getAlbumInfo(album_id: string) { 257 | testForRequirements(); 258 | const url = new URL(process.env.QOBUZ_API_BASE + 'album/get'); 259 | url.searchParams.append("album_id", album_id); 260 | url.searchParams.append("extra", "track_ids"); 261 | let proxyAgent = undefined; 262 | if (process.env.SOCKS5_PROXY) { 263 | proxyAgent = new SocksProxyAgent("socks5://" + process.env.SOCKS5_PROXY); 264 | } 265 | const response = await axios.get(process.env.CORS_PROXY ? process.env.CORS_PROXY + encodeURIComponent(url.toString()) : url.toString(), { 266 | headers: { 267 | "x-app-id": process.env.QOBUZ_APP_ID!, 268 | "x-user-auth-token": getRandomToken(), 269 | "User-Agent": process.env.CORS_PROXY ? "Qobuz-DL" : undefined 270 | }, 271 | httpAgent: proxyAgent, 272 | httpsAgent: proxyAgent 273 | }) 274 | return response.data; 275 | } 276 | 277 | export async function getArtistReleases(artist_id: string, release_type: string = "album", limit: number = 10, offset: number = 0, track_size: number = 1000) { 278 | testForRequirements(); 279 | const url = new URL(process.env.QOBUZ_API_BASE + 'artist/getReleasesList'); 280 | url.searchParams.append("artist_id", artist_id); 281 | url.searchParams.append("release_type", release_type); 282 | url.searchParams.append("limit", limit.toString()); 283 | url.searchParams.append("offset", offset.toString()); 284 | url.searchParams.append("track_size", track_size.toString()); 285 | url.searchParams.append("sort", "release_date"); 286 | let proxyAgent = undefined; 287 | if (process.env.SOCKS5_PROXY) { 288 | proxyAgent = new SocksProxyAgent("socks5://" + process.env.SOCKS5_PROXY); 289 | } 290 | const response = await axios.get(process.env.CORS_PROXY ? process.env.CORS_PROXY + encodeURIComponent(url.toString()) : url.toString(), { 291 | headers: { 292 | "x-app-id": process.env.QOBUZ_APP_ID!, 293 | "x-user-auth-token": getRandomToken(), 294 | "User-Agent": process.env.CORS_PROXY ? "Qobuz-DL" : undefined 295 | }, 296 | httpAgent: proxyAgent, 297 | httpsAgent: proxyAgent 298 | }) 299 | return response.data; 300 | } 301 | 302 | export async function getFullResImage(resultData: QobuzAlbum | QobuzTrack): Promise { 303 | return new Promise((resolve) => { 304 | const canvas = document.createElement("canvas"); 305 | const context = canvas.getContext("2d"); 306 | const imgToResize = new Image(); 307 | imgToResize.crossOrigin = "anonymous"; 308 | imgToResize.src = getFullResImageUrl(resultData); 309 | imgToResize.onerror = () => resolve(null); 310 | imgToResize.onload = () => { 311 | canvas.width = 3000; 312 | canvas.height = 3000; 313 | context!.drawImage( 314 | imgToResize, 315 | 0, 316 | 0, 317 | 3000, 318 | 3000 319 | ); 320 | resolve(canvas.toDataURL("image/jpeg")); 321 | } 322 | }) 323 | } 324 | 325 | export function formatDuration(seconds: number) { 326 | if (!seconds) return "0m"; 327 | const totalMinutes = Math.floor(seconds / 60); 328 | const hours = Math.floor(totalMinutes / 60); 329 | const remainingMinutes = totalMinutes % 60; 330 | const remainingSeconds = seconds % 60; 331 | 332 | return `${hours > 0 ? hours + "h " : ""} ${remainingMinutes > 0 ? remainingMinutes + "m " : ""} ${remainingSeconds > 0 && hours <= 0 ? remainingSeconds + "s" : ""}`.trim(); 333 | } 334 | 335 | export function testForRequirements() { 336 | if (process.env.QOBUZ_APP_ID?.length === 0) throw new Error("Deployment is missing QOBUZ_APP_ID environment variable."); 337 | if (process.env.QOBUZ_AUTH_TOKENS?.length === 0) throw new Error("Deployment is missing QOBUZ_AUTH_TOKENS environment variable."); 338 | if (process.env.QOBUZ_SECRET?.length === 0) throw new Error("Deployment is missing QOBUZ_SECRET environment variable."); 339 | if (process.env.QOBUZ_API_BASE?.length === 0) throw new Error("Deployment is missing QOBUZ_API_BASE environment variable."); 340 | return true; 341 | } 342 | 343 | export async function getFullAlbumInfo(fetchedAlbumData: FetchedQobuzAlbum | null, setFetchedAlbumData: React.Dispatch>, result: QobuzAlbum) { 344 | if (fetchedAlbumData && (fetchedAlbumData as FetchedQobuzAlbum).id === (result as QobuzAlbum).id) return fetchedAlbumData; 345 | setFetchedAlbumData(null); 346 | const albumDataResponse = await axios.get("/api/get-album", { params: { album_id: (result as QobuzAlbum).id } }); 347 | setFetchedAlbumData(albumDataResponse.data.data); 348 | return albumDataResponse.data.data; 349 | } 350 | 351 | export function getType(input: QobuzAlbum | QobuzTrack | QobuzArtist): QobuzSearchFilters { 352 | if ("albums_count" in input) return "artists"; 353 | if ("album" in input) return "tracks"; 354 | return "albums"; 355 | } 356 | 357 | export async function getArtist(artistId: string) { 358 | testForRequirements(); 359 | const url = new URL(process.env.QOBUZ_API_BASE + "/artist/page"); 360 | let proxyAgent = undefined; 361 | if (process.env.SOCKS5_PROXY) { 362 | proxyAgent = new SocksProxyAgent("socks5://" + process.env.SOCKS5_PROXY); 363 | } 364 | return (await axios.get(process.env.CORS_PROXY ? process.env.CORS_PROXY + encodeURIComponent(url.toString()) : url.toString(), { 365 | params: { artist_id: artistId, sort: "release_date" }, 366 | headers: { 367 | "x-app-id": process.env.QOBUZ_APP_ID!, 368 | "x-user-auth-token": getRandomToken(), 369 | "User-Agent": process.env.CORS_PROXY ? "Qobuz-DL" : undefined 370 | }, 371 | httpAgent: proxyAgent, 372 | httpsAgent: proxyAgent 373 | })).data; 374 | } 375 | 376 | export function parseArtistAlbumData(album: QobuzAlbum) { 377 | album.maximum_sampling_rate = (album as any).audio_info.maximum_sampling_rate; 378 | album.maximum_bit_depth = (album as any).audio_info.maximum_bit_depth; 379 | album.streamable = (album as any).rights.streamable; 380 | album.released_at = new Date((album as any).dates.stream).getTime() / 1000; 381 | album.release_date_original = (album as any).dates.original; 382 | return album; 383 | } 384 | 385 | export function parseArtistData(artistData: QobuzArtistResults) { 386 | // Fix weird inconsistencies in Qobuz API data 387 | if ((!artistData.artist.releases as any).length) return artistData; 388 | (artistData.artist.releases as any).forEach((release: any) => release.items.forEach((album: any, index: number) => { 389 | release.items[index] = parseArtistAlbumData(album); 390 | })); 391 | const newReleases = {} as any; 392 | for (const type of ["album", "live", "compilation", "epSingle"]) { 393 | if (!(artistData.artist.releases as any).find((release: any) => release.type === type)) continue; 394 | newReleases[type] = { 395 | has_more: (artistData.artist.releases as any).find((release: any) => release.type === type)!.has_more, 396 | items: (artistData.artist.releases as any).find((release: any) => release.type === type)!.items 397 | } 398 | } 399 | artistData.artist.releases = newReleases; 400 | return artistData; 401 | } -------------------------------------------------------------------------------- /lib/settings-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react'; 3 | 4 | export type SettingsProps = { 5 | particles: boolean, 6 | outputQuality: "27" | "7" | "6" | "5", 7 | outputCodec: "FLAC" | "WAV" | "ALAC" | "MP3" | "AAC" | "OPUS", 8 | bitrate: number | undefined, 9 | applyMetadata: boolean, 10 | fixMD5: boolean, 11 | explicitContent: boolean 12 | } 13 | 14 | const isValidSettings = (obj: any): obj is SettingsProps => { 15 | return ( 16 | typeof obj.particles === 'boolean' && 17 | ['27', '7', '6', '5'].includes(obj.outputQuality) && 18 | ['FLAC', 'WAV', 'ALAC', 'MP3', 'AAC', 'OPUS'].includes(obj.outputCodec) && 19 | (typeof obj.bitrate === 'number' && obj.bitrate >= 24 && obj.bitrate <= 320) || obj.bitrate === undefined && 20 | typeof obj.applyMetadata === 'boolean' && 21 | typeof obj.explicitContent === 'boolean' && 22 | typeof obj.fixMD5 === 'boolean' 23 | ); 24 | }; 25 | 26 | const SettingsContext = createContext<{ 27 | settings: SettingsProps; 28 | setSettings: React.Dispatch>; 29 | } | undefined>(undefined); 30 | 31 | export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 32 | const [settings, setSettings] = useState( { 33 | particles: true, 34 | outputQuality: "27", 35 | outputCodec: "FLAC", 36 | bitrate: 320, 37 | applyMetadata: true, 38 | fixMD5: true, 39 | explicitContent: true 40 | }); 41 | 42 | useEffect(() => { 43 | const savedSettings = localStorage.getItem('settings'); 44 | if (savedSettings && isValidSettings(JSON.parse(savedSettings))) { 45 | setSettings(JSON.parse(savedSettings)); 46 | } 47 | }, []) 48 | 49 | useEffect(() => { 50 | localStorage.setItem("settings", JSON.stringify(settings)); 51 | }, [settings]); 52 | 53 | return ( 54 | 55 | {children} 56 | 57 | ); 58 | }; 59 | 60 | export const useSettings = () => { 61 | const context = useContext(SettingsContext); 62 | 63 | if (!context) { 64 | throw new Error('useSettings must be used within a SettingsProvider'); 65 | } 66 | 67 | return context; 68 | }; -------------------------------------------------------------------------------- /lib/status-bar/context.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { createContext, useContext, useState, ReactNode } from 'react'; 3 | import type { StatusBarProps } from '@/components/status-bar/status-bar'; 4 | 5 | const StatusBarContext = createContext<{ 6 | statusBar: StatusBarProps; 7 | setStatusBar: React.Dispatch>; 8 | } | undefined>(undefined); 9 | 10 | export const StatusBarProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 11 | const [statusBar, setStatusBar] = useState({ 12 | title: "", 13 | open: false, 14 | openPreference: true, 15 | progress: 0, 16 | description: "", 17 | processing: false, 18 | onCancel: () => {} 19 | }); 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | }; 27 | 28 | export const useStatusBar = () => { 29 | const context = useContext(StatusBarContext); 30 | 31 | if (!context) { 32 | throw new Error('useStatusBar must be used within a StatusBarProvider'); 33 | } 34 | 35 | return context; 36 | }; -------------------------------------------------------------------------------- /lib/status-bar/jobs.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBarProps } from "@/components/status-bar/status-bar"; 2 | import { LucideIcon } from "lucide-react"; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | let jobs = [] as { ready: () => Promise, UUID: string }[] 6 | 7 | export function loadStatusBarValue(setStatusBar: React.Dispatch>): Promise { 8 | return new Promise((resolve) => 9 | { 10 | setStatusBar((prev) => (resolve(prev), prev)) 11 | }); 12 | } 13 | 14 | export async function createJob(setStatusBar: React.Dispatch>, QueueTitle: string, QueueIcon: LucideIcon, ready: () => Promise) { 15 | let running; 16 | const UUID = uuidv4(); 17 | const job = { ready, UUID }; 18 | const updateJob = async () => { 19 | const statusBar: StatusBarProps = await loadStatusBarValue(setStatusBar); 20 | if (statusBar!.processing) { 21 | setStatusBar(prev => ({ ...prev, queue: [...(prev.queue || []), { title: QueueTitle, UUID: UUID, icon: QueueIcon, remove: () => { 22 | jobs = jobs.filter(item => item.UUID !== UUID) 23 | } }] })) 24 | } else { 25 | running = true; 26 | setStatusBar(prev => ({ ...prev, processing: true, open: prev.openPreference, progress: 0 })) 27 | ready().then(() => { 28 | jobs.shift(); 29 | if (jobs.length <= 0) { 30 | setStatusBar(prev => ({ ...prev, open: false, title: "", description: "", progress: 0, processing: false })); 31 | } 32 | }); 33 | } 34 | } 35 | await updateJob(); 36 | jobs.push(job) 37 | if (!running) { 38 | const interval = setInterval(async () => { 39 | if (jobs[0] === job) { 40 | setStatusBar(prev => ({ ...prev, processing: false, onCancel: () => {}, queue: prev.queue?.filter(item => item.UUID !== UUID), progress: 0 })) 41 | clearInterval(interval); 42 | await updateJob(); 43 | } 44 | }, 100); 45 | } 46 | } -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export const formatBytes = (bytes: number): string => { 9 | if (bytes === 0) return "0 Bytes"; 10 | 11 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"]; 12 | const i = Math.floor(Math.log(bytes) / Math.log(1024)); 13 | 14 | const sizeInUnit = bytes / Math.pow(1024, i); 15 | 16 | const formattedSize = new Intl.NumberFormat("en-US", { 17 | maximumFractionDigits: i >= 3 ? 2 : 0, 18 | }).format(sizeInUnit); 19 | 20 | return `${formattedSize} ${sizes[i]}`; 21 | }; 22 | 23 | export const cleanFileName = (filename: string) => { 24 | const bannedChars = ["/", "\\", "?", ":", "*", '"', "<", ">", "|"]; 25 | for (const char in bannedChars) { 26 | filename = filename.replaceAll(bannedChars[char], "_"); 27 | }; 28 | return filename; 29 | } 30 | 31 | export function getTailwindBreakpoint(width: any) { 32 | if (width >= 1536) { 33 | return '2xl'; 34 | } else if (width >= 1280) { 35 | return 'xl'; 36 | } else if (width >= 1024) { 37 | return 'lg'; 38 | } else if (width >= 768) { 39 | return 'md'; 40 | } else if (width >= 640) { 41 | return 'sm'; 42 | } else { 43 | return 'base'; // Base size (less than 640px) 44 | } 45 | } -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: 'https', 8 | hostname: 'static.qobuz.com', 9 | port: '', 10 | pathname: '**', 11 | search: '', 12 | }, 13 | ], 14 | }, 15 | async headers() { 16 | return [ 17 | { 18 | source: '/(.*)', 19 | headers: [ 20 | { 21 | key: "Access-Control-Allow-Origin", 22 | value: "*", 23 | }, 24 | { 25 | key: 'Cross-Origin-Opener-Policy', 26 | value: 'same-origin', 27 | }, 28 | { 29 | key: 'Cross-Origin-Embedder-Policy', 30 | value: 'require-corp', 31 | }, 32 | ], 33 | }, 34 | ]; 35 | }, 36 | }; 37 | 38 | export default nextConfig; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qobuz-dl", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-checkbox": "^1.1.3", 13 | "@radix-ui/react-dialog": "^1.1.4", 14 | "@radix-ui/react-dropdown-menu": "^2.1.4", 15 | "@radix-ui/react-label": "^2.1.1", 16 | "@radix-ui/react-progress": "^1.1.1", 17 | "@radix-ui/react-scroll-area": "^1.2.2", 18 | "@radix-ui/react-separator": "^1.1.1", 19 | "@radix-ui/react-slot": "^1.1.1", 20 | "@radix-ui/react-toast": "^1.2.4", 21 | "@react-icons/all-files": "^4.1.0", 22 | "@tsparticles/all": "^3.7.1", 23 | "@tsparticles/react": "^3.0.0", 24 | "axios": "^1.7.9", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "fflate": "^0.8.2", 28 | "file-saver": "^2.0.5", 29 | "lucide-react": "^0.468.0", 30 | "motion": "^11.15.0", 31 | "next": "^15.2.4", 32 | "next-themes": "^0.4.4", 33 | "react": "^19.0.0", 34 | "react-dom": "^19.0.0", 35 | "socks-proxy-agent": "^8.0.5", 36 | "tailwind-merge": "^2.5.5", 37 | "tailwind-scrollbar-hide": "^2.0.0", 38 | "tailwindcss-animate": "^1.0.7", 39 | "uuid": "^11.0.3", 40 | "zod": "^3.24.1" 41 | }, 42 | "devDependencies": { 43 | "@eslint/eslintrc": "^3", 44 | "@ffmpeg/ffmpeg": "^0.12.10", 45 | "@ffmpeg/util": "^0.12.1", 46 | "@types/file-saver": "^2.0.7", 47 | "@types/node": "^20", 48 | "@types/react": "^19", 49 | "@types/react-dom": "^19", 50 | "eslint": "^9", 51 | "eslint-config-next": "15.1.2", 52 | "postcss": "^8", 53 | "react-intersection-observer": "^9.14.0", 54 | "tailwindcss": "^3.4.1", 55 | "typescript": "^5" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/flac/EmsArgs.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2014 Rainer Rillke @wikipedia.de 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to 6 | * deal in the Software without restriction, including without limitation the 7 | * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | * sell copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in 12 | * all copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | * IN THE SOFTWARE. 21 | */ 22 | 23 | /*global self: false, Module:false */ 24 | /*jslint vars: false, white: false */ 25 | /*jshint onevar: false, white: false, laxbreak: true */ 26 | ( function( global ) { 27 | 'use strict'; 28 | 29 | /** 30 | * Emscripten C function arguments utitlites 31 | * @class EmsArgs 32 | * @singleton 33 | */ 34 | global.EmsArgs = { 35 | /** 36 | * Read a Blob as ArrayBuffer and invoke the supplied callback when completed 37 | * 38 | * @param {Blob} blob Blob to be read 39 | * @param {Function} cb Callback invoked upon completion of the 40 | * operation 41 | * @param {ArrayBuffer} cb.data ArrayBuffer containing a copy of the Blob data 42 | */ 43 | readBlobAsArrayBuffer: function readBlobAsArrayBuffer( blob, cb ) { 44 | var frs, fr; 45 | 46 | if ( global.FileReaderSync ) { 47 | frs = new FileReaderSync(); 48 | // Ensure ASYNC callback 49 | // TODO: Investigate why application crashes (exit(1)) 50 | // when using synchroneous callback here 51 | setTimeout( function() { 52 | cb( frs.readAsArrayBuffer( blob ) ); 53 | }, 5 ); 54 | return; 55 | } 56 | 57 | fr = new FileReader(); 58 | fr.addEventListener( 'loadend', function() { 59 | cb( fr.result ); 60 | } ); 61 | fr.readAsArrayBuffer( blob ); 62 | }, 63 | 64 | 65 | /** 66 | * @param {string} s String to be UTF-8 encoded as buffer 67 | * @param {Function} cb Callback when string has been converted 68 | * @param {number} cb.sPointer Pointer to the string 69 | */ 70 | getBufferFor: function( s, cb ) { 71 | var b = new Blob( [ s + '\0' ] ); 72 | global.EmsArgs.readBlobAsArrayBuffer( b, function( res ) { 73 | var arg = new Uint8Array( res ); 74 | var dataPtr = Module._malloc( arg.length * arg.BYTES_PER_ELEMENT ); 75 | 76 | // Copy data to Emscripten heap 77 | var dataHeap = new Uint8Array( Module.HEAPU8.buffer, dataPtr, arg.length * arg.BYTES_PER_ELEMENT ); 78 | dataHeap.set( arg ); 79 | cb( dataPtr ); 80 | } ); 81 | }, 82 | 83 | /** 84 | * @param {Array} args Arguments to be passed to the C function. 85 | * @param {Function} cb Callback when arguments were converted 86 | * @param {number} cb.argsPointer A pointer to the the Array of Pointers of arguments 87 | * in the Emscripten Heap (**argv) 88 | */ 89 | cArgsPointer: function( args, cb ) { 90 | var pointers = new Uint32Array( args.length ), 91 | nextArg, processedArg; 92 | 93 | args = args.slice( 0 ); 94 | 95 | processedArg = function( sPointer ) { 96 | pointers[ pointers.length - args.length - 1 ] = sPointer; 97 | nextArg(); 98 | }; 99 | 100 | nextArg = function() { 101 | if ( args.length ) { 102 | global.EmsArgs.getBufferFor( args.shift(), processedArg ); 103 | } else { 104 | var nPointerBytes = pointers.length * pointers.BYTES_PER_ELEMENT; 105 | var pointerPtr = Module._malloc( nPointerBytes ); 106 | var pointerHeap = new Uint8Array( Module.HEAPU8.buffer, pointerPtr, nPointerBytes ); 107 | pointerHeap.set( new Uint8Array( pointers.buffer ) ); 108 | cb( pointerHeap ); 109 | } 110 | }; 111 | nextArg(); 112 | } 113 | }; 114 | 115 | }( self ) ); 116 | -------------------------------------------------------------------------------- /public/flac/EmsWorkerProxy.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2014 Rainer Rillke @wikipedia.de 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to 6 | * deal in the Software without restriction, including without limitation the 7 | * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | * sell copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in 12 | * all copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | * IN THE SOFTWARE. 21 | */ 22 | 23 | /*global self: false, FlacEncoder: false, importScripts: false */ 24 | /*jslint vars: false, white: false */ 25 | /*jshint onevar: false, white: false, laxbreak: true */ 26 | ( function( global ) { 27 | 'use strict'; 28 | 29 | /** 30 | * Worker proxy implementing communication between worker and website 31 | * @class EmsWorkerProxy 32 | * @singleton 33 | */ 34 | global.EmsWorkerProxy = { 35 | init: function() { 36 | global.onmessage = function( e ) { 37 | switch ( e.data.command ) { 38 | case 'ping': 39 | global.postMessage( { reply: 'pong' } ); 40 | break; 41 | case 'encode': 42 | if ( !global.FlacEncoder ) { 43 | importScripts( 'FlacEncoder.js' ); 44 | } 45 | FlacEncoder.encode( e.data ); 46 | break; 47 | case 'prefetch': 48 | if ( !global.FlacEncoder ) { 49 | importScripts( 'FlacEncoder.js' ); 50 | FlacEncoder.prefetch( e.data ); 51 | } 52 | break; 53 | } 54 | }; 55 | } 56 | }; 57 | global.EmsWorkerProxy.init(); 58 | 59 | }( self ) ); 60 | -------------------------------------------------------------------------------- /public/flac/FlacEncoder.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2014 Rainer Rillke @wikipedia.de 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to 6 | * deal in the Software without restriction, including without limitation the 7 | * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | * sell copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in 12 | * all copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | * IN THE SOFTWARE. 21 | */ 22 | 23 | /*global self: false, Runtime: false, FlacEncoder: false, Module: false, FS: false, EmsArgs: false, console: false, WebAssembly: false */ 24 | /*jslint vars: false, white: false */ 25 | /*jshint onevar: false, white: false, laxbreak: true, worker: true, strict: false */ 26 | 27 | ( function( global ) { 28 | 'use strict'; 29 | var MainScriptLoader, 30 | downloadCompleted, 31 | downloadError; 32 | 33 | /** 34 | * Manages encoding and progress notifications 35 | * @class FlacEncoder 36 | * @singleton 37 | */ 38 | global.FlacEncoder = { 39 | encode: function( data ) { 40 | FlacEncoder.setUpLogging( data ); 41 | FlacEncoder.monitorWWDownload(); 42 | MainScriptLoader.downloadAndExecute( data, function() { 43 | // Before execute main script ... 44 | if ( !global.EmsArgs ){ 45 | importScripts( 'EmsArgs.js' ); 46 | } 47 | FlacEncoder.setUpModule( data ); 48 | }, function() { 49 | // After the main script was executed ... 50 | MainScriptLoader.whenInitialized( function() { 51 | FlacEncoder._encode( data ); 52 | } ); 53 | } ); 54 | }, 55 | 56 | prefetch: function( data ) { 57 | if ( global.Module && global.EmsArgs && global.Runtime ) { 58 | return; 59 | } 60 | FlacEncoder.setUpLogging( data ); 61 | MainScriptLoader.xhrload( data ); 62 | importScripts( 'EmsArgs.js' ); 63 | }, 64 | 65 | setUpLogging: function( data ) { 66 | if ( !global.console ) global.console = {}; 67 | console.log = function() { 68 | ( data.log || FlacEncoder.log )( 69 | Array.prototype.slice.call( arguments ) 70 | ); 71 | }; 72 | 73 | console.error = function() { 74 | ( data.err || FlacEncoder.err )( 75 | Array.prototype.slice.call( arguments ) 76 | ); 77 | }; 78 | }, 79 | 80 | monitorWWDownload: function() { 81 | var lastLog = 0; 82 | 83 | MainScriptLoader.onDownloadComplete = function() { 84 | console.log( 'Worker downloaded successfully.' ); 85 | }; 86 | 87 | MainScriptLoader.onDownloadProgress = function( loaded, total ) { 88 | var now = Date.now(), 89 | diff = now - lastLog; 90 | 91 | if ( diff > 450 ) { 92 | lastLog = now; 93 | console.log( 'Downloading Flac Encoder code ... ' + Math.round( 100 * loaded / total, 2) + '%' ); 94 | } 95 | }; 96 | MainScriptLoader.onDownloadError = function( err ) { 97 | console.log( 'Failed to download worker utilizing XHR.\n' + err + '\nTrying importScripts() ...' ); 98 | }; 99 | }, 100 | 101 | setUpModule: function( data ) { 102 | /*jshint forin:false */ 103 | var totalFileLength = 0, 104 | filename, memRequired; 105 | 106 | for ( filename in data.fileData ) { 107 | if ( !data.fileData.hasOwnProperty( filename ) ) { 108 | return; 109 | } 110 | var fileData = data.fileData[filename]; 111 | 112 | totalFileLength += fileData.length; 113 | } 114 | 115 | memRequired = totalFileLength * 2 + 0x1000000; 116 | // Currently "The asm.js rules specify that the heap size must be 117 | // a multiple of 16MB or a power of two. Minimum heap size is 64KB" 118 | // If we don't correct it here asm will dump errors on us while adjusting 119 | // the number but we would ignore following the error and shut down the 120 | // worker due to this error 121 | memRequired = memRequired - ( memRequired % 0x1000000 ) + 0x1000000; 122 | 123 | global.Module = { 124 | TOTAL_MEMORY: memRequired, 125 | _main: MainScriptLoader.initialized, 126 | noExitRuntime: true, 127 | preRun: FlacEncoder.setUpFilesystem, 128 | printErr: console.error.bind( console ), 129 | monitorRunDependencies: function( runDeps ) { 130 | console.log( 'Loading run dependencies. Outstanding: ' + runDeps ); 131 | }, 132 | locateFile: function( memFile ) { 133 | return memFile 134 | .replace( /^flac\.(html|js)\.mem$/, 'asm/flac.mem.png' ) 135 | .replace( 'flac.wasm', 'wasm/flac.wasm.png' ); 136 | } 137 | }; 138 | }, 139 | 140 | setUpFilesystem: function() { 141 | var infoBuff = '', 142 | errBuff = '', 143 | lastInfoFlush = Date.now(), 144 | lastErrFlush = Date.now(), 145 | infoTimeout, errTimeout, flushInfo, flushErr; 146 | 147 | FlacEncoder.flushInfo = flushInfo = function() { 148 | clearTimeout( infoTimeout ); 149 | lastInfoFlush = Date.now(); 150 | if ( infoBuff.replace( /\s*/g, '' ) ) { 151 | console.log( infoBuff ); 152 | infoBuff = ''; 153 | } 154 | }; 155 | FlacEncoder.flushErr = flushErr = function() { 156 | clearTimeout( errTimeout ); 157 | lastErrFlush = Date.now(); 158 | if ( errBuff.replace( /\s*/g, '' ) ) { 159 | console.log( errBuff ); 160 | errBuff = ''; 161 | } 162 | }; 163 | 164 | FS.init( global.prompt || function() { 165 | console.log( 'Input requested from within web worker. Returning empty string.' ); 166 | return ''; 167 | }, function( infoChar ) { 168 | infoBuff += String.fromCharCode( infoChar ); 169 | clearTimeout( infoTimeout ); 170 | infoTimeout = setTimeout( flushInfo, 5 ); 171 | if ( lastInfoFlush + 700 < Date.now() ) { 172 | flushInfo(); 173 | } 174 | }, function( errChar ) { 175 | errBuff += String.fromCharCode( errChar ); 176 | clearTimeout( errTimeout ); 177 | errTimeout = setTimeout( flushErr, 5 ); 178 | if ( lastErrFlush + 700 < Date.now() ) { 179 | flushErr(); 180 | } 181 | } ); 182 | }, 183 | 184 | done: function( args ) { 185 | global.postMessage( { 186 | reply: 'done', 187 | values: args 188 | } ); 189 | }, 190 | 191 | progress: function( args ) { 192 | global.postMessage( { 193 | reply: 'progress', 194 | values: args 195 | } ); 196 | }, 197 | 198 | log: function( args ) { 199 | global.postMessage( { 200 | reply: 'log', 201 | values: args 202 | } ); 203 | }, 204 | 205 | err: function( args ) { 206 | global.postMessage( { 207 | reply: 'err', 208 | values: args 209 | } ); 210 | }, 211 | 212 | _encode: function( data ) { 213 | /*jshint forin:false */ 214 | var fPointer; 215 | 216 | // Get a pointer for the callback function 217 | fPointer = Runtime.addFunction( function( encoded, total, bytesWritten ) { 218 | var filename, fileContent, b; 219 | 220 | // We *know* that writing to to stdin and/or stderr completed 221 | FlacEncoder.flushInfo(); 222 | FlacEncoder.flushErr(); 223 | 224 | if ( encoded === total && encoded === 100 && bytesWritten === -1 ) { 225 | // Read output files 226 | for ( filename in data.outData ) { 227 | if ( !data.outData.hasOwnProperty( filename ) ) { 228 | return; 229 | } 230 | fileContent = FS.readFile( filename, { 231 | encoding: 'binary' 232 | } ); 233 | b = new Blob( 234 | [fileContent], 235 | {type: data.outData[filename].MIME} 236 | ); 237 | data.outData[filename].blob = b; 238 | } 239 | (data.done || FlacEncoder.done)( data.outData ); 240 | } else { 241 | (data.progress || FlacEncoder.progress)( 242 | Array.prototype.slice.call( arguments ) 243 | ); 244 | } 245 | } ); 246 | 247 | // Set module arguments (command line arguments) 248 | var args = data.args, 249 | argsCloned = args.slice( 0 ); 250 | 251 | args.unshift( 'flac.js' ); 252 | Module['arguments'] = argsCloned; 253 | 254 | // Create all neccessary files in MEMFS or whatever 255 | // the mounted file system is 256 | var filename; 257 | 258 | for ( filename in data.fileData ) { 259 | if ( !data.fileData.hasOwnProperty( filename ) ) { 260 | return; 261 | } 262 | var fileData = data.fileData[filename], 263 | stream = FS.open( filename, 'w+' ); 264 | 265 | FS.write( stream, fileData, 0, fileData.length ); 266 | FS.close( stream ); 267 | } 268 | 269 | // Do NOT create output files ... (Warning: Output file already exists ...) 270 | 271 | // Prepare C function to be called 272 | var encode_buffer = Module.cwrap( 'main_js', 'number', ['number', 'number', 'number'] ); 273 | 274 | // Copy command line args to Emscripten Heap and get a pointer to them 275 | EmsArgs.cArgsPointer( args, function( pointerHeap ) { 276 | try { 277 | global.Module.noExitRuntime = false; 278 | encode_buffer( args.length, pointerHeap.byteOffset, fPointer ); 279 | } catch ( ex ) { 280 | console.error( ex.message || ex ); 281 | } 282 | } ); 283 | } 284 | }; 285 | 286 | /** 287 | * Downloads main script 288 | * @class MainScriptLoader 289 | * @singleton 290 | * @private 291 | */ 292 | MainScriptLoader = { 293 | name: ( global.WebAssembly ? 'wasm/' : 'asm/' ) + 'flac.js', 294 | text: null, 295 | status: 'idle', 296 | xhrload: function( data, complete, err ) { 297 | var xhrfailed = function( errMsg ) { 298 | if ( MainScriptLoader.status !== 'loading' ) { 299 | return; 300 | } 301 | MainScriptLoader.status = 'xhrfailed'; 302 | MainScriptLoader.onDownloadError( errMsg ); 303 | if ( err ) err(); 304 | }; 305 | 306 | if ( global.__debug ) { 307 | MainScriptLoader.status = 'loading'; 308 | return xhrfailed( 'Debug modus enabled.' ); 309 | } 310 | 311 | var xhr = new XMLHttpRequest(); 312 | xhr.onreadystatechange = function() { 313 | if ( xhr.readyState === xhr.DONE ) { 314 | if ( xhr.status === 200 || xhr.status === 0 && location.protocol === 'file:' ) { 315 | MainScriptLoader.text = xhr.responseText; 316 | MainScriptLoader.status = 'loaded'; 317 | MainScriptLoader.onDownloadComplete(); 318 | if ( complete ) complete(); 319 | if ( downloadCompleted ) downloadCompleted(); 320 | } else { 321 | xhrfailed( 'Server status ' + xhr.status ); 322 | } 323 | } 324 | }; 325 | xhr.onprogress = function( e ) { 326 | if ( e.lengthComputable ) { 327 | MainScriptLoader.onDownloadProgress( e.loaded, e.total ); 328 | } 329 | }; 330 | xhr.onerror = function() { 331 | xhrfailed( 'There was an error with the request.' ); 332 | }; 333 | xhr.ontimeout = function() { 334 | xhrfailed( 'Request timed out.' ); 335 | }; 336 | 337 | try { 338 | MainScriptLoader.status = 'loading'; 339 | xhr.open( 'GET', MainScriptLoader.name ); 340 | xhr.send( null ); 341 | } catch ( ex ) { 342 | xhrfailed( ex.message || ex ); 343 | } 344 | }, 345 | onDownloadProgress: function( /* loaded, total */ ) {}, 346 | onDownloadComplete: function() {}, 347 | onDownloadError: function( /* description */ ) {}, 348 | downloadAndExecute: function( data, beforeExecution, afterExecution ) { 349 | switch ( MainScriptLoader.status ) { 350 | case 'idle': 351 | MainScriptLoader.xhrload( data, function() { 352 | beforeExecution(); 353 | MainScriptLoader.execute(); 354 | afterExecution(); 355 | }, function() { 356 | beforeExecution(); 357 | importScripts( MainScriptLoader.name ); 358 | afterExecution(); 359 | } ); 360 | break; 361 | case 'xhrfailed': 362 | beforeExecution(); 363 | importScripts( MainScriptLoader.name ); 364 | afterExecution(); 365 | break; 366 | case 'loaded': 367 | beforeExecution(); 368 | MainScriptLoader.execute(); 369 | afterExecution(); 370 | break; 371 | case 'loading': 372 | downloadCompleted = function() { 373 | downloadCompleted = null; 374 | downloadError = null; 375 | beforeExecution(); 376 | MainScriptLoader.execute(); 377 | afterExecution(); 378 | }; 379 | downloadError = function() { 380 | beforeExecution(); 381 | importScripts( MainScriptLoader.name ); 382 | afterExecution(); 383 | }; 384 | break; 385 | } 386 | }, 387 | execute: function() { 388 | if ( !MainScriptLoader.text ) { 389 | throw new Error( 'Main script text must be loaded before!' ); 390 | } 391 | global.callEval( MainScriptLoader.text ); 392 | }, 393 | queue: [], 394 | isInitialized: false, 395 | whenInitialized: function( cb ) { 396 | if ( MainScriptLoader.isInitialized ) { 397 | cb(); 398 | } else { 399 | MainScriptLoader.queue.push( cb ); 400 | } 401 | }, 402 | initialized: function() { 403 | MainScriptLoader.isInitialized = true; 404 | while ( MainScriptLoader.queue.length ) { 405 | MainScriptLoader.queue.shift()(); 406 | } 407 | } 408 | }; 409 | 410 | }( self ) ); 411 | 412 | ( function( global ) { 413 | /* jshint evil: true */ 414 | global.callEval = function ( s ) { 415 | var Module = global.Module, 416 | ret = eval( s ); 417 | 418 | global.FS = FS; 419 | global.Module = Module; 420 | global.Runtime = Runtime; 421 | return ret; 422 | }; 423 | }( self ) ); 424 | -------------------------------------------------------------------------------- /public/flac/asm/flac.encoder.mem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QobuzDL/Qobuz-DL/d8b33e79c465a7e754afba8c3084cf9c578bfd40/public/flac/asm/flac.encoder.mem.png -------------------------------------------------------------------------------- /public/flac/asm/flac.mem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QobuzDL/Qobuz-DL/d8b33e79c465a7e754afba8c3084cf9c578bfd40/public/flac/asm/flac.mem.png -------------------------------------------------------------------------------- /public/flac/wasm/flac.encoder.wasm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QobuzDL/Qobuz-DL/d8b33e79c465a7e754afba8c3084cf9c578bfd40/public/flac/wasm/flac.encoder.wasm.png -------------------------------------------------------------------------------- /public/flac/wasm/flac.wasm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QobuzDL/Qobuz-DL/d8b33e79c465a7e754afba8c3084cf9c578bfd40/public/flac/wasm/flac.wasm.png -------------------------------------------------------------------------------- /public/logo/qobuz-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QobuzDL/Qobuz-DL/d8b33e79c465a7e754afba8c3084cf9c578bfd40/public/logo/qobuz-banner.png -------------------------------------------------------------------------------- /public/logo/qobuz-web-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QobuzDL/Qobuz-DL/d8b33e79c465a7e754afba8c3084cf9c578bfd40/public/logo/qobuz-web-dark.png -------------------------------------------------------------------------------- /public/logo/qobuz-web-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QobuzDL/Qobuz-DL/d8b33e79c465a7e754afba8c3084cf9c578bfd40/public/logo/qobuz-web-light.png -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))', 34 | background: 'hsl(var(--muted-background))' 35 | }, 36 | accent: { 37 | DEFAULT: 'hsl(var(--accent))', 38 | foreground: 'hsl(var(--accent-foreground))', 39 | background: 'hsl(var(--accent-background))' 40 | }, 41 | destructive: { 42 | DEFAULT: 'hsl(var(--destructive))', 43 | foreground: 'hsl(var(--destructive-foreground))' 44 | }, 45 | border: 'hsl(var(--border))', 46 | input: 'hsl(var(--input))', 47 | ring: 'hsl(var(--ring))', 48 | chart: { 49 | '1': 'hsl(var(--chart-1))', 50 | '2': 'hsl(var(--chart-2))', 51 | '3': 'hsl(var(--chart-3))', 52 | '4': 'hsl(var(--chart-4))', 53 | '5': 'hsl(var(--chart-5))' 54 | } 55 | }, 56 | borderRadius: { 57 | lg: 'var(--radius)', 58 | md: 'calc(var(--radius) - 2px)', 59 | sm: 'calc(var(--radius) - 4px)' 60 | } 61 | } 62 | }, 63 | plugins: [require("tailwindcss-animate"), require("tailwind-scrollbar-hide")], 64 | } satisfies Config; 65 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------