├── .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 | 
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------