Deploy: Add Dockerfile and static file serving
This commit is contained in:
parent
dd788db786
commit
d0d26171f0
187 changed files with 22835 additions and 101600 deletions
|
|
@ -1,29 +1,10 @@
|
|||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Python
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# OS
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
backend.log
|
||||
ngrok.log
|
||||
backend-go/server.exe
|
||||
backend-go/server
|
||||
|
|
|
|||
BIN
.tools/node.zip
Normal file
BIN
.tools/node.zip
Normal file
Binary file not shown.
1377
.tools/node_extracted/node-v20.11.0-win-x64/CHANGELOG.md
Normal file
1377
.tools/node_extracted/node-v20.11.0-win-x64/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
2183
.tools/node_extracted/node-v20.11.0-win-x64/LICENSE
Normal file
2183
.tools/node_extracted/node-v20.11.0-win-x64/LICENSE
Normal file
File diff suppressed because it is too large
Load diff
871
.tools/node_extracted/node-v20.11.0-win-x64/README.md
Normal file
871
.tools/node_extracted/node-v20.11.0-win-x64/README.md
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
# Node.js
|
||||
|
||||
Node.js is an open-source, cross-platform JavaScript runtime environment.
|
||||
|
||||
For information on using Node.js, see the [Node.js website][].
|
||||
|
||||
The Node.js project uses an [open governance model](./GOVERNANCE.md). The
|
||||
[OpenJS Foundation][] provides support for the project.
|
||||
|
||||
Contributors are expected to act in a collaborative manner to move
|
||||
the project forward. We encourage the constructive exchange of contrary
|
||||
opinions and compromise. The [TSC](./GOVERNANCE.md#technical-steering-committee)
|
||||
reserves the right to limit or block contributors who repeatedly act in ways
|
||||
that discourage, exhaust, or otherwise negatively affect other participants.
|
||||
|
||||
**This project has a [Code of Conduct][].**
|
||||
|
||||
## Table of contents
|
||||
|
||||
* [Support](#support)
|
||||
* [Release types](#release-types)
|
||||
* [Download](#download)
|
||||
* [Current and LTS releases](#current-and-lts-releases)
|
||||
* [Nightly releases](#nightly-releases)
|
||||
* [API documentation](#api-documentation)
|
||||
* [Verifying binaries](#verifying-binaries)
|
||||
* [Building Node.js](#building-nodejs)
|
||||
* [Security](#security)
|
||||
* [Contributing to Node.js](#contributing-to-nodejs)
|
||||
* [Current project team members](#current-project-team-members)
|
||||
* [TSC (Technical Steering Committee)](#tsc-technical-steering-committee)
|
||||
* [Collaborators](#collaborators)
|
||||
* [Triagers](#triagers)
|
||||
* [Release keys](#release-keys)
|
||||
* [License](#license)
|
||||
|
||||
## Support
|
||||
|
||||
Looking for help? Check out the
|
||||
[instructions for getting support](.github/SUPPORT.md).
|
||||
|
||||
## Release types
|
||||
|
||||
* **Current**: Under active development. Code for the Current release is in the
|
||||
branch for its major version number (for example,
|
||||
[v19.x](https://github.com/nodejs/node/tree/v19.x)). Node.js releases a new
|
||||
major version every 6 months, allowing for breaking changes. This happens in
|
||||
April and October every year. Releases appearing each October have a support
|
||||
life of 8 months. Releases appearing each April convert to LTS (see below)
|
||||
each October.
|
||||
* **LTS**: Releases that receive Long Term Support, with a focus on stability
|
||||
and security. Every even-numbered major version will become an LTS release.
|
||||
LTS releases receive 12 months of _Active LTS_ support and a further 18 months
|
||||
of _Maintenance_. LTS release lines have alphabetically-ordered code names,
|
||||
beginning with v4 Argon. There are no breaking changes or feature additions,
|
||||
except in some special circumstances.
|
||||
* **Nightly**: Code from the Current branch built every 24-hours when there are
|
||||
changes. Use with caution.
|
||||
|
||||
Current and LTS releases follow [semantic versioning](https://semver.org). A
|
||||
member of the Release Team [signs](#release-keys) each Current and LTS release.
|
||||
For more information, see the
|
||||
[Release README](https://github.com/nodejs/Release#readme).
|
||||
|
||||
### Download
|
||||
|
||||
Binaries, installers, and source tarballs are available at
|
||||
<https://nodejs.org/en/download/>.
|
||||
|
||||
#### Current and LTS releases
|
||||
|
||||
<https://nodejs.org/download/release/>
|
||||
|
||||
The [latest](https://nodejs.org/download/release/latest/) directory is an
|
||||
alias for the latest Current release. The latest-_codename_ directory is an
|
||||
alias for the latest release from an LTS line. For example, the
|
||||
[latest-hydrogen](https://nodejs.org/download/release/latest-hydrogen/)
|
||||
directory contains the latest Hydrogen (Node.js 18) release.
|
||||
|
||||
#### Nightly releases
|
||||
|
||||
<https://nodejs.org/download/nightly/>
|
||||
|
||||
Each directory name and filename contains a date (in UTC) and the commit
|
||||
SHA at the HEAD of the release.
|
||||
|
||||
#### API documentation
|
||||
|
||||
Documentation for the latest Current release is at <https://nodejs.org/api/>.
|
||||
Version-specific documentation is available in each release directory in the
|
||||
_docs_ subdirectory. Version-specific documentation is also at
|
||||
<https://nodejs.org/download/docs/>.
|
||||
|
||||
### Verifying binaries
|
||||
|
||||
Download directories contain a `SHASUMS256.txt` file with SHA checksums for the
|
||||
files.
|
||||
|
||||
To download `SHASUMS256.txt` using `curl`:
|
||||
|
||||
```bash
|
||||
curl -O https://nodejs.org/dist/vx.y.z/SHASUMS256.txt
|
||||
```
|
||||
|
||||
To check that a downloaded file matches the checksum, run
|
||||
it through `sha256sum` with a command such as:
|
||||
|
||||
```bash
|
||||
grep node-vx.y.z.tar.gz SHASUMS256.txt | sha256sum -c -
|
||||
```
|
||||
|
||||
For Current and LTS, the GPG detached signature of `SHASUMS256.txt` is in
|
||||
`SHASUMS256.txt.sig`. You can use it with `gpg` to verify the integrity of
|
||||
`SHASUMS256.txt`. You will first need to import
|
||||
[the GPG keys of individuals authorized to create releases](#release-keys). To
|
||||
import the keys:
|
||||
|
||||
```bash
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 4ED778F539E3634C779C87C6D7062848A1AB005C
|
||||
```
|
||||
|
||||
See [Release keys](#release-keys) for a script to import active release keys.
|
||||
|
||||
Next, download the `SHASUMS256.txt.sig` for the release:
|
||||
|
||||
```bash
|
||||
curl -O https://nodejs.org/dist/vx.y.z/SHASUMS256.txt.sig
|
||||
```
|
||||
|
||||
Then use `gpg --verify SHASUMS256.txt.sig SHASUMS256.txt` to verify
|
||||
the file's signature.
|
||||
|
||||
## Building Node.js
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for instructions on how to build Node.js from
|
||||
source and a list of supported platforms.
|
||||
|
||||
## Security
|
||||
|
||||
For information on reporting security vulnerabilities in Node.js, see
|
||||
[SECURITY.md](./SECURITY.md).
|
||||
|
||||
## Contributing to Node.js
|
||||
|
||||
* [Contributing to the project][]
|
||||
* [Working Groups][]
|
||||
* [Strategic initiatives][]
|
||||
* [Technical values and prioritization][]
|
||||
|
||||
## Current project team members
|
||||
|
||||
For information about the governance of the Node.js project, see
|
||||
[GOVERNANCE.md](./GOVERNANCE.md).
|
||||
|
||||
<!-- node-core-utils and find-inactive-tsc.mjs depend on the format of the TSC
|
||||
list. If the format changes, those utilities need to be tested and
|
||||
updated. -->
|
||||
|
||||
### TSC (Technical Steering Committee)
|
||||
|
||||
#### TSC voting members
|
||||
|
||||
<!--lint disable prohibited-strings-->
|
||||
|
||||
* [aduh95](https://github.com/aduh95) -
|
||||
**Antoine du Hamel** <<duhamelantoine1995@gmail.com>> (he/him)
|
||||
* [anonrig](https://github.com/anonrig) -
|
||||
**Yagiz Nizipli** <<yagiz.nizipli@sentry.io>> (he/him)
|
||||
* [apapirovski](https://github.com/apapirovski) -
|
||||
**Anatoli Papirovski** <<apapirovski@mac.com>> (he/him)
|
||||
* [benjamingr](https://github.com/benjamingr) -
|
||||
**Benjamin Gruenbaum** <<benjamingr@gmail.com>>
|
||||
* [BridgeAR](https://github.com/BridgeAR) -
|
||||
**Ruben Bridgewater** <<ruben@bridgewater.de>> (he/him)
|
||||
* [GeoffreyBooth](https://github.com/geoffreybooth) -
|
||||
**Geoffrey Booth** <<webadmin@geoffreybooth.com>> (he/him)
|
||||
* [gireeshpunathil](https://github.com/gireeshpunathil) -
|
||||
**Gireesh Punathil** <<gpunathi@in.ibm.com>> (he/him)
|
||||
* [jasnell](https://github.com/jasnell) -
|
||||
**James M Snell** <<jasnell@gmail.com>> (he/him)
|
||||
* [joyeecheung](https://github.com/joyeecheung) -
|
||||
**Joyee Cheung** <<joyeec9h3@gmail.com>> (she/her)
|
||||
* [legendecas](https://github.com/legendecas) -
|
||||
**Chengzhong Wu** <<legendecas@gmail.com>> (he/him)
|
||||
* [mcollina](https://github.com/mcollina) -
|
||||
**Matteo Collina** <<matteo.collina@gmail.com>> (he/him)
|
||||
* [mhdawson](https://github.com/mhdawson) -
|
||||
**Michael Dawson** <<midawson@redhat.com>> (he/him)
|
||||
* [MoLow](https://github.com/MoLow) -
|
||||
**Moshe Atlow** <<moshe@atlow.co.il>> (he/him)
|
||||
* [RafaelGSS](https://github.com/RafaelGSS) -
|
||||
**Rafael Gonzaga** <<rafael.nunu@hotmail.com>> (he/him)
|
||||
* [RaisinTen](https://github.com/RaisinTen) -
|
||||
**Darshan Sen** <<raisinten@gmail.com>> (he/him)
|
||||
* [richardlau](https://github.com/richardlau) -
|
||||
**Richard Lau** <<rlau@redhat.com>>
|
||||
* [ronag](https://github.com/ronag) -
|
||||
**Robert Nagy** <<ronagy@icloud.com>>
|
||||
* [ruyadorno](https://github.com/ruyadorno) -
|
||||
**Ruy Adorno** <<ruyadorno@google.com>> (he/him)
|
||||
* [targos](https://github.com/targos) -
|
||||
**Michaël Zasso** <<targos@protonmail.com>> (he/him)
|
||||
* [tniessen](https://github.com/tniessen) -
|
||||
**Tobias Nießen** <<tniessen@tnie.de>> (he/him)
|
||||
|
||||
#### TSC regular members
|
||||
|
||||
* [BethGriggs](https://github.com/BethGriggs) -
|
||||
**Beth Griggs** <<bethanyngriggs@gmail.com>> (she/her)
|
||||
* [bnoordhuis](https://github.com/bnoordhuis) -
|
||||
**Ben Noordhuis** <<info@bnoordhuis.nl>>
|
||||
* [ChALkeR](https://github.com/ChALkeR) -
|
||||
**Сковорода Никита Андреевич** <<chalkerx@gmail.com>> (he/him)
|
||||
* [cjihrig](https://github.com/cjihrig) -
|
||||
**Colin Ihrig** <<cjihrig@gmail.com>> (he/him)
|
||||
* [codebytere](https://github.com/codebytere) -
|
||||
**Shelley Vohr** <<shelley.vohr@gmail.com>> (she/her)
|
||||
* [danbev](https://github.com/danbev) -
|
||||
**Daniel Bevenius** <<daniel.bevenius@gmail.com>> (he/him)
|
||||
* [danielleadams](https://github.com/danielleadams) -
|
||||
**Danielle Adams** <<adamzdanielle@gmail.com>> (she/her)
|
||||
* [fhinkel](https://github.com/fhinkel) -
|
||||
**Franziska Hinkelmann** <<franziska.hinkelmann@gmail.com>> (she/her)
|
||||
* [gabrielschulhof](https://github.com/gabrielschulhof) -
|
||||
**Gabriel Schulhof** <<gabrielschulhof@gmail.com>>
|
||||
* [mscdex](https://github.com/mscdex) -
|
||||
**Brian White** <<mscdex@mscdex.net>>
|
||||
* [MylesBorins](https://github.com/MylesBorins) -
|
||||
**Myles Borins** <<myles.borins@gmail.com>> (he/him)
|
||||
* [rvagg](https://github.com/rvagg) -
|
||||
**Rod Vagg** <<r@va.gg>>
|
||||
* [TimothyGu](https://github.com/TimothyGu) -
|
||||
**Tiancheng "Timothy" Gu** <<timothygu99@gmail.com>> (he/him)
|
||||
* [Trott](https://github.com/Trott) -
|
||||
**Rich Trott** <<rtrott@gmail.com>> (he/him)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>TSC emeriti members</summary>
|
||||
|
||||
#### TSC emeriti members
|
||||
|
||||
* [addaleax](https://github.com/addaleax) -
|
||||
**Anna Henningsen** <<anna@addaleax.net>> (she/her)
|
||||
* [chrisdickinson](https://github.com/chrisdickinson) -
|
||||
**Chris Dickinson** <<christopher.s.dickinson@gmail.com>>
|
||||
* [evanlucas](https://github.com/evanlucas) -
|
||||
**Evan Lucas** <<evanlucas@me.com>> (he/him)
|
||||
* [Fishrock123](https://github.com/Fishrock123) -
|
||||
**Jeremiah Senkpiel** <<fishrock123@rocketmail.com>> (he/they)
|
||||
* [gibfahn](https://github.com/gibfahn) -
|
||||
**Gibson Fahnestock** <<gibfahn@gmail.com>> (he/him)
|
||||
* [indutny](https://github.com/indutny) -
|
||||
**Fedor Indutny** <<fedor@indutny.com>>
|
||||
* [isaacs](https://github.com/isaacs) -
|
||||
**Isaac Z. Schlueter** <<i@izs.me>>
|
||||
* [joshgav](https://github.com/joshgav) -
|
||||
**Josh Gavant** <<josh.gavant@outlook.com>>
|
||||
* [mmarchini](https://github.com/mmarchini) -
|
||||
**Mary Marchini** <<oss@mmarchini.me>> (she/her)
|
||||
* [nebrius](https://github.com/nebrius) -
|
||||
**Bryan Hughes** <<bryan@nebri.us>>
|
||||
* [ofrobots](https://github.com/ofrobots) -
|
||||
**Ali Ijaz Sheikh** <<ofrobots@google.com>> (he/him)
|
||||
* [orangemocha](https://github.com/orangemocha) -
|
||||
**Alexis Campailla** <<orangemocha@nodejs.org>>
|
||||
* [piscisaureus](https://github.com/piscisaureus) -
|
||||
**Bert Belder** <<bertbelder@gmail.com>>
|
||||
* [sam-github](https://github.com/sam-github) -
|
||||
**Sam Roberts** <<vieuxtech@gmail.com>>
|
||||
* [shigeki](https://github.com/shigeki) -
|
||||
**Shigeki Ohtsu** <<ohtsu@ohtsu.org>> (he/him)
|
||||
* [thefourtheye](https://github.com/thefourtheye) -
|
||||
**Sakthipriyan Vairamani** <<thechargingvolcano@gmail.com>> (he/him)
|
||||
* [trevnorris](https://github.com/trevnorris) -
|
||||
**Trevor Norris** <<trev.norris@gmail.com>>
|
||||
|
||||
</details>
|
||||
|
||||
<!-- node-core-utils and find-inactive-collaborators.mjs depend on the format
|
||||
of the collaborator list. If the format changes, those utilities need to be
|
||||
tested and updated. -->
|
||||
|
||||
### Collaborators
|
||||
|
||||
* [addaleax](https://github.com/addaleax) -
|
||||
**Anna Henningsen** <<anna@addaleax.net>> (she/her)
|
||||
* [aduh95](https://github.com/aduh95) -
|
||||
**Antoine du Hamel** <<duhamelantoine1995@gmail.com>> (he/him)
|
||||
* [anonrig](https://github.com/anonrig) -
|
||||
**Yagiz Nizipli** <<yagiz.nizipli@sentry.io>> (he/him)
|
||||
* [antsmartian](https://github.com/antsmartian) -
|
||||
**Anto Aravinth** <<anto.aravinth.cse@gmail.com>> (he/him)
|
||||
* [apapirovski](https://github.com/apapirovski) -
|
||||
**Anatoli Papirovski** <<apapirovski@mac.com>> (he/him)
|
||||
* [AshCripps](https://github.com/AshCripps) -
|
||||
**Ash Cripps** <<email@ashleycripps.co.uk>>
|
||||
* [atlowChemi](https://github.com/atlowChemi) -
|
||||
**Chemi Atlow** <<chemi@atlow.co.il>> (he/him)
|
||||
* [Ayase-252](https://github.com/Ayase-252) -
|
||||
**Qingyu Deng** <<i@ayase-lab.com>>
|
||||
* [bengl](https://github.com/bengl) -
|
||||
**Bryan English** <<bryan@bryanenglish.com>> (he/him)
|
||||
* [benjamingr](https://github.com/benjamingr) -
|
||||
**Benjamin Gruenbaum** <<benjamingr@gmail.com>>
|
||||
* [BethGriggs](https://github.com/BethGriggs) -
|
||||
**Beth Griggs** <<bethanyngriggs@gmail.com>> (she/her)
|
||||
* [bmeck](https://github.com/bmeck) -
|
||||
**Bradley Farias** <<bradley.meck@gmail.com>>
|
||||
* [bnb](https://github.com/bnb) -
|
||||
**Tierney Cyren** <<hello@bnb.im>> (they/them)
|
||||
* [bnoordhuis](https://github.com/bnoordhuis) -
|
||||
**Ben Noordhuis** <<info@bnoordhuis.nl>>
|
||||
* [BridgeAR](https://github.com/BridgeAR) -
|
||||
**Ruben Bridgewater** <<ruben@bridgewater.de>> (he/him)
|
||||
* [cclauss](https://github.com/cclauss) -
|
||||
**Christian Clauss** <<cclauss@me.com>> (he/him)
|
||||
* [ChALkeR](https://github.com/ChALkeR) -
|
||||
**Сковорода Никита Андреевич** <<chalkerx@gmail.com>> (he/him)
|
||||
* [cjihrig](https://github.com/cjihrig) -
|
||||
**Colin Ihrig** <<cjihrig@gmail.com>> (he/him)
|
||||
* [codebytere](https://github.com/codebytere) -
|
||||
**Shelley Vohr** <<shelley.vohr@gmail.com>> (she/her)
|
||||
* [cola119](https://github.com/cola119) -
|
||||
**Kohei Ueno** <<kohei.ueno119@gmail.com>> (he/him)
|
||||
* [daeyeon](https://github.com/daeyeon) -
|
||||
**Daeyeon Jeong** <<daeyeon.dev@gmail.com>> (he/him)
|
||||
* [danbev](https://github.com/danbev) -
|
||||
**Daniel Bevenius** <<daniel.bevenius@gmail.com>> (he/him)
|
||||
* [danielleadams](https://github.com/danielleadams) -
|
||||
**Danielle Adams** <<adamzdanielle@gmail.com>> (she/her)
|
||||
* [debadree25](https://github.com/debadree25) -
|
||||
**Debadree Chatterjee** <<debadree333@gmail.com>> (he/him)
|
||||
* [deokjinkim](https://github.com/deokjinkim) -
|
||||
**Deokjin Kim** <<deokjin81.kim@gmail.com>> (he/him)
|
||||
* [devnexen](https://github.com/devnexen) -
|
||||
**David Carlier** <<devnexen@gmail.com>>
|
||||
* [devsnek](https://github.com/devsnek) -
|
||||
**Gus Caplan** <<me@gus.host>> (they/them)
|
||||
* [edsadr](https://github.com/edsadr) -
|
||||
**Adrian Estrada** <<edsadr@gmail.com>> (he/him)
|
||||
* [erickwendel](https://github.com/erickwendel) -
|
||||
**Erick Wendel** <<erick.workspace@gmail.com>> (he/him)
|
||||
* [Ethan-Arrowood](https://github.com/Ethan-Arrowood) -
|
||||
**Ethan Arrowood** <<ethan@arrowood.dev>> (he/him)
|
||||
* [fhinkel](https://github.com/fhinkel) -
|
||||
**Franziska Hinkelmann** <<franziska.hinkelmann@gmail.com>> (she/her)
|
||||
* [F3n67u](https://github.com/F3n67u) -
|
||||
**Feng Yu** <<F3n67u@outlook.com>> (he/him)
|
||||
* [Flarna](https://github.com/Flarna) -
|
||||
**Gerhard Stöbich** <<deb2001-github@yahoo.de>> (he/they)
|
||||
* [gabrielschulhof](https://github.com/gabrielschulhof) -
|
||||
**Gabriel Schulhof** <<gabrielschulhof@gmail.com>>
|
||||
* [gengjiawen](https://github.com/gengjiawen) -
|
||||
**Jiawen Geng** <<technicalcute@gmail.com>>
|
||||
* [GeoffreyBooth](https://github.com/geoffreybooth) -
|
||||
**Geoffrey Booth** <<webadmin@geoffreybooth.com>> (he/him)
|
||||
* [gireeshpunathil](https://github.com/gireeshpunathil) -
|
||||
**Gireesh Punathil** <<gpunathi@in.ibm.com>> (he/him)
|
||||
* [guybedford](https://github.com/guybedford) -
|
||||
**Guy Bedford** <<guybedford@gmail.com>> (he/him)
|
||||
* [H4ad](https://github.com/H4ad) -
|
||||
**Vinícius Lourenço Claro Cardoso** <<contact@viniciusl.com.br>> (he/him)
|
||||
* [HarshithaKP](https://github.com/HarshithaKP) -
|
||||
**Harshitha K P** <<harshitha014@gmail.com>> (she/her)
|
||||
* [himself65](https://github.com/himself65) -
|
||||
**Zeyu "Alex" Yang** <<himself65@outlook.com>> (he/him)
|
||||
* [iansu](https://github.com/iansu) -
|
||||
**Ian Sutherland** <<ian@iansutherland.ca>>
|
||||
* [JacksonTian](https://github.com/JacksonTian) -
|
||||
**Jackson Tian** <<shyvo1987@gmail.com>>
|
||||
* [JakobJingleheimer](https://github.com/JakobJingleheimer) -
|
||||
**Jacob Smith** <<jacob@frende.me>> (he/him)
|
||||
* [jasnell](https://github.com/jasnell) -
|
||||
**James M Snell** <<jasnell@gmail.com>> (he/him)
|
||||
* [jkrems](https://github.com/jkrems) -
|
||||
**Jan Krems** <<jan.krems@gmail.com>> (he/him)
|
||||
* [joesepi](https://github.com/joesepi) -
|
||||
**Joe Sepi** <<sepi@joesepi.com>> (he/him)
|
||||
* [joyeecheung](https://github.com/joyeecheung) -
|
||||
**Joyee Cheung** <<joyeec9h3@gmail.com>> (she/her)
|
||||
* [juanarbol](https://github.com/juanarbol) -
|
||||
**Juan José Arboleda** <<soyjuanarbol@gmail.com>> (he/him)
|
||||
* [JungMinu](https://github.com/JungMinu) -
|
||||
**Minwoo Jung** <<nodecorelab@gmail.com>> (he/him)
|
||||
* [KhafraDev](https://github.com/KhafraDev) -
|
||||
**Matthew Aitken** <<maitken033380023@gmail.com>> (he/him)
|
||||
* [kuriyosh](https://github.com/kuriyosh) -
|
||||
**Yoshiki Kurihara** <<yosyos0306@gmail.com>> (he/him)
|
||||
* [kvakil](https://github.com/kvakil) -
|
||||
**Keyhan Vakil** <<kvakil@sylph.kvakil.me>>
|
||||
* [legendecas](https://github.com/legendecas) -
|
||||
**Chengzhong Wu** <<legendecas@gmail.com>> (he/him)
|
||||
* [linkgoron](https://github.com/linkgoron) -
|
||||
**Nitzan Uziely** <<linkgoron@gmail.com>>
|
||||
* [LiviaMedeiros](https://github.com/LiviaMedeiros) -
|
||||
**LiviaMedeiros** <<livia@cirno.name>>
|
||||
* [lpinca](https://github.com/lpinca) -
|
||||
**Luigi Pinca** <<luigipinca@gmail.com>> (he/him)
|
||||
* [lukekarrys](https://github.com/lukekarrys) -
|
||||
**Luke Karrys** <<luke@lukekarrys.com>> (he/him)
|
||||
* [Lxxyx](https://github.com/Lxxyx) -
|
||||
**Zijian Liu** <<lxxyxzj@gmail.com>> (he/him)
|
||||
* [marco-ippolito](https://github.com/marco-ippolito) -
|
||||
**Marco Ippolito** <<marcoippolito54@gmail.com>> (he/him)
|
||||
* [marsonya](https://github.com/marsonya) -
|
||||
**Akhil Marsonya** <<akhil.marsonya27@gmail.com>> (he/him)
|
||||
* [mcollina](https://github.com/mcollina) -
|
||||
**Matteo Collina** <<matteo.collina@gmail.com>> (he/him)
|
||||
* [meixg](https://github.com/meixg) -
|
||||
**Xuguang Mei** <<meixuguang@gmail.com>> (he/him)
|
||||
* [Mesteery](https://github.com/Mesteery) -
|
||||
**Mestery** <<mestery@protonmail.com>> (he/him)
|
||||
* [mhdawson](https://github.com/mhdawson) -
|
||||
**Michael Dawson** <<midawson@redhat.com>> (he/him)
|
||||
* [miladfarca](https://github.com/miladfarca) -
|
||||
**Milad Fa** <<mfarazma@redhat.com>> (he/him)
|
||||
* [mildsunrise](https://github.com/mildsunrise) -
|
||||
**Alba Mendez** <<me@alba.sh>> (she/her)
|
||||
* [MoLow](https://github.com/MoLow) -
|
||||
**Moshe Atlow** <<moshe@atlow.co.il>> (he/him)
|
||||
* [MrJithil](https://github.com/MrJithil) -
|
||||
**Jithil P Ponnan** <<jithil@outlook.com>> (he/him)
|
||||
* [mscdex](https://github.com/mscdex) -
|
||||
**Brian White** <<mscdex@mscdex.net>>
|
||||
* [MylesBorins](https://github.com/MylesBorins) -
|
||||
**Myles Borins** <<myles.borins@gmail.com>> (he/him)
|
||||
* [ovflowd](https://github.com/ovflowd) -
|
||||
**Claudio Wunder** <<cwunder@gnome.org>> (he/they)
|
||||
* [oyyd](https://github.com/oyyd) -
|
||||
**Ouyang Yadong** <<oyydoibh@gmail.com>> (he/him)
|
||||
* [panva](https://github.com/panva) -
|
||||
**Filip Skokan** <<panva.ip@gmail.com>> (he/him)
|
||||
* [Qard](https://github.com/Qard) -
|
||||
**Stephen Belanger** <<admin@stephenbelanger.com>> (he/him)
|
||||
* [RafaelGSS](https://github.com/RafaelGSS) -
|
||||
**Rafael Gonzaga** <<rafael.nunu@hotmail.com>> (he/him)
|
||||
* [RaisinTen](https://github.com/RaisinTen) -
|
||||
**Darshan Sen** <<raisinten@gmail.com>> (he/him)
|
||||
* [rluvaton](https://github.com/rluvaton) -
|
||||
**Raz Luvaton** <<rluvaton@gmail.com>> (he/him)
|
||||
* [richardlau](https://github.com/richardlau) -
|
||||
**Richard Lau** <<rlau@redhat.com>>
|
||||
* [rickyes](https://github.com/rickyes) -
|
||||
**Ricky Zhou** <<0x19951125@gmail.com>> (he/him)
|
||||
* [ronag](https://github.com/ronag) -
|
||||
**Robert Nagy** <<ronagy@icloud.com>>
|
||||
* [ruyadorno](https://github.com/ruyadorno) -
|
||||
**Ruy Adorno** <<ruyadorno@google.com>> (he/him)
|
||||
* [rvagg](https://github.com/rvagg) -
|
||||
**Rod Vagg** <<rod@vagg.org>>
|
||||
* [ryzokuken](https://github.com/ryzokuken) -
|
||||
**Ujjwal Sharma** <<ryzokuken@disroot.org>> (he/him)
|
||||
* [santigimeno](https://github.com/santigimeno) -
|
||||
**Santiago Gimeno** <<santiago.gimeno@gmail.com>>
|
||||
* [shisama](https://github.com/shisama) -
|
||||
**Masashi Hirano** <<shisama07@gmail.com>> (he/him)
|
||||
* [ShogunPanda](https://github.com/ShogunPanda) -
|
||||
**Paolo Insogna** <<paolo@cowtech.it>> (he/him)
|
||||
* [srl295](https://github.com/srl295) -
|
||||
**Steven R Loomis** <<srl295@gmail.com>>
|
||||
* [sxa](https://github.com/sxa) -
|
||||
**Stewart X Addison** <<sxa@redhat.com>> (he/him)
|
||||
* [targos](https://github.com/targos) -
|
||||
**Michaël Zasso** <<targos@protonmail.com>> (he/him)
|
||||
* [theanarkh](https://github.com/theanarkh) -
|
||||
**theanarkh** <<theratliter@gmail.com>> (he/him)
|
||||
* [TimothyGu](https://github.com/TimothyGu) -
|
||||
**Tiancheng "Timothy" Gu** <<timothygu99@gmail.com>> (he/him)
|
||||
* [tniessen](https://github.com/tniessen) -
|
||||
**Tobias Nießen** <<tniessen@tnie.de>> (he/him)
|
||||
* [trivikr](https://github.com/trivikr) -
|
||||
**Trivikram Kamat** <<trivikr.dev@gmail.com>>
|
||||
* [Trott](https://github.com/Trott) -
|
||||
**Rich Trott** <<rtrott@gmail.com>> (he/him)
|
||||
* [vdeturckheim](https://github.com/vdeturckheim) -
|
||||
**Vladimir de Turckheim** <<vlad2t@hotmail.com>> (he/him)
|
||||
* [vmoroz](https://github.com/vmoroz) -
|
||||
**Vladimir Morozov** <<vmorozov@microsoft.com>> (he/him)
|
||||
* [VoltrexKeyva](https://github.com/VoltrexKeyva) -
|
||||
**Mohammed Keyvanzadeh** <<mohammadkeyvanzade94@gmail.com>> (he/him)
|
||||
* [watilde](https://github.com/watilde) -
|
||||
**Daijiro Wachi** <<daijiro.wachi@gmail.com>> (he/him)
|
||||
* [XadillaX](https://github.com/XadillaX) -
|
||||
**Khaidi Chu** <<i@2333.moe>> (he/him)
|
||||
* [yashLadha](https://github.com/yashLadha) -
|
||||
**Yash Ladha** <<yash@yashladha.in>> (he/him)
|
||||
* [ZYSzys](https://github.com/ZYSzys) -
|
||||
**Yongsheng Zhang** <<zyszys98@gmail.com>> (he/him)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Emeriti</summary>
|
||||
|
||||
<!-- find-inactive-collaborators.mjs depends on the format of the emeriti list.
|
||||
If the format changes, those utilities need to be tested and updated. -->
|
||||
|
||||
### Collaborator emeriti
|
||||
|
||||
* [ak239](https://github.com/ak239) -
|
||||
**Aleksei Koziatinskii** <<ak239spb@gmail.com>>
|
||||
* [andrasq](https://github.com/andrasq) -
|
||||
**Andras** <<andras@kinvey.com>>
|
||||
* [AnnaMag](https://github.com/AnnaMag) -
|
||||
**Anna M. Kedzierska** <<anna.m.kedzierska@gmail.com>>
|
||||
* [AndreasMadsen](https://github.com/AndreasMadsen) -
|
||||
**Andreas Madsen** <<amwebdk@gmail.com>> (he/him)
|
||||
* [aqrln](https://github.com/aqrln) -
|
||||
**Alexey Orlenko** <<eaglexrlnk@gmail.com>> (he/him)
|
||||
* [bcoe](https://github.com/bcoe) -
|
||||
**Ben Coe** <<bencoe@gmail.com>> (he/him)
|
||||
* [bmeurer](https://github.com/bmeurer) -
|
||||
**Benedikt Meurer** <<benedikt.meurer@gmail.com>>
|
||||
* [boneskull](https://github.com/boneskull) -
|
||||
**Christopher Hiller** <<boneskull@boneskull.com>> (he/him)
|
||||
* [brendanashworth](https://github.com/brendanashworth) -
|
||||
**Brendan Ashworth** <<brendan.ashworth@me.com>>
|
||||
* [bzoz](https://github.com/bzoz) -
|
||||
**Bartosz Sosnowski** <<bartosz@janeasystems.com>>
|
||||
* [calvinmetcalf](https://github.com/calvinmetcalf) -
|
||||
**Calvin Metcalf** <<calvin.metcalf@gmail.com>>
|
||||
* [chrisdickinson](https://github.com/chrisdickinson) -
|
||||
**Chris Dickinson** <<christopher.s.dickinson@gmail.com>>
|
||||
* [claudiorodriguez](https://github.com/claudiorodriguez) -
|
||||
**Claudio Rodriguez** <<cjrodr@yahoo.com>>
|
||||
* [DavidCai1993](https://github.com/DavidCai1993) -
|
||||
**David Cai** <<davidcai1993@yahoo.com>> (he/him)
|
||||
* [davisjam](https://github.com/davisjam) -
|
||||
**Jamie Davis** <<davisjam@vt.edu>> (he/him)
|
||||
* [digitalinfinity](https://github.com/digitalinfinity) -
|
||||
**Hitesh Kanwathirtha** <<digitalinfinity@gmail.com>> (he/him)
|
||||
* [dmabupt](https://github.com/dmabupt) -
|
||||
**Xu Meng** <<dmabupt@gmail.com>> (he/him)
|
||||
* [dnlup](https://github.com/dnlup)
|
||||
**dnlup** <<dnlup.dev@gmail.com>>
|
||||
* [eljefedelrodeodeljefe](https://github.com/eljefedelrodeodeljefe) -
|
||||
**Robert Jefe Lindstaedt** <<robert.lindstaedt@gmail.com>>
|
||||
* [estliberitas](https://github.com/estliberitas) -
|
||||
**Alexander Makarenko** <<estliberitas@gmail.com>>
|
||||
* [eugeneo](https://github.com/eugeneo) -
|
||||
**Eugene Ostroukhov** <<eostroukhov@google.com>>
|
||||
* [evanlucas](https://github.com/evanlucas) -
|
||||
**Evan Lucas** <<evanlucas@me.com>> (he/him)
|
||||
* [firedfox](https://github.com/firedfox) -
|
||||
**Daniel Wang** <<wangyang0123@gmail.com>>
|
||||
* [Fishrock123](https://github.com/Fishrock123) -
|
||||
**Jeremiah Senkpiel** <<fishrock123@rocketmail.com>> (he/they)
|
||||
* [gdams](https://github.com/gdams) -
|
||||
**George Adams** <<gadams@microsoft.com>> (he/him)
|
||||
* [geek](https://github.com/geek) -
|
||||
**Wyatt Preul** <<wpreul@gmail.com>>
|
||||
* [gibfahn](https://github.com/gibfahn) -
|
||||
**Gibson Fahnestock** <<gibfahn@gmail.com>> (he/him)
|
||||
* [glentiki](https://github.com/glentiki) -
|
||||
**Glen Keane** <<glenkeane.94@gmail.com>> (he/him)
|
||||
* [hashseed](https://github.com/hashseed) -
|
||||
**Yang Guo** <<yangguo@chromium.org>> (he/him)
|
||||
* [hiroppy](https://github.com/hiroppy) -
|
||||
**Yuta Hiroto** <<hello@hiroppy.me>> (he/him)
|
||||
* [iarna](https://github.com/iarna) -
|
||||
**Rebecca Turner** <<me@re-becca.org>>
|
||||
* [imran-iq](https://github.com/imran-iq) -
|
||||
**Imran Iqbal** <<imran@imraniqbal.org>>
|
||||
* [imyller](https://github.com/imyller) -
|
||||
**Ilkka Myller** <<ilkka.myller@nodefield.com>>
|
||||
* [indutny](https://github.com/indutny) -
|
||||
**Fedor Indutny** <<fedor@indutny.com>>
|
||||
* [isaacs](https://github.com/isaacs) -
|
||||
**Isaac Z. Schlueter** <<i@izs.me>>
|
||||
* [italoacasas](https://github.com/italoacasas) -
|
||||
**Italo A. Casas** <<me@italoacasas.com>> (he/him)
|
||||
* [jasongin](https://github.com/jasongin) -
|
||||
**Jason Ginchereau** <<jasongin@microsoft.com>>
|
||||
* [jbergstroem](https://github.com/jbergstroem) -
|
||||
**Johan Bergström** <<bugs@bergstroem.nu>>
|
||||
* [jdalton](https://github.com/jdalton) -
|
||||
**John-David Dalton** <<john.david.dalton@gmail.com>>
|
||||
* [jhamhader](https://github.com/jhamhader) -
|
||||
**Yuval Brik** <<yuval@brik.org.il>>
|
||||
* [joaocgreis](https://github.com/joaocgreis) -
|
||||
**João Reis** <<reis@janeasystems.com>>
|
||||
* [joshgav](https://github.com/joshgav) -
|
||||
**Josh Gavant** <<josh.gavant@outlook.com>>
|
||||
* [julianduque](https://github.com/julianduque) -
|
||||
**Julian Duque** <<julianduquej@gmail.com>> (he/him)
|
||||
* [kfarnung](https://github.com/kfarnung) -
|
||||
**Kyle Farnung** <<kfarnung@microsoft.com>> (he/him)
|
||||
* [kunalspathak](https://github.com/kunalspathak) -
|
||||
**Kunal Pathak** <<kunal.pathak@microsoft.com>>
|
||||
* [lance](https://github.com/lance) -
|
||||
**Lance Ball** <<lball@redhat.com>> (he/him)
|
||||
* [Leko](https://github.com/Leko) -
|
||||
**Shingo Inoue** <<leko.noor@gmail.com>> (he/him)
|
||||
* [lucamaraschi](https://github.com/lucamaraschi) -
|
||||
**Luca Maraschi** <<luca.maraschi@gmail.com>> (he/him)
|
||||
* [lundibundi](https://github.com/lundibundi) -
|
||||
**Denys Otrishko** <<shishugi@gmail.com>> (he/him)
|
||||
* [lxe](https://github.com/lxe) -
|
||||
**Aleksey Smolenchuk** <<lxe@lxe.co>>
|
||||
* [maclover7](https://github.com/maclover7) -
|
||||
**Jon Moss** <<me@jonathanmoss.me>> (he/him)
|
||||
* [mafintosh](https://github.com/mafintosh) -
|
||||
**Mathias Buus** <<mathiasbuus@gmail.com>> (he/him)
|
||||
* [matthewloring](https://github.com/matthewloring) -
|
||||
**Matthew Loring** <<mattloring@google.com>>
|
||||
* [micnic](https://github.com/micnic) -
|
||||
**Nicu Micleușanu** <<micnic90@gmail.com>> (he/him)
|
||||
* [mikeal](https://github.com/mikeal) -
|
||||
**Mikeal Rogers** <<mikeal.rogers@gmail.com>>
|
||||
* [misterdjules](https://github.com/misterdjules) -
|
||||
**Julien Gilli** <<jgilli@netflix.com>>
|
||||
* [mmarchini](https://github.com/mmarchini) -
|
||||
**Mary Marchini** <<oss@mmarchini.me>> (she/her)
|
||||
* [monsanto](https://github.com/monsanto) -
|
||||
**Christopher Monsanto** <<chris@monsan.to>>
|
||||
* [MoonBall](https://github.com/MoonBall) -
|
||||
**Chen Gang** <<gangc.cxy@foxmail.com>>
|
||||
* [not-an-aardvark](https://github.com/not-an-aardvark) -
|
||||
**Teddy Katz** <<teddy.katz@gmail.com>> (he/him)
|
||||
* [ofrobots](https://github.com/ofrobots) -
|
||||
**Ali Ijaz Sheikh** <<ofrobots@google.com>> (he/him)
|
||||
* [Olegas](https://github.com/Olegas) -
|
||||
**Oleg Elifantiev** <<oleg@elifantiev.ru>>
|
||||
* [orangemocha](https://github.com/orangemocha) -
|
||||
**Alexis Campailla** <<orangemocha@nodejs.org>>
|
||||
* [othiym23](https://github.com/othiym23) -
|
||||
**Forrest L Norvell** <<ogd@aoaioxxysz.net>> (they/them/themself)
|
||||
* [petkaantonov](https://github.com/petkaantonov) -
|
||||
**Petka Antonov** <<petka_antonov@hotmail.com>>
|
||||
* [phillipj](https://github.com/phillipj) -
|
||||
**Phillip Johnsen** <<johphi@gmail.com>>
|
||||
* [piscisaureus](https://github.com/piscisaureus) -
|
||||
**Bert Belder** <<bertbelder@gmail.com>>
|
||||
* [pmq20](https://github.com/pmq20) -
|
||||
**Minqi Pan** <<pmq2001@gmail.com>>
|
||||
* [PoojaDurgad](https://github.com/PoojaDurgad) -
|
||||
**Pooja D P** <<Pooja.D.P@ibm.com>> (she/her)
|
||||
* [princejwesley](https://github.com/princejwesley) -
|
||||
**Prince John Wesley** <<princejohnwesley@gmail.com>>
|
||||
* [psmarshall](https://github.com/psmarshall) -
|
||||
**Peter Marshall** <<petermarshall@chromium.org>> (he/him)
|
||||
* [puzpuzpuz](https://github.com/puzpuzpuz) -
|
||||
**Andrey Pechkurov** <<apechkurov@gmail.com>> (he/him)
|
||||
* [refack](https://github.com/refack) -
|
||||
**Refael Ackermann (רפאל פלחי)** <<refack@gmail.com>> (he/him/הוא/אתה)
|
||||
* [rexagod](https://github.com/rexagod) -
|
||||
**Pranshu Srivastava** <<rexagod@gmail.com>> (he/him)
|
||||
* [rlidwka](https://github.com/rlidwka) -
|
||||
**Alex Kocharin** <<alex@kocharin.ru>>
|
||||
* [rmg](https://github.com/rmg) -
|
||||
**Ryan Graham** <<r.m.graham@gmail.com>>
|
||||
* [robertkowalski](https://github.com/robertkowalski) -
|
||||
**Robert Kowalski** <<rok@kowalski.gd>>
|
||||
* [romankl](https://github.com/romankl) -
|
||||
**Roman Klauke** <<romaaan.git@gmail.com>>
|
||||
* [ronkorving](https://github.com/ronkorving) -
|
||||
**Ron Korving** <<ron@ronkorving.nl>>
|
||||
* [RReverser](https://github.com/RReverser) -
|
||||
**Ingvar Stepanyan** <<me@rreverser.com>>
|
||||
* [rubys](https://github.com/rubys) -
|
||||
**Sam Ruby** <<rubys@intertwingly.net>>
|
||||
* [saghul](https://github.com/saghul) -
|
||||
**Saúl Ibarra Corretgé** <<s@saghul.net>>
|
||||
* [sam-github](https://github.com/sam-github) -
|
||||
**Sam Roberts** <<vieuxtech@gmail.com>>
|
||||
* [sebdeckers](https://github.com/sebdeckers) -
|
||||
**Sebastiaan Deckers** <<sebdeckers83@gmail.com>>
|
||||
* [seishun](https://github.com/seishun) -
|
||||
**Nikolai Vavilov** <<vvnicholas@gmail.com>>
|
||||
* [shigeki](https://github.com/shigeki) -
|
||||
**Shigeki Ohtsu** <<ohtsu@ohtsu.org>> (he/him)
|
||||
* [silverwind](https://github.com/silverwind) -
|
||||
**Roman Reiss** <<me@silverwind.io>>
|
||||
* [starkwang](https://github.com/starkwang) -
|
||||
**Weijia Wang** <<starkwang@126.com>>
|
||||
* [stefanmb](https://github.com/stefanmb) -
|
||||
**Stefan Budeanu** <<stefan@budeanu.com>>
|
||||
* [tellnes](https://github.com/tellnes) -
|
||||
**Christian Tellnes** <<christian@tellnes.no>>
|
||||
* [thefourtheye](https://github.com/thefourtheye) -
|
||||
**Sakthipriyan Vairamani** <<thechargingvolcano@gmail.com>> (he/him)
|
||||
* [thlorenz](https://github.com/thlorenz) -
|
||||
**Thorsten Lorenz** <<thlorenz@gmx.de>>
|
||||
* [trevnorris](https://github.com/trevnorris) -
|
||||
**Trevor Norris** <<trev.norris@gmail.com>>
|
||||
* [tunniclm](https://github.com/tunniclm) -
|
||||
**Mike Tunnicliffe** <<m.j.tunnicliffe@gmail.com>>
|
||||
* [vkurchatkin](https://github.com/vkurchatkin) -
|
||||
**Vladimir Kurchatkin** <<vladimir.kurchatkin@gmail.com>>
|
||||
* [vsemozhetbyt](https://github.com/vsemozhetbyt) -
|
||||
**Vse Mozhet Byt** <<vsemozhetbyt@gmail.com>> (he/him)
|
||||
* [watson](https://github.com/watson) -
|
||||
**Thomas Watson** <<w@tson.dk>>
|
||||
* [whitlockjc](https://github.com/whitlockjc) -
|
||||
**Jeremy Whitlock** <<jwhitlock@apache.org>>
|
||||
* [yhwang](https://github.com/yhwang) -
|
||||
**Yihong Wang** <<yh.wang@ibm.com>>
|
||||
* [yorkie](https://github.com/yorkie) -
|
||||
**Yorkie Liu** <<yorkiefixer@gmail.com>>
|
||||
* [yosuke-furukawa](https://github.com/yosuke-furukawa) -
|
||||
**Yosuke Furukawa** <<yosuke.furukawa@gmail.com>>
|
||||
|
||||
</details>
|
||||
|
||||
<!--lint enable prohibited-strings-->
|
||||
|
||||
Collaborators follow the [Collaborator Guide](./doc/contributing/collaborator-guide.md) in
|
||||
maintaining the Node.js project.
|
||||
|
||||
### Triagers
|
||||
|
||||
* [atlowChemi](https://github.com/atlowChemi) -
|
||||
**Chemi Atlow** <<chemi@atlow.co.il>> (he/him)
|
||||
* [Ayase-252](https://github.com/Ayase-252) -
|
||||
**Qingyu Deng** <<i@ayase-lab.com>>
|
||||
* [bmuenzenmeyer](https://github.com/bmuenzenmeyer) -
|
||||
**Brian Muenzenmeyer** <<brian.muenzenmeyer@gmail.com>> (he/him)
|
||||
* [CanadaHonk](https://github.com/CanadaHonk) -
|
||||
**Oliver Medhurst** <<honk@goose.icu>> (they/them)
|
||||
* [daeyeon](https://github.com/daeyeon) -
|
||||
**Daeyeon Jeong** <<daeyeon.dev@gmail.com>> (he/him)
|
||||
* [F3n67u](https://github.com/F3n67u) -
|
||||
**Feng Yu** <<F3n67u@outlook.com>> (he/him)
|
||||
* [himadriganguly](https://github.com/himadriganguly) -
|
||||
**Himadri Ganguly** <<himadri.tech@gmail.com>> (he/him)
|
||||
* [iam-frankqiu](https://github.com/iam-frankqiu) -
|
||||
**Frank Qiu** <<iam.frankqiu@gmail.com>> (he/him)
|
||||
* [marsonya](https://github.com/marsonya) -
|
||||
**Akhil Marsonya** <<akhil.marsonya27@gmail.com>> (he/him)
|
||||
* [meixg](https://github.com/meixg) -
|
||||
**Xuguang Mei** <<meixuguang@gmail.com>> (he/him)
|
||||
* [mertcanaltin](https://github.com/mertcanaltin) -
|
||||
**Mert Can Altin** <<mertgold60@gmail.com>>
|
||||
* [Mesteery](https://github.com/Mesteery) -
|
||||
**Mestery** <<mestery@protonmail.com>> (he/him)
|
||||
* [preveen-stack](https://github.com/preveen-stack) -
|
||||
**Preveen Padmanabhan** <<wide4head@gmail.com>> (he/him)
|
||||
* [PoojaDurgad](https://github.com/PoojaDurgad) -
|
||||
**Pooja Durgad** <<Pooja.D.P@ibm.com>>
|
||||
* [RaisinTen](https://github.com/RaisinTen) -
|
||||
**Darshan Sen** <<raisinten@gmail.com>>
|
||||
* [VoltrexKeyva](https://github.com/VoltrexKeyva) -
|
||||
**Mohammed Keyvanzadeh** <<mohammadkeyvanzade94@gmail.com>> (he/him)
|
||||
|
||||
Triagers follow the [Triage Guide](./doc/contributing/issues.md#triaging-a-bug-report) when
|
||||
responding to new issues.
|
||||
|
||||
### Release keys
|
||||
|
||||
Primary GPG keys for Node.js Releasers (some Releasers sign with subkeys):
|
||||
|
||||
* **Beth Griggs** <<bethanyngriggs@gmail.com>>
|
||||
`4ED778F539E3634C779C87C6D7062848A1AB005C`
|
||||
* **Bryan English** <<bryan@bryanenglish.com>>
|
||||
`141F07595B7B3FFE74309A937405533BE57C7D57`
|
||||
* **Danielle Adams** <<adamzdanielle@gmail.com>>
|
||||
`74F12602B6F1C4E913FAA37AD3A89613643B6201`
|
||||
* **Juan José Arboleda** <<soyjuanarbol@gmail.com>>
|
||||
`DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7`
|
||||
* **Michaël Zasso** <<targos@protonmail.com>>
|
||||
`8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600`
|
||||
* **Myles Borins** <<myles.borins@gmail.com>>
|
||||
`C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8`
|
||||
* **RafaelGSS** <<rafael.nunu@hotmail.com>>
|
||||
`890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4`
|
||||
* **Richard Lau** <<rlau@redhat.com>>
|
||||
`C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C`
|
||||
* **Ruy Adorno** <<ruyadorno@hotmail.com>>
|
||||
`108F52B48DB57BB0CC439B2997B01419BD92F80A`
|
||||
* **Ulises Gascón** <<ulisesgascongonzalez@gmail.com>>
|
||||
`A363A499291CBBC940DD62E41F10027AF002F8B0`
|
||||
|
||||
To import the full set of trusted release keys (including subkeys possibly used
|
||||
to sign releases):
|
||||
|
||||
```bash
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 4ED778F539E3634C779C87C6D7062848A1AB005C
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 141F07595B7B3FFE74309A937405533BE57C7D57
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 74F12602B6F1C4E913FAA37AD3A89613643B6201
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 108F52B48DB57BB0CC439B2997B01419BD92F80A
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys A363A499291CBBC940DD62E41F10027AF002F8B0
|
||||
```
|
||||
|
||||
See [Verifying binaries](#verifying-binaries) for how to use these keys to
|
||||
verify a downloaded file.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Other keys used to sign some previous releases</summary>
|
||||
|
||||
* **Chris Dickinson** <<christopher.s.dickinson@gmail.com>>
|
||||
`9554F04D7259F04124DE6B476D5A82AC7E37093B`
|
||||
* **Colin Ihrig** <<cjihrig@gmail.com>>
|
||||
`94AE36675C464D64BAFA68DD7434390BDBE9B9C5`
|
||||
* **Danielle Adams** <<adamzdanielle@gmail.com>>
|
||||
`1C050899334244A8AF75E53792EF661D867B9DFA`
|
||||
* **Evan Lucas** <<evanlucas@me.com>>
|
||||
`B9AE9905FFD7803F25714661B63B535A4C206CA9`
|
||||
* **Gibson Fahnestock** <<gibfahn@gmail.com>>
|
||||
`77984A986EBC2AA786BC0F66B01FBB92821C587A`
|
||||
* **Isaac Z. Schlueter** <<i@izs.me>>
|
||||
`93C7E9E91B49E432C2F75674B0A78B0A6C481CF6`
|
||||
* **Italo A. Casas** <<me@italoacasas.com>>
|
||||
`56730D5401028683275BD23C23EFEFE93C4CFFFE`
|
||||
* **James M Snell** <<jasnell@keybase.io>>
|
||||
`71DCFD284A79C3B38668286BC97EC7A07EDE3FC1`
|
||||
* **Jeremiah Senkpiel** <<fishrock@keybase.io>>
|
||||
`FD3A5288F042B6850C66B31F09FE44734EB7990E`
|
||||
* **Juan José Arboleda** <<soyjuanarbol@gmail.com>>
|
||||
`61FC681DFB92A079F1685E77973F295594EC4689`
|
||||
* **Julien Gilli** <<jgilli@fastmail.fm>>
|
||||
`114F43EE0176B71C7BC219DD50A3051F888C628D`
|
||||
* **Rod Vagg** <<rod@vagg.org>>
|
||||
`DD8F2338BAE7501E3DD5AC78C273792F7D83545D`
|
||||
* **Ruben Bridgewater** <<ruben@bridgewater.de>>
|
||||
`A48C2BEE680E841632CD4E44F07496B3EB3C1762`
|
||||
* **Shelley Vohr** <<shelley.vohr@gmail.com>>
|
||||
`B9E2F5981AA6E0CD28160D9FF13993A75599653C`
|
||||
* **Timothy J Fontaine** <<tjfontaine@gmail.com>>
|
||||
`7937DFD2AB06298B2293C3187D33FF9D0246406D`
|
||||
|
||||
</details>
|
||||
|
||||
### Security release stewards
|
||||
|
||||
When possible, the commitment to take slots in the
|
||||
security release steward rotation is made by companies in order
|
||||
to ensure individuals who act as security stewards have the
|
||||
support and recognition from their employer to be able to
|
||||
prioritize security releases. Security release stewards manage security
|
||||
releases on a rotation basis as outlined in the
|
||||
[security release process](./doc/contributing/security-release-process.md).
|
||||
|
||||
* Datadog
|
||||
* [bengl](https://github.com/bengl) -
|
||||
**Bryan English** <<bryan@bryanenglish.com>> (he/him)
|
||||
* NearForm
|
||||
* [RafaelGSS](https://github.com/RafaelGSS) -
|
||||
**Rafael Gonzaga** <<rafael.nunu@hotmail.com>> (he/him)
|
||||
* NodeSource
|
||||
* [juanarbol](https://github.com/juanarbol) -
|
||||
**Juan José Arboleda** <<soyjuanarbol@gmail.com>> (he/him)
|
||||
* Platformatic
|
||||
* [mcollina](https://github.com/mcollina) -
|
||||
**Matteo Collina** <<matteo.collina@gmail.com>> (he/him)
|
||||
* Red Hat and IBM
|
||||
* [joesepi](https://github.com/joesepi) -
|
||||
**Joe Sepi** <<joesepi@ibm.com>> (he/him)
|
||||
* [mhdawson](https://github.com/mhdawson) -
|
||||
**Michael Dawson** <<midawson@redhat.com>> (he/him)
|
||||
|
||||
## License
|
||||
|
||||
Node.js is available under the
|
||||
[MIT license](https://opensource.org/licenses/MIT). Node.js also includes
|
||||
external libraries that are available under a variety of licenses. See
|
||||
[LICENSE](https://github.com/nodejs/node/blob/HEAD/LICENSE) for the full
|
||||
license text.
|
||||
|
||||
[Code of Conduct]: https://github.com/nodejs/admin/blob/HEAD/CODE_OF_CONDUCT.md
|
||||
[Contributing to the project]: CONTRIBUTING.md
|
||||
[Node.js website]: https://nodejs.org/
|
||||
[OpenJS Foundation]: https://openjsf.org/
|
||||
[Strategic initiatives]: doc/contributing/strategic-initiatives.md
|
||||
[Technical values and prioritization]: doc/contributing/technical-values.md
|
||||
[Working Groups]: https://github.com/nodejs/TSC/blob/HEAD/WORKING_GROUPS.md
|
||||
12
.tools/node_extracted/node-v20.11.0-win-x64/corepack
Normal file
12
.tools/node_extracted/node-v20.11.0-win-x64/corepack
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/node_modules/corepack/dist/corepack.js" "$@"
|
||||
else
|
||||
exec node "$basedir/node_modules/corepack/dist/corepack.js" "$@"
|
||||
fi
|
||||
7
.tools/node_extracted/node-v20.11.0-win-x64/corepack.cmd
Normal file
7
.tools/node_extracted/node-v20.11.0-win-x64/corepack.cmd
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@SETLOCAL
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\node_modules\corepack\dist\corepack.js" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\node_modules\corepack\dist\corepack.js" %*
|
||||
)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
@echo off
|
||||
|
||||
setlocal
|
||||
title Install Additional Tools for Node.js
|
||||
|
||||
cls
|
||||
|
||||
echo ====================================================
|
||||
echo Tools for Node.js Native Modules Installation Script
|
||||
echo ====================================================
|
||||
echo.
|
||||
echo This script will install Python and the Visual Studio Build Tools, necessary
|
||||
echo to compile Node.js native modules. Note that Chocolatey and required Windows
|
||||
echo updates will also be installed.
|
||||
echo.
|
||||
echo This will require about 3 GiB of free disk space, plus any space necessary to
|
||||
echo install Windows updates. This will take a while to run.
|
||||
echo.
|
||||
echo Please close all open programs for the duration of the installation. If the
|
||||
echo installation fails, please ensure Windows is fully updated, reboot your
|
||||
echo computer and try to run this again. This script can be found in the
|
||||
echo Start menu under Node.js.
|
||||
echo.
|
||||
echo You can close this window to stop now. Detailed instructions to install these
|
||||
echo tools manually are available at https://github.com/nodejs/node-gyp#on-windows
|
||||
echo.
|
||||
pause
|
||||
|
||||
cls
|
||||
|
||||
REM Adapted from https://github.com/Microsoft/windows-dev-box-setup-scripts/blob/79bbe5bdc4867088b3e074f9610932f8e4e192c2/README.md#legal
|
||||
echo Using this script downloads third party software
|
||||
echo ------------------------------------------------
|
||||
echo This script will direct to Chocolatey to install packages. By using
|
||||
echo Chocolatey to install a package, you are accepting the license for the
|
||||
echo application, executable(s), or other artifacts delivered to your machine as a
|
||||
echo result of a Chocolatey install. This acceptance occurs whether you know the
|
||||
echo license terms or not. Read and understand the license terms of the packages
|
||||
echo being installed and their dependencies prior to installation:
|
||||
echo - https://chocolatey.org/packages/chocolatey
|
||||
echo - https://chocolatey.org/packages/python
|
||||
echo - https://chocolatey.org/packages/visualstudio2019-workload-vctools
|
||||
echo.
|
||||
echo This script is provided AS-IS without any warranties of any kind
|
||||
echo ----------------------------------------------------------------
|
||||
echo Chocolatey has implemented security safeguards in their process to help
|
||||
echo protect the community from malicious or pirated software, but any use of this
|
||||
echo script is at your own risk. Please read the Chocolatey's legal terms of use
|
||||
echo as well as how the community repository for Chocolatey.org is maintained.
|
||||
echo.
|
||||
pause
|
||||
|
||||
cls
|
||||
|
||||
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command Start-Process '%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe' -ArgumentList '-NoProfile -InputFormat None -ExecutionPolicy Bypass -Command [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; iex ((New-Object System.Net.WebClient).DownloadString(''https://chocolatey.org/install.ps1'')); choco upgrade -y python visualstudio2019-workload-vctools; Read-Host ''Type ENTER to exit'' ' -Verb RunAs
|
||||
BIN
.tools/node_extracted/node-v20.11.0-win-x64/node.exe
Normal file
BIN
.tools/node_extracted/node-v20.11.0-win-x64/node.exe
Normal file
Binary file not shown.
24
.tools/node_extracted/node-v20.11.0-win-x64/nodevars.bat
Normal file
24
.tools/node_extracted/node-v20.11.0-win-x64/nodevars.bat
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
@echo off
|
||||
|
||||
rem Ensure this Node.js and npm are first in the PATH
|
||||
set "PATH=%APPDATA%\npm;%~dp0;%PATH%"
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
pushd "%~dp0"
|
||||
|
||||
rem Figure out the Node.js version.
|
||||
set print_version=.\node.exe -p -e "process.versions.node + ' (' + process.arch + ')'"
|
||||
for /F "usebackq delims=" %%v in (`%print_version%`) do set version=%%v
|
||||
|
||||
rem Print message.
|
||||
if exist npm.cmd (
|
||||
echo Your environment has been set up for using Node.js !version! and npm.
|
||||
) else (
|
||||
echo Your environment has been set up for using Node.js !version!.
|
||||
)
|
||||
|
||||
popd
|
||||
endlocal
|
||||
|
||||
rem If we're in the Node.js directory, change to the user's home dir.
|
||||
if "%CD%\"=="%~dp0" cd /d "%HOMEDRIVE%%HOMEPATH%"
|
||||
64
.tools/node_extracted/node-v20.11.0-win-x64/npm
Normal file
64
.tools/node_extracted/node-v20.11.0-win-x64/npm
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# This is used by the Node.js installer, which expects the cygwin/mingw
|
||||
# shell script to already be present in the npm dependency folder.
|
||||
|
||||
(set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix
|
||||
|
||||
basedir=`dirname "$0"`
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ `uname` = 'Linux' ] && type wslpath &>/dev/null ; then
|
||||
IS_WSL="true"
|
||||
fi
|
||||
|
||||
function no_node_dir {
|
||||
# if this didn't work, then everything else below will fail
|
||||
echo "Could not determine Node.js install directory" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
NODE_EXE="$basedir/node.exe"
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE="$basedir/node"
|
||||
fi
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE=node
|
||||
fi
|
||||
|
||||
# this path is passed to node.exe, so it needs to match whatever
|
||||
# kind of paths Node.js thinks it's using, typically win32 paths.
|
||||
CLI_BASEDIR="$("$NODE_EXE" -p 'require("path").dirname(process.execPath)' 2> /dev/null)"
|
||||
if [ $? -ne 0 ]; then
|
||||
# this fails under WSL 1 so add an additional message. we also suppress stderr above
|
||||
# because the actual error raised is not helpful. in WSL 1 node.exe cannot handle
|
||||
# output redirection properly. See https://github.com/microsoft/WSL/issues/2370
|
||||
if [ "$IS_WSL" == "true" ]; then
|
||||
echo "WSL 1 is not supported. Please upgrade to WSL 2 or above." >&2
|
||||
fi
|
||||
no_node_dir
|
||||
fi
|
||||
NPM_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npm-cli.js"
|
||||
NPM_PREFIX=`"$NODE_EXE" "$NPM_CLI_JS" prefix -g`
|
||||
if [ $? -ne 0 ]; then
|
||||
no_node_dir
|
||||
fi
|
||||
NPM_PREFIX_NPM_CLI_JS="$NPM_PREFIX/node_modules/npm/bin/npm-cli.js"
|
||||
|
||||
# a path that will fail -f test on any posix bash
|
||||
NPM_WSL_PATH="/.."
|
||||
|
||||
# WSL can run Windows binaries, so we have to give it the win32 path
|
||||
# however, WSL bash tests against posix paths, so we need to construct that
|
||||
# to know if npm is installed globally.
|
||||
if [ "$IS_WSL" == "true" ]; then
|
||||
NPM_WSL_PATH=`wslpath "$NPM_PREFIX_NPM_CLI_JS"`
|
||||
fi
|
||||
if [ -f "$NPM_PREFIX_NPM_CLI_JS" ] || [ -f "$NPM_WSL_PATH" ]; then
|
||||
NPM_CLI_JS="$NPM_PREFIX_NPM_CLI_JS"
|
||||
fi
|
||||
|
||||
"$NODE_EXE" "$NPM_CLI_JS" "$@"
|
||||
19
.tools/node_extracted/node-v20.11.0-win-x64/npm.cmd
Normal file
19
.tools/node_extracted/node-v20.11.0-win-x64/npm.cmd
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
:: Created by npm, please don't edit manually.
|
||||
@ECHO OFF
|
||||
|
||||
SETLOCAL
|
||||
|
||||
SET "NODE_EXE=%~dp0\node.exe"
|
||||
IF NOT EXIST "%NODE_EXE%" (
|
||||
SET "NODE_EXE=node"
|
||||
)
|
||||
|
||||
SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
|
||||
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_CLI_JS%" prefix -g') DO (
|
||||
SET "NPM_PREFIX_NPM_CLI_JS=%%F\node_modules\npm\bin\npm-cli.js"
|
||||
)
|
||||
IF EXIST "%NPM_PREFIX_NPM_CLI_JS%" (
|
||||
SET "NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%"
|
||||
)
|
||||
|
||||
"%NODE_EXE%" "%NPM_CLI_JS%" %*
|
||||
65
.tools/node_extracted/node-v20.11.0-win-x64/npx
Normal file
65
.tools/node_extracted/node-v20.11.0-win-x64/npx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# This is used by the Node.js installer, which expects the cygwin/mingw
|
||||
# shell script to already be present in the npm dependency folder.
|
||||
|
||||
(set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix
|
||||
|
||||
basedir=`dirname "$0"`
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ `uname` = 'Linux' ] && type wslpath &>/dev/null ; then
|
||||
IS_WSL="true"
|
||||
fi
|
||||
|
||||
function no_node_dir {
|
||||
# if this didn't work, then everything else below will fail
|
||||
echo "Could not determine Node.js install directory" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
NODE_EXE="$basedir/node.exe"
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE="$basedir/node"
|
||||
fi
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE=node
|
||||
fi
|
||||
|
||||
# this path is passed to node.exe, so it needs to match whatever
|
||||
# kind of paths Node.js thinks it's using, typically win32 paths.
|
||||
CLI_BASEDIR="$("$NODE_EXE" -p 'require("path").dirname(process.execPath)' 2> /dev/null)"
|
||||
if [ $? -ne 0 ]; then
|
||||
# this fails under WSL 1 so add an additional message. we also suppress stderr above
|
||||
# because the actual error raised is not helpful. in WSL 1 node.exe cannot handle
|
||||
# output redirection properly. See https://github.com/microsoft/WSL/issues/2370
|
||||
if [ "$IS_WSL" == "true" ]; then
|
||||
echo "WSL 1 is not supported. Please upgrade to WSL 2 or above." >&2
|
||||
fi
|
||||
no_node_dir
|
||||
fi
|
||||
NPM_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npm-cli.js"
|
||||
NPX_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npx-cli.js"
|
||||
NPM_PREFIX=`"$NODE_EXE" "$NPM_CLI_JS" prefix -g`
|
||||
if [ $? -ne 0 ]; then
|
||||
no_node_dir
|
||||
fi
|
||||
NPM_PREFIX_NPX_CLI_JS="$NPM_PREFIX/node_modules/npm/bin/npx-cli.js"
|
||||
|
||||
# a path that will fail -f test on any posix bash
|
||||
NPX_WSL_PATH="/.."
|
||||
|
||||
# WSL can run Windows binaries, so we have to give it the win32 path
|
||||
# however, WSL bash tests against posix paths, so we need to construct that
|
||||
# to know if npm is installed globally.
|
||||
if [ "$IS_WSL" == "true" ]; then
|
||||
NPX_WSL_PATH=`wslpath "$NPM_PREFIX_NPX_CLI_JS"`
|
||||
fi
|
||||
if [ -f "$NPM_PREFIX_NPX_CLI_JS" ] || [ -f "$NPX_WSL_PATH" ]; then
|
||||
NPX_CLI_JS="$NPM_PREFIX_NPX_CLI_JS"
|
||||
fi
|
||||
|
||||
"$NODE_EXE" "$NPX_CLI_JS" "$@"
|
||||
20
.tools/node_extracted/node-v20.11.0-win-x64/npx.cmd
Normal file
20
.tools/node_extracted/node-v20.11.0-win-x64/npx.cmd
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
:: Created by npm, please don't edit manually.
|
||||
@ECHO OFF
|
||||
|
||||
SETLOCAL
|
||||
|
||||
SET "NODE_EXE=%~dp0\node.exe"
|
||||
IF NOT EXIST "%NODE_EXE%" (
|
||||
SET "NODE_EXE=node"
|
||||
)
|
||||
|
||||
SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
|
||||
SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
|
||||
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_CLI_JS%" prefix -g') DO (
|
||||
SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js"
|
||||
)
|
||||
IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" (
|
||||
SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%"
|
||||
)
|
||||
|
||||
"%NODE_EXE%" "%NPX_CLI_JS%" %*
|
||||
1377
.tools/node_tar/node-v20.11.0-win-x64/CHANGELOG.md
Normal file
1377
.tools/node_tar/node-v20.11.0-win-x64/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
2183
.tools/node_tar/node-v20.11.0-win-x64/LICENSE
Normal file
2183
.tools/node_tar/node-v20.11.0-win-x64/LICENSE
Normal file
File diff suppressed because it is too large
Load diff
871
.tools/node_tar/node-v20.11.0-win-x64/README.md
Normal file
871
.tools/node_tar/node-v20.11.0-win-x64/README.md
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
# Node.js
|
||||
|
||||
Node.js is an open-source, cross-platform JavaScript runtime environment.
|
||||
|
||||
For information on using Node.js, see the [Node.js website][].
|
||||
|
||||
The Node.js project uses an [open governance model](./GOVERNANCE.md). The
|
||||
[OpenJS Foundation][] provides support for the project.
|
||||
|
||||
Contributors are expected to act in a collaborative manner to move
|
||||
the project forward. We encourage the constructive exchange of contrary
|
||||
opinions and compromise. The [TSC](./GOVERNANCE.md#technical-steering-committee)
|
||||
reserves the right to limit or block contributors who repeatedly act in ways
|
||||
that discourage, exhaust, or otherwise negatively affect other participants.
|
||||
|
||||
**This project has a [Code of Conduct][].**
|
||||
|
||||
## Table of contents
|
||||
|
||||
* [Support](#support)
|
||||
* [Release types](#release-types)
|
||||
* [Download](#download)
|
||||
* [Current and LTS releases](#current-and-lts-releases)
|
||||
* [Nightly releases](#nightly-releases)
|
||||
* [API documentation](#api-documentation)
|
||||
* [Verifying binaries](#verifying-binaries)
|
||||
* [Building Node.js](#building-nodejs)
|
||||
* [Security](#security)
|
||||
* [Contributing to Node.js](#contributing-to-nodejs)
|
||||
* [Current project team members](#current-project-team-members)
|
||||
* [TSC (Technical Steering Committee)](#tsc-technical-steering-committee)
|
||||
* [Collaborators](#collaborators)
|
||||
* [Triagers](#triagers)
|
||||
* [Release keys](#release-keys)
|
||||
* [License](#license)
|
||||
|
||||
## Support
|
||||
|
||||
Looking for help? Check out the
|
||||
[instructions for getting support](.github/SUPPORT.md).
|
||||
|
||||
## Release types
|
||||
|
||||
* **Current**: Under active development. Code for the Current release is in the
|
||||
branch for its major version number (for example,
|
||||
[v19.x](https://github.com/nodejs/node/tree/v19.x)). Node.js releases a new
|
||||
major version every 6 months, allowing for breaking changes. This happens in
|
||||
April and October every year. Releases appearing each October have a support
|
||||
life of 8 months. Releases appearing each April convert to LTS (see below)
|
||||
each October.
|
||||
* **LTS**: Releases that receive Long Term Support, with a focus on stability
|
||||
and security. Every even-numbered major version will become an LTS release.
|
||||
LTS releases receive 12 months of _Active LTS_ support and a further 18 months
|
||||
of _Maintenance_. LTS release lines have alphabetically-ordered code names,
|
||||
beginning with v4 Argon. There are no breaking changes or feature additions,
|
||||
except in some special circumstances.
|
||||
* **Nightly**: Code from the Current branch built every 24-hours when there are
|
||||
changes. Use with caution.
|
||||
|
||||
Current and LTS releases follow [semantic versioning](https://semver.org). A
|
||||
member of the Release Team [signs](#release-keys) each Current and LTS release.
|
||||
For more information, see the
|
||||
[Release README](https://github.com/nodejs/Release#readme).
|
||||
|
||||
### Download
|
||||
|
||||
Binaries, installers, and source tarballs are available at
|
||||
<https://nodejs.org/en/download/>.
|
||||
|
||||
#### Current and LTS releases
|
||||
|
||||
<https://nodejs.org/download/release/>
|
||||
|
||||
The [latest](https://nodejs.org/download/release/latest/) directory is an
|
||||
alias for the latest Current release. The latest-_codename_ directory is an
|
||||
alias for the latest release from an LTS line. For example, the
|
||||
[latest-hydrogen](https://nodejs.org/download/release/latest-hydrogen/)
|
||||
directory contains the latest Hydrogen (Node.js 18) release.
|
||||
|
||||
#### Nightly releases
|
||||
|
||||
<https://nodejs.org/download/nightly/>
|
||||
|
||||
Each directory name and filename contains a date (in UTC) and the commit
|
||||
SHA at the HEAD of the release.
|
||||
|
||||
#### API documentation
|
||||
|
||||
Documentation for the latest Current release is at <https://nodejs.org/api/>.
|
||||
Version-specific documentation is available in each release directory in the
|
||||
_docs_ subdirectory. Version-specific documentation is also at
|
||||
<https://nodejs.org/download/docs/>.
|
||||
|
||||
### Verifying binaries
|
||||
|
||||
Download directories contain a `SHASUMS256.txt` file with SHA checksums for the
|
||||
files.
|
||||
|
||||
To download `SHASUMS256.txt` using `curl`:
|
||||
|
||||
```bash
|
||||
curl -O https://nodejs.org/dist/vx.y.z/SHASUMS256.txt
|
||||
```
|
||||
|
||||
To check that a downloaded file matches the checksum, run
|
||||
it through `sha256sum` with a command such as:
|
||||
|
||||
```bash
|
||||
grep node-vx.y.z.tar.gz SHASUMS256.txt | sha256sum -c -
|
||||
```
|
||||
|
||||
For Current and LTS, the GPG detached signature of `SHASUMS256.txt` is in
|
||||
`SHASUMS256.txt.sig`. You can use it with `gpg` to verify the integrity of
|
||||
`SHASUMS256.txt`. You will first need to import
|
||||
[the GPG keys of individuals authorized to create releases](#release-keys). To
|
||||
import the keys:
|
||||
|
||||
```bash
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 4ED778F539E3634C779C87C6D7062848A1AB005C
|
||||
```
|
||||
|
||||
See [Release keys](#release-keys) for a script to import active release keys.
|
||||
|
||||
Next, download the `SHASUMS256.txt.sig` for the release:
|
||||
|
||||
```bash
|
||||
curl -O https://nodejs.org/dist/vx.y.z/SHASUMS256.txt.sig
|
||||
```
|
||||
|
||||
Then use `gpg --verify SHASUMS256.txt.sig SHASUMS256.txt` to verify
|
||||
the file's signature.
|
||||
|
||||
## Building Node.js
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for instructions on how to build Node.js from
|
||||
source and a list of supported platforms.
|
||||
|
||||
## Security
|
||||
|
||||
For information on reporting security vulnerabilities in Node.js, see
|
||||
[SECURITY.md](./SECURITY.md).
|
||||
|
||||
## Contributing to Node.js
|
||||
|
||||
* [Contributing to the project][]
|
||||
* [Working Groups][]
|
||||
* [Strategic initiatives][]
|
||||
* [Technical values and prioritization][]
|
||||
|
||||
## Current project team members
|
||||
|
||||
For information about the governance of the Node.js project, see
|
||||
[GOVERNANCE.md](./GOVERNANCE.md).
|
||||
|
||||
<!-- node-core-utils and find-inactive-tsc.mjs depend on the format of the TSC
|
||||
list. If the format changes, those utilities need to be tested and
|
||||
updated. -->
|
||||
|
||||
### TSC (Technical Steering Committee)
|
||||
|
||||
#### TSC voting members
|
||||
|
||||
<!--lint disable prohibited-strings-->
|
||||
|
||||
* [aduh95](https://github.com/aduh95) -
|
||||
**Antoine du Hamel** <<duhamelantoine1995@gmail.com>> (he/him)
|
||||
* [anonrig](https://github.com/anonrig) -
|
||||
**Yagiz Nizipli** <<yagiz.nizipli@sentry.io>> (he/him)
|
||||
* [apapirovski](https://github.com/apapirovski) -
|
||||
**Anatoli Papirovski** <<apapirovski@mac.com>> (he/him)
|
||||
* [benjamingr](https://github.com/benjamingr) -
|
||||
**Benjamin Gruenbaum** <<benjamingr@gmail.com>>
|
||||
* [BridgeAR](https://github.com/BridgeAR) -
|
||||
**Ruben Bridgewater** <<ruben@bridgewater.de>> (he/him)
|
||||
* [GeoffreyBooth](https://github.com/geoffreybooth) -
|
||||
**Geoffrey Booth** <<webadmin@geoffreybooth.com>> (he/him)
|
||||
* [gireeshpunathil](https://github.com/gireeshpunathil) -
|
||||
**Gireesh Punathil** <<gpunathi@in.ibm.com>> (he/him)
|
||||
* [jasnell](https://github.com/jasnell) -
|
||||
**James M Snell** <<jasnell@gmail.com>> (he/him)
|
||||
* [joyeecheung](https://github.com/joyeecheung) -
|
||||
**Joyee Cheung** <<joyeec9h3@gmail.com>> (she/her)
|
||||
* [legendecas](https://github.com/legendecas) -
|
||||
**Chengzhong Wu** <<legendecas@gmail.com>> (he/him)
|
||||
* [mcollina](https://github.com/mcollina) -
|
||||
**Matteo Collina** <<matteo.collina@gmail.com>> (he/him)
|
||||
* [mhdawson](https://github.com/mhdawson) -
|
||||
**Michael Dawson** <<midawson@redhat.com>> (he/him)
|
||||
* [MoLow](https://github.com/MoLow) -
|
||||
**Moshe Atlow** <<moshe@atlow.co.il>> (he/him)
|
||||
* [RafaelGSS](https://github.com/RafaelGSS) -
|
||||
**Rafael Gonzaga** <<rafael.nunu@hotmail.com>> (he/him)
|
||||
* [RaisinTen](https://github.com/RaisinTen) -
|
||||
**Darshan Sen** <<raisinten@gmail.com>> (he/him)
|
||||
* [richardlau](https://github.com/richardlau) -
|
||||
**Richard Lau** <<rlau@redhat.com>>
|
||||
* [ronag](https://github.com/ronag) -
|
||||
**Robert Nagy** <<ronagy@icloud.com>>
|
||||
* [ruyadorno](https://github.com/ruyadorno) -
|
||||
**Ruy Adorno** <<ruyadorno@google.com>> (he/him)
|
||||
* [targos](https://github.com/targos) -
|
||||
**Michaël Zasso** <<targos@protonmail.com>> (he/him)
|
||||
* [tniessen](https://github.com/tniessen) -
|
||||
**Tobias Nießen** <<tniessen@tnie.de>> (he/him)
|
||||
|
||||
#### TSC regular members
|
||||
|
||||
* [BethGriggs](https://github.com/BethGriggs) -
|
||||
**Beth Griggs** <<bethanyngriggs@gmail.com>> (she/her)
|
||||
* [bnoordhuis](https://github.com/bnoordhuis) -
|
||||
**Ben Noordhuis** <<info@bnoordhuis.nl>>
|
||||
* [ChALkeR](https://github.com/ChALkeR) -
|
||||
**Сковорода Никита Андреевич** <<chalkerx@gmail.com>> (he/him)
|
||||
* [cjihrig](https://github.com/cjihrig) -
|
||||
**Colin Ihrig** <<cjihrig@gmail.com>> (he/him)
|
||||
* [codebytere](https://github.com/codebytere) -
|
||||
**Shelley Vohr** <<shelley.vohr@gmail.com>> (she/her)
|
||||
* [danbev](https://github.com/danbev) -
|
||||
**Daniel Bevenius** <<daniel.bevenius@gmail.com>> (he/him)
|
||||
* [danielleadams](https://github.com/danielleadams) -
|
||||
**Danielle Adams** <<adamzdanielle@gmail.com>> (she/her)
|
||||
* [fhinkel](https://github.com/fhinkel) -
|
||||
**Franziska Hinkelmann** <<franziska.hinkelmann@gmail.com>> (she/her)
|
||||
* [gabrielschulhof](https://github.com/gabrielschulhof) -
|
||||
**Gabriel Schulhof** <<gabrielschulhof@gmail.com>>
|
||||
* [mscdex](https://github.com/mscdex) -
|
||||
**Brian White** <<mscdex@mscdex.net>>
|
||||
* [MylesBorins](https://github.com/MylesBorins) -
|
||||
**Myles Borins** <<myles.borins@gmail.com>> (he/him)
|
||||
* [rvagg](https://github.com/rvagg) -
|
||||
**Rod Vagg** <<r@va.gg>>
|
||||
* [TimothyGu](https://github.com/TimothyGu) -
|
||||
**Tiancheng "Timothy" Gu** <<timothygu99@gmail.com>> (he/him)
|
||||
* [Trott](https://github.com/Trott) -
|
||||
**Rich Trott** <<rtrott@gmail.com>> (he/him)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>TSC emeriti members</summary>
|
||||
|
||||
#### TSC emeriti members
|
||||
|
||||
* [addaleax](https://github.com/addaleax) -
|
||||
**Anna Henningsen** <<anna@addaleax.net>> (she/her)
|
||||
* [chrisdickinson](https://github.com/chrisdickinson) -
|
||||
**Chris Dickinson** <<christopher.s.dickinson@gmail.com>>
|
||||
* [evanlucas](https://github.com/evanlucas) -
|
||||
**Evan Lucas** <<evanlucas@me.com>> (he/him)
|
||||
* [Fishrock123](https://github.com/Fishrock123) -
|
||||
**Jeremiah Senkpiel** <<fishrock123@rocketmail.com>> (he/they)
|
||||
* [gibfahn](https://github.com/gibfahn) -
|
||||
**Gibson Fahnestock** <<gibfahn@gmail.com>> (he/him)
|
||||
* [indutny](https://github.com/indutny) -
|
||||
**Fedor Indutny** <<fedor@indutny.com>>
|
||||
* [isaacs](https://github.com/isaacs) -
|
||||
**Isaac Z. Schlueter** <<i@izs.me>>
|
||||
* [joshgav](https://github.com/joshgav) -
|
||||
**Josh Gavant** <<josh.gavant@outlook.com>>
|
||||
* [mmarchini](https://github.com/mmarchini) -
|
||||
**Mary Marchini** <<oss@mmarchini.me>> (she/her)
|
||||
* [nebrius](https://github.com/nebrius) -
|
||||
**Bryan Hughes** <<bryan@nebri.us>>
|
||||
* [ofrobots](https://github.com/ofrobots) -
|
||||
**Ali Ijaz Sheikh** <<ofrobots@google.com>> (he/him)
|
||||
* [orangemocha](https://github.com/orangemocha) -
|
||||
**Alexis Campailla** <<orangemocha@nodejs.org>>
|
||||
* [piscisaureus](https://github.com/piscisaureus) -
|
||||
**Bert Belder** <<bertbelder@gmail.com>>
|
||||
* [sam-github](https://github.com/sam-github) -
|
||||
**Sam Roberts** <<vieuxtech@gmail.com>>
|
||||
* [shigeki](https://github.com/shigeki) -
|
||||
**Shigeki Ohtsu** <<ohtsu@ohtsu.org>> (he/him)
|
||||
* [thefourtheye](https://github.com/thefourtheye) -
|
||||
**Sakthipriyan Vairamani** <<thechargingvolcano@gmail.com>> (he/him)
|
||||
* [trevnorris](https://github.com/trevnorris) -
|
||||
**Trevor Norris** <<trev.norris@gmail.com>>
|
||||
|
||||
</details>
|
||||
|
||||
<!-- node-core-utils and find-inactive-collaborators.mjs depend on the format
|
||||
of the collaborator list. If the format changes, those utilities need to be
|
||||
tested and updated. -->
|
||||
|
||||
### Collaborators
|
||||
|
||||
* [addaleax](https://github.com/addaleax) -
|
||||
**Anna Henningsen** <<anna@addaleax.net>> (she/her)
|
||||
* [aduh95](https://github.com/aduh95) -
|
||||
**Antoine du Hamel** <<duhamelantoine1995@gmail.com>> (he/him)
|
||||
* [anonrig](https://github.com/anonrig) -
|
||||
**Yagiz Nizipli** <<yagiz.nizipli@sentry.io>> (he/him)
|
||||
* [antsmartian](https://github.com/antsmartian) -
|
||||
**Anto Aravinth** <<anto.aravinth.cse@gmail.com>> (he/him)
|
||||
* [apapirovski](https://github.com/apapirovski) -
|
||||
**Anatoli Papirovski** <<apapirovski@mac.com>> (he/him)
|
||||
* [AshCripps](https://github.com/AshCripps) -
|
||||
**Ash Cripps** <<email@ashleycripps.co.uk>>
|
||||
* [atlowChemi](https://github.com/atlowChemi) -
|
||||
**Chemi Atlow** <<chemi@atlow.co.il>> (he/him)
|
||||
* [Ayase-252](https://github.com/Ayase-252) -
|
||||
**Qingyu Deng** <<i@ayase-lab.com>>
|
||||
* [bengl](https://github.com/bengl) -
|
||||
**Bryan English** <<bryan@bryanenglish.com>> (he/him)
|
||||
* [benjamingr](https://github.com/benjamingr) -
|
||||
**Benjamin Gruenbaum** <<benjamingr@gmail.com>>
|
||||
* [BethGriggs](https://github.com/BethGriggs) -
|
||||
**Beth Griggs** <<bethanyngriggs@gmail.com>> (she/her)
|
||||
* [bmeck](https://github.com/bmeck) -
|
||||
**Bradley Farias** <<bradley.meck@gmail.com>>
|
||||
* [bnb](https://github.com/bnb) -
|
||||
**Tierney Cyren** <<hello@bnb.im>> (they/them)
|
||||
* [bnoordhuis](https://github.com/bnoordhuis) -
|
||||
**Ben Noordhuis** <<info@bnoordhuis.nl>>
|
||||
* [BridgeAR](https://github.com/BridgeAR) -
|
||||
**Ruben Bridgewater** <<ruben@bridgewater.de>> (he/him)
|
||||
* [cclauss](https://github.com/cclauss) -
|
||||
**Christian Clauss** <<cclauss@me.com>> (he/him)
|
||||
* [ChALkeR](https://github.com/ChALkeR) -
|
||||
**Сковорода Никита Андреевич** <<chalkerx@gmail.com>> (he/him)
|
||||
* [cjihrig](https://github.com/cjihrig) -
|
||||
**Colin Ihrig** <<cjihrig@gmail.com>> (he/him)
|
||||
* [codebytere](https://github.com/codebytere) -
|
||||
**Shelley Vohr** <<shelley.vohr@gmail.com>> (she/her)
|
||||
* [cola119](https://github.com/cola119) -
|
||||
**Kohei Ueno** <<kohei.ueno119@gmail.com>> (he/him)
|
||||
* [daeyeon](https://github.com/daeyeon) -
|
||||
**Daeyeon Jeong** <<daeyeon.dev@gmail.com>> (he/him)
|
||||
* [danbev](https://github.com/danbev) -
|
||||
**Daniel Bevenius** <<daniel.bevenius@gmail.com>> (he/him)
|
||||
* [danielleadams](https://github.com/danielleadams) -
|
||||
**Danielle Adams** <<adamzdanielle@gmail.com>> (she/her)
|
||||
* [debadree25](https://github.com/debadree25) -
|
||||
**Debadree Chatterjee** <<debadree333@gmail.com>> (he/him)
|
||||
* [deokjinkim](https://github.com/deokjinkim) -
|
||||
**Deokjin Kim** <<deokjin81.kim@gmail.com>> (he/him)
|
||||
* [devnexen](https://github.com/devnexen) -
|
||||
**David Carlier** <<devnexen@gmail.com>>
|
||||
* [devsnek](https://github.com/devsnek) -
|
||||
**Gus Caplan** <<me@gus.host>> (they/them)
|
||||
* [edsadr](https://github.com/edsadr) -
|
||||
**Adrian Estrada** <<edsadr@gmail.com>> (he/him)
|
||||
* [erickwendel](https://github.com/erickwendel) -
|
||||
**Erick Wendel** <<erick.workspace@gmail.com>> (he/him)
|
||||
* [Ethan-Arrowood](https://github.com/Ethan-Arrowood) -
|
||||
**Ethan Arrowood** <<ethan@arrowood.dev>> (he/him)
|
||||
* [fhinkel](https://github.com/fhinkel) -
|
||||
**Franziska Hinkelmann** <<franziska.hinkelmann@gmail.com>> (she/her)
|
||||
* [F3n67u](https://github.com/F3n67u) -
|
||||
**Feng Yu** <<F3n67u@outlook.com>> (he/him)
|
||||
* [Flarna](https://github.com/Flarna) -
|
||||
**Gerhard Stöbich** <<deb2001-github@yahoo.de>> (he/they)
|
||||
* [gabrielschulhof](https://github.com/gabrielschulhof) -
|
||||
**Gabriel Schulhof** <<gabrielschulhof@gmail.com>>
|
||||
* [gengjiawen](https://github.com/gengjiawen) -
|
||||
**Jiawen Geng** <<technicalcute@gmail.com>>
|
||||
* [GeoffreyBooth](https://github.com/geoffreybooth) -
|
||||
**Geoffrey Booth** <<webadmin@geoffreybooth.com>> (he/him)
|
||||
* [gireeshpunathil](https://github.com/gireeshpunathil) -
|
||||
**Gireesh Punathil** <<gpunathi@in.ibm.com>> (he/him)
|
||||
* [guybedford](https://github.com/guybedford) -
|
||||
**Guy Bedford** <<guybedford@gmail.com>> (he/him)
|
||||
* [H4ad](https://github.com/H4ad) -
|
||||
**Vinícius Lourenço Claro Cardoso** <<contact@viniciusl.com.br>> (he/him)
|
||||
* [HarshithaKP](https://github.com/HarshithaKP) -
|
||||
**Harshitha K P** <<harshitha014@gmail.com>> (she/her)
|
||||
* [himself65](https://github.com/himself65) -
|
||||
**Zeyu "Alex" Yang** <<himself65@outlook.com>> (he/him)
|
||||
* [iansu](https://github.com/iansu) -
|
||||
**Ian Sutherland** <<ian@iansutherland.ca>>
|
||||
* [JacksonTian](https://github.com/JacksonTian) -
|
||||
**Jackson Tian** <<shyvo1987@gmail.com>>
|
||||
* [JakobJingleheimer](https://github.com/JakobJingleheimer) -
|
||||
**Jacob Smith** <<jacob@frende.me>> (he/him)
|
||||
* [jasnell](https://github.com/jasnell) -
|
||||
**James M Snell** <<jasnell@gmail.com>> (he/him)
|
||||
* [jkrems](https://github.com/jkrems) -
|
||||
**Jan Krems** <<jan.krems@gmail.com>> (he/him)
|
||||
* [joesepi](https://github.com/joesepi) -
|
||||
**Joe Sepi** <<sepi@joesepi.com>> (he/him)
|
||||
* [joyeecheung](https://github.com/joyeecheung) -
|
||||
**Joyee Cheung** <<joyeec9h3@gmail.com>> (she/her)
|
||||
* [juanarbol](https://github.com/juanarbol) -
|
||||
**Juan José Arboleda** <<soyjuanarbol@gmail.com>> (he/him)
|
||||
* [JungMinu](https://github.com/JungMinu) -
|
||||
**Minwoo Jung** <<nodecorelab@gmail.com>> (he/him)
|
||||
* [KhafraDev](https://github.com/KhafraDev) -
|
||||
**Matthew Aitken** <<maitken033380023@gmail.com>> (he/him)
|
||||
* [kuriyosh](https://github.com/kuriyosh) -
|
||||
**Yoshiki Kurihara** <<yosyos0306@gmail.com>> (he/him)
|
||||
* [kvakil](https://github.com/kvakil) -
|
||||
**Keyhan Vakil** <<kvakil@sylph.kvakil.me>>
|
||||
* [legendecas](https://github.com/legendecas) -
|
||||
**Chengzhong Wu** <<legendecas@gmail.com>> (he/him)
|
||||
* [linkgoron](https://github.com/linkgoron) -
|
||||
**Nitzan Uziely** <<linkgoron@gmail.com>>
|
||||
* [LiviaMedeiros](https://github.com/LiviaMedeiros) -
|
||||
**LiviaMedeiros** <<livia@cirno.name>>
|
||||
* [lpinca](https://github.com/lpinca) -
|
||||
**Luigi Pinca** <<luigipinca@gmail.com>> (he/him)
|
||||
* [lukekarrys](https://github.com/lukekarrys) -
|
||||
**Luke Karrys** <<luke@lukekarrys.com>> (he/him)
|
||||
* [Lxxyx](https://github.com/Lxxyx) -
|
||||
**Zijian Liu** <<lxxyxzj@gmail.com>> (he/him)
|
||||
* [marco-ippolito](https://github.com/marco-ippolito) -
|
||||
**Marco Ippolito** <<marcoippolito54@gmail.com>> (he/him)
|
||||
* [marsonya](https://github.com/marsonya) -
|
||||
**Akhil Marsonya** <<akhil.marsonya27@gmail.com>> (he/him)
|
||||
* [mcollina](https://github.com/mcollina) -
|
||||
**Matteo Collina** <<matteo.collina@gmail.com>> (he/him)
|
||||
* [meixg](https://github.com/meixg) -
|
||||
**Xuguang Mei** <<meixuguang@gmail.com>> (he/him)
|
||||
* [Mesteery](https://github.com/Mesteery) -
|
||||
**Mestery** <<mestery@protonmail.com>> (he/him)
|
||||
* [mhdawson](https://github.com/mhdawson) -
|
||||
**Michael Dawson** <<midawson@redhat.com>> (he/him)
|
||||
* [miladfarca](https://github.com/miladfarca) -
|
||||
**Milad Fa** <<mfarazma@redhat.com>> (he/him)
|
||||
* [mildsunrise](https://github.com/mildsunrise) -
|
||||
**Alba Mendez** <<me@alba.sh>> (she/her)
|
||||
* [MoLow](https://github.com/MoLow) -
|
||||
**Moshe Atlow** <<moshe@atlow.co.il>> (he/him)
|
||||
* [MrJithil](https://github.com/MrJithil) -
|
||||
**Jithil P Ponnan** <<jithil@outlook.com>> (he/him)
|
||||
* [mscdex](https://github.com/mscdex) -
|
||||
**Brian White** <<mscdex@mscdex.net>>
|
||||
* [MylesBorins](https://github.com/MylesBorins) -
|
||||
**Myles Borins** <<myles.borins@gmail.com>> (he/him)
|
||||
* [ovflowd](https://github.com/ovflowd) -
|
||||
**Claudio Wunder** <<cwunder@gnome.org>> (he/they)
|
||||
* [oyyd](https://github.com/oyyd) -
|
||||
**Ouyang Yadong** <<oyydoibh@gmail.com>> (he/him)
|
||||
* [panva](https://github.com/panva) -
|
||||
**Filip Skokan** <<panva.ip@gmail.com>> (he/him)
|
||||
* [Qard](https://github.com/Qard) -
|
||||
**Stephen Belanger** <<admin@stephenbelanger.com>> (he/him)
|
||||
* [RafaelGSS](https://github.com/RafaelGSS) -
|
||||
**Rafael Gonzaga** <<rafael.nunu@hotmail.com>> (he/him)
|
||||
* [RaisinTen](https://github.com/RaisinTen) -
|
||||
**Darshan Sen** <<raisinten@gmail.com>> (he/him)
|
||||
* [rluvaton](https://github.com/rluvaton) -
|
||||
**Raz Luvaton** <<rluvaton@gmail.com>> (he/him)
|
||||
* [richardlau](https://github.com/richardlau) -
|
||||
**Richard Lau** <<rlau@redhat.com>>
|
||||
* [rickyes](https://github.com/rickyes) -
|
||||
**Ricky Zhou** <<0x19951125@gmail.com>> (he/him)
|
||||
* [ronag](https://github.com/ronag) -
|
||||
**Robert Nagy** <<ronagy@icloud.com>>
|
||||
* [ruyadorno](https://github.com/ruyadorno) -
|
||||
**Ruy Adorno** <<ruyadorno@google.com>> (he/him)
|
||||
* [rvagg](https://github.com/rvagg) -
|
||||
**Rod Vagg** <<rod@vagg.org>>
|
||||
* [ryzokuken](https://github.com/ryzokuken) -
|
||||
**Ujjwal Sharma** <<ryzokuken@disroot.org>> (he/him)
|
||||
* [santigimeno](https://github.com/santigimeno) -
|
||||
**Santiago Gimeno** <<santiago.gimeno@gmail.com>>
|
||||
* [shisama](https://github.com/shisama) -
|
||||
**Masashi Hirano** <<shisama07@gmail.com>> (he/him)
|
||||
* [ShogunPanda](https://github.com/ShogunPanda) -
|
||||
**Paolo Insogna** <<paolo@cowtech.it>> (he/him)
|
||||
* [srl295](https://github.com/srl295) -
|
||||
**Steven R Loomis** <<srl295@gmail.com>>
|
||||
* [sxa](https://github.com/sxa) -
|
||||
**Stewart X Addison** <<sxa@redhat.com>> (he/him)
|
||||
* [targos](https://github.com/targos) -
|
||||
**Michaël Zasso** <<targos@protonmail.com>> (he/him)
|
||||
* [theanarkh](https://github.com/theanarkh) -
|
||||
**theanarkh** <<theratliter@gmail.com>> (he/him)
|
||||
* [TimothyGu](https://github.com/TimothyGu) -
|
||||
**Tiancheng "Timothy" Gu** <<timothygu99@gmail.com>> (he/him)
|
||||
* [tniessen](https://github.com/tniessen) -
|
||||
**Tobias Nießen** <<tniessen@tnie.de>> (he/him)
|
||||
* [trivikr](https://github.com/trivikr) -
|
||||
**Trivikram Kamat** <<trivikr.dev@gmail.com>>
|
||||
* [Trott](https://github.com/Trott) -
|
||||
**Rich Trott** <<rtrott@gmail.com>> (he/him)
|
||||
* [vdeturckheim](https://github.com/vdeturckheim) -
|
||||
**Vladimir de Turckheim** <<vlad2t@hotmail.com>> (he/him)
|
||||
* [vmoroz](https://github.com/vmoroz) -
|
||||
**Vladimir Morozov** <<vmorozov@microsoft.com>> (he/him)
|
||||
* [VoltrexKeyva](https://github.com/VoltrexKeyva) -
|
||||
**Mohammed Keyvanzadeh** <<mohammadkeyvanzade94@gmail.com>> (he/him)
|
||||
* [watilde](https://github.com/watilde) -
|
||||
**Daijiro Wachi** <<daijiro.wachi@gmail.com>> (he/him)
|
||||
* [XadillaX](https://github.com/XadillaX) -
|
||||
**Khaidi Chu** <<i@2333.moe>> (he/him)
|
||||
* [yashLadha](https://github.com/yashLadha) -
|
||||
**Yash Ladha** <<yash@yashladha.in>> (he/him)
|
||||
* [ZYSzys](https://github.com/ZYSzys) -
|
||||
**Yongsheng Zhang** <<zyszys98@gmail.com>> (he/him)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Emeriti</summary>
|
||||
|
||||
<!-- find-inactive-collaborators.mjs depends on the format of the emeriti list.
|
||||
If the format changes, those utilities need to be tested and updated. -->
|
||||
|
||||
### Collaborator emeriti
|
||||
|
||||
* [ak239](https://github.com/ak239) -
|
||||
**Aleksei Koziatinskii** <<ak239spb@gmail.com>>
|
||||
* [andrasq](https://github.com/andrasq) -
|
||||
**Andras** <<andras@kinvey.com>>
|
||||
* [AnnaMag](https://github.com/AnnaMag) -
|
||||
**Anna M. Kedzierska** <<anna.m.kedzierska@gmail.com>>
|
||||
* [AndreasMadsen](https://github.com/AndreasMadsen) -
|
||||
**Andreas Madsen** <<amwebdk@gmail.com>> (he/him)
|
||||
* [aqrln](https://github.com/aqrln) -
|
||||
**Alexey Orlenko** <<eaglexrlnk@gmail.com>> (he/him)
|
||||
* [bcoe](https://github.com/bcoe) -
|
||||
**Ben Coe** <<bencoe@gmail.com>> (he/him)
|
||||
* [bmeurer](https://github.com/bmeurer) -
|
||||
**Benedikt Meurer** <<benedikt.meurer@gmail.com>>
|
||||
* [boneskull](https://github.com/boneskull) -
|
||||
**Christopher Hiller** <<boneskull@boneskull.com>> (he/him)
|
||||
* [brendanashworth](https://github.com/brendanashworth) -
|
||||
**Brendan Ashworth** <<brendan.ashworth@me.com>>
|
||||
* [bzoz](https://github.com/bzoz) -
|
||||
**Bartosz Sosnowski** <<bartosz@janeasystems.com>>
|
||||
* [calvinmetcalf](https://github.com/calvinmetcalf) -
|
||||
**Calvin Metcalf** <<calvin.metcalf@gmail.com>>
|
||||
* [chrisdickinson](https://github.com/chrisdickinson) -
|
||||
**Chris Dickinson** <<christopher.s.dickinson@gmail.com>>
|
||||
* [claudiorodriguez](https://github.com/claudiorodriguez) -
|
||||
**Claudio Rodriguez** <<cjrodr@yahoo.com>>
|
||||
* [DavidCai1993](https://github.com/DavidCai1993) -
|
||||
**David Cai** <<davidcai1993@yahoo.com>> (he/him)
|
||||
* [davisjam](https://github.com/davisjam) -
|
||||
**Jamie Davis** <<davisjam@vt.edu>> (he/him)
|
||||
* [digitalinfinity](https://github.com/digitalinfinity) -
|
||||
**Hitesh Kanwathirtha** <<digitalinfinity@gmail.com>> (he/him)
|
||||
* [dmabupt](https://github.com/dmabupt) -
|
||||
**Xu Meng** <<dmabupt@gmail.com>> (he/him)
|
||||
* [dnlup](https://github.com/dnlup)
|
||||
**dnlup** <<dnlup.dev@gmail.com>>
|
||||
* [eljefedelrodeodeljefe](https://github.com/eljefedelrodeodeljefe) -
|
||||
**Robert Jefe Lindstaedt** <<robert.lindstaedt@gmail.com>>
|
||||
* [estliberitas](https://github.com/estliberitas) -
|
||||
**Alexander Makarenko** <<estliberitas@gmail.com>>
|
||||
* [eugeneo](https://github.com/eugeneo) -
|
||||
**Eugene Ostroukhov** <<eostroukhov@google.com>>
|
||||
* [evanlucas](https://github.com/evanlucas) -
|
||||
**Evan Lucas** <<evanlucas@me.com>> (he/him)
|
||||
* [firedfox](https://github.com/firedfox) -
|
||||
**Daniel Wang** <<wangyang0123@gmail.com>>
|
||||
* [Fishrock123](https://github.com/Fishrock123) -
|
||||
**Jeremiah Senkpiel** <<fishrock123@rocketmail.com>> (he/they)
|
||||
* [gdams](https://github.com/gdams) -
|
||||
**George Adams** <<gadams@microsoft.com>> (he/him)
|
||||
* [geek](https://github.com/geek) -
|
||||
**Wyatt Preul** <<wpreul@gmail.com>>
|
||||
* [gibfahn](https://github.com/gibfahn) -
|
||||
**Gibson Fahnestock** <<gibfahn@gmail.com>> (he/him)
|
||||
* [glentiki](https://github.com/glentiki) -
|
||||
**Glen Keane** <<glenkeane.94@gmail.com>> (he/him)
|
||||
* [hashseed](https://github.com/hashseed) -
|
||||
**Yang Guo** <<yangguo@chromium.org>> (he/him)
|
||||
* [hiroppy](https://github.com/hiroppy) -
|
||||
**Yuta Hiroto** <<hello@hiroppy.me>> (he/him)
|
||||
* [iarna](https://github.com/iarna) -
|
||||
**Rebecca Turner** <<me@re-becca.org>>
|
||||
* [imran-iq](https://github.com/imran-iq) -
|
||||
**Imran Iqbal** <<imran@imraniqbal.org>>
|
||||
* [imyller](https://github.com/imyller) -
|
||||
**Ilkka Myller** <<ilkka.myller@nodefield.com>>
|
||||
* [indutny](https://github.com/indutny) -
|
||||
**Fedor Indutny** <<fedor@indutny.com>>
|
||||
* [isaacs](https://github.com/isaacs) -
|
||||
**Isaac Z. Schlueter** <<i@izs.me>>
|
||||
* [italoacasas](https://github.com/italoacasas) -
|
||||
**Italo A. Casas** <<me@italoacasas.com>> (he/him)
|
||||
* [jasongin](https://github.com/jasongin) -
|
||||
**Jason Ginchereau** <<jasongin@microsoft.com>>
|
||||
* [jbergstroem](https://github.com/jbergstroem) -
|
||||
**Johan Bergström** <<bugs@bergstroem.nu>>
|
||||
* [jdalton](https://github.com/jdalton) -
|
||||
**John-David Dalton** <<john.david.dalton@gmail.com>>
|
||||
* [jhamhader](https://github.com/jhamhader) -
|
||||
**Yuval Brik** <<yuval@brik.org.il>>
|
||||
* [joaocgreis](https://github.com/joaocgreis) -
|
||||
**João Reis** <<reis@janeasystems.com>>
|
||||
* [joshgav](https://github.com/joshgav) -
|
||||
**Josh Gavant** <<josh.gavant@outlook.com>>
|
||||
* [julianduque](https://github.com/julianduque) -
|
||||
**Julian Duque** <<julianduquej@gmail.com>> (he/him)
|
||||
* [kfarnung](https://github.com/kfarnung) -
|
||||
**Kyle Farnung** <<kfarnung@microsoft.com>> (he/him)
|
||||
* [kunalspathak](https://github.com/kunalspathak) -
|
||||
**Kunal Pathak** <<kunal.pathak@microsoft.com>>
|
||||
* [lance](https://github.com/lance) -
|
||||
**Lance Ball** <<lball@redhat.com>> (he/him)
|
||||
* [Leko](https://github.com/Leko) -
|
||||
**Shingo Inoue** <<leko.noor@gmail.com>> (he/him)
|
||||
* [lucamaraschi](https://github.com/lucamaraschi) -
|
||||
**Luca Maraschi** <<luca.maraschi@gmail.com>> (he/him)
|
||||
* [lundibundi](https://github.com/lundibundi) -
|
||||
**Denys Otrishko** <<shishugi@gmail.com>> (he/him)
|
||||
* [lxe](https://github.com/lxe) -
|
||||
**Aleksey Smolenchuk** <<lxe@lxe.co>>
|
||||
* [maclover7](https://github.com/maclover7) -
|
||||
**Jon Moss** <<me@jonathanmoss.me>> (he/him)
|
||||
* [mafintosh](https://github.com/mafintosh) -
|
||||
**Mathias Buus** <<mathiasbuus@gmail.com>> (he/him)
|
||||
* [matthewloring](https://github.com/matthewloring) -
|
||||
**Matthew Loring** <<mattloring@google.com>>
|
||||
* [micnic](https://github.com/micnic) -
|
||||
**Nicu Micleușanu** <<micnic90@gmail.com>> (he/him)
|
||||
* [mikeal](https://github.com/mikeal) -
|
||||
**Mikeal Rogers** <<mikeal.rogers@gmail.com>>
|
||||
* [misterdjules](https://github.com/misterdjules) -
|
||||
**Julien Gilli** <<jgilli@netflix.com>>
|
||||
* [mmarchini](https://github.com/mmarchini) -
|
||||
**Mary Marchini** <<oss@mmarchini.me>> (she/her)
|
||||
* [monsanto](https://github.com/monsanto) -
|
||||
**Christopher Monsanto** <<chris@monsan.to>>
|
||||
* [MoonBall](https://github.com/MoonBall) -
|
||||
**Chen Gang** <<gangc.cxy@foxmail.com>>
|
||||
* [not-an-aardvark](https://github.com/not-an-aardvark) -
|
||||
**Teddy Katz** <<teddy.katz@gmail.com>> (he/him)
|
||||
* [ofrobots](https://github.com/ofrobots) -
|
||||
**Ali Ijaz Sheikh** <<ofrobots@google.com>> (he/him)
|
||||
* [Olegas](https://github.com/Olegas) -
|
||||
**Oleg Elifantiev** <<oleg@elifantiev.ru>>
|
||||
* [orangemocha](https://github.com/orangemocha) -
|
||||
**Alexis Campailla** <<orangemocha@nodejs.org>>
|
||||
* [othiym23](https://github.com/othiym23) -
|
||||
**Forrest L Norvell** <<ogd@aoaioxxysz.net>> (they/them/themself)
|
||||
* [petkaantonov](https://github.com/petkaantonov) -
|
||||
**Petka Antonov** <<petka_antonov@hotmail.com>>
|
||||
* [phillipj](https://github.com/phillipj) -
|
||||
**Phillip Johnsen** <<johphi@gmail.com>>
|
||||
* [piscisaureus](https://github.com/piscisaureus) -
|
||||
**Bert Belder** <<bertbelder@gmail.com>>
|
||||
* [pmq20](https://github.com/pmq20) -
|
||||
**Minqi Pan** <<pmq2001@gmail.com>>
|
||||
* [PoojaDurgad](https://github.com/PoojaDurgad) -
|
||||
**Pooja D P** <<Pooja.D.P@ibm.com>> (she/her)
|
||||
* [princejwesley](https://github.com/princejwesley) -
|
||||
**Prince John Wesley** <<princejohnwesley@gmail.com>>
|
||||
* [psmarshall](https://github.com/psmarshall) -
|
||||
**Peter Marshall** <<petermarshall@chromium.org>> (he/him)
|
||||
* [puzpuzpuz](https://github.com/puzpuzpuz) -
|
||||
**Andrey Pechkurov** <<apechkurov@gmail.com>> (he/him)
|
||||
* [refack](https://github.com/refack) -
|
||||
**Refael Ackermann (רפאל פלחי)** <<refack@gmail.com>> (he/him/הוא/אתה)
|
||||
* [rexagod](https://github.com/rexagod) -
|
||||
**Pranshu Srivastava** <<rexagod@gmail.com>> (he/him)
|
||||
* [rlidwka](https://github.com/rlidwka) -
|
||||
**Alex Kocharin** <<alex@kocharin.ru>>
|
||||
* [rmg](https://github.com/rmg) -
|
||||
**Ryan Graham** <<r.m.graham@gmail.com>>
|
||||
* [robertkowalski](https://github.com/robertkowalski) -
|
||||
**Robert Kowalski** <<rok@kowalski.gd>>
|
||||
* [romankl](https://github.com/romankl) -
|
||||
**Roman Klauke** <<romaaan.git@gmail.com>>
|
||||
* [ronkorving](https://github.com/ronkorving) -
|
||||
**Ron Korving** <<ron@ronkorving.nl>>
|
||||
* [RReverser](https://github.com/RReverser) -
|
||||
**Ingvar Stepanyan** <<me@rreverser.com>>
|
||||
* [rubys](https://github.com/rubys) -
|
||||
**Sam Ruby** <<rubys@intertwingly.net>>
|
||||
* [saghul](https://github.com/saghul) -
|
||||
**Saúl Ibarra Corretgé** <<s@saghul.net>>
|
||||
* [sam-github](https://github.com/sam-github) -
|
||||
**Sam Roberts** <<vieuxtech@gmail.com>>
|
||||
* [sebdeckers](https://github.com/sebdeckers) -
|
||||
**Sebastiaan Deckers** <<sebdeckers83@gmail.com>>
|
||||
* [seishun](https://github.com/seishun) -
|
||||
**Nikolai Vavilov** <<vvnicholas@gmail.com>>
|
||||
* [shigeki](https://github.com/shigeki) -
|
||||
**Shigeki Ohtsu** <<ohtsu@ohtsu.org>> (he/him)
|
||||
* [silverwind](https://github.com/silverwind) -
|
||||
**Roman Reiss** <<me@silverwind.io>>
|
||||
* [starkwang](https://github.com/starkwang) -
|
||||
**Weijia Wang** <<starkwang@126.com>>
|
||||
* [stefanmb](https://github.com/stefanmb) -
|
||||
**Stefan Budeanu** <<stefan@budeanu.com>>
|
||||
* [tellnes](https://github.com/tellnes) -
|
||||
**Christian Tellnes** <<christian@tellnes.no>>
|
||||
* [thefourtheye](https://github.com/thefourtheye) -
|
||||
**Sakthipriyan Vairamani** <<thechargingvolcano@gmail.com>> (he/him)
|
||||
* [thlorenz](https://github.com/thlorenz) -
|
||||
**Thorsten Lorenz** <<thlorenz@gmx.de>>
|
||||
* [trevnorris](https://github.com/trevnorris) -
|
||||
**Trevor Norris** <<trev.norris@gmail.com>>
|
||||
* [tunniclm](https://github.com/tunniclm) -
|
||||
**Mike Tunnicliffe** <<m.j.tunnicliffe@gmail.com>>
|
||||
* [vkurchatkin](https://github.com/vkurchatkin) -
|
||||
**Vladimir Kurchatkin** <<vladimir.kurchatkin@gmail.com>>
|
||||
* [vsemozhetbyt](https://github.com/vsemozhetbyt) -
|
||||
**Vse Mozhet Byt** <<vsemozhetbyt@gmail.com>> (he/him)
|
||||
* [watson](https://github.com/watson) -
|
||||
**Thomas Watson** <<w@tson.dk>>
|
||||
* [whitlockjc](https://github.com/whitlockjc) -
|
||||
**Jeremy Whitlock** <<jwhitlock@apache.org>>
|
||||
* [yhwang](https://github.com/yhwang) -
|
||||
**Yihong Wang** <<yh.wang@ibm.com>>
|
||||
* [yorkie](https://github.com/yorkie) -
|
||||
**Yorkie Liu** <<yorkiefixer@gmail.com>>
|
||||
* [yosuke-furukawa](https://github.com/yosuke-furukawa) -
|
||||
**Yosuke Furukawa** <<yosuke.furukawa@gmail.com>>
|
||||
|
||||
</details>
|
||||
|
||||
<!--lint enable prohibited-strings-->
|
||||
|
||||
Collaborators follow the [Collaborator Guide](./doc/contributing/collaborator-guide.md) in
|
||||
maintaining the Node.js project.
|
||||
|
||||
### Triagers
|
||||
|
||||
* [atlowChemi](https://github.com/atlowChemi) -
|
||||
**Chemi Atlow** <<chemi@atlow.co.il>> (he/him)
|
||||
* [Ayase-252](https://github.com/Ayase-252) -
|
||||
**Qingyu Deng** <<i@ayase-lab.com>>
|
||||
* [bmuenzenmeyer](https://github.com/bmuenzenmeyer) -
|
||||
**Brian Muenzenmeyer** <<brian.muenzenmeyer@gmail.com>> (he/him)
|
||||
* [CanadaHonk](https://github.com/CanadaHonk) -
|
||||
**Oliver Medhurst** <<honk@goose.icu>> (they/them)
|
||||
* [daeyeon](https://github.com/daeyeon) -
|
||||
**Daeyeon Jeong** <<daeyeon.dev@gmail.com>> (he/him)
|
||||
* [F3n67u](https://github.com/F3n67u) -
|
||||
**Feng Yu** <<F3n67u@outlook.com>> (he/him)
|
||||
* [himadriganguly](https://github.com/himadriganguly) -
|
||||
**Himadri Ganguly** <<himadri.tech@gmail.com>> (he/him)
|
||||
* [iam-frankqiu](https://github.com/iam-frankqiu) -
|
||||
**Frank Qiu** <<iam.frankqiu@gmail.com>> (he/him)
|
||||
* [marsonya](https://github.com/marsonya) -
|
||||
**Akhil Marsonya** <<akhil.marsonya27@gmail.com>> (he/him)
|
||||
* [meixg](https://github.com/meixg) -
|
||||
**Xuguang Mei** <<meixuguang@gmail.com>> (he/him)
|
||||
* [mertcanaltin](https://github.com/mertcanaltin) -
|
||||
**Mert Can Altin** <<mertgold60@gmail.com>>
|
||||
* [Mesteery](https://github.com/Mesteery) -
|
||||
**Mestery** <<mestery@protonmail.com>> (he/him)
|
||||
* [preveen-stack](https://github.com/preveen-stack) -
|
||||
**Preveen Padmanabhan** <<wide4head@gmail.com>> (he/him)
|
||||
* [PoojaDurgad](https://github.com/PoojaDurgad) -
|
||||
**Pooja Durgad** <<Pooja.D.P@ibm.com>>
|
||||
* [RaisinTen](https://github.com/RaisinTen) -
|
||||
**Darshan Sen** <<raisinten@gmail.com>>
|
||||
* [VoltrexKeyva](https://github.com/VoltrexKeyva) -
|
||||
**Mohammed Keyvanzadeh** <<mohammadkeyvanzade94@gmail.com>> (he/him)
|
||||
|
||||
Triagers follow the [Triage Guide](./doc/contributing/issues.md#triaging-a-bug-report) when
|
||||
responding to new issues.
|
||||
|
||||
### Release keys
|
||||
|
||||
Primary GPG keys for Node.js Releasers (some Releasers sign with subkeys):
|
||||
|
||||
* **Beth Griggs** <<bethanyngriggs@gmail.com>>
|
||||
`4ED778F539E3634C779C87C6D7062848A1AB005C`
|
||||
* **Bryan English** <<bryan@bryanenglish.com>>
|
||||
`141F07595B7B3FFE74309A937405533BE57C7D57`
|
||||
* **Danielle Adams** <<adamzdanielle@gmail.com>>
|
||||
`74F12602B6F1C4E913FAA37AD3A89613643B6201`
|
||||
* **Juan José Arboleda** <<soyjuanarbol@gmail.com>>
|
||||
`DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7`
|
||||
* **Michaël Zasso** <<targos@protonmail.com>>
|
||||
`8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600`
|
||||
* **Myles Borins** <<myles.borins@gmail.com>>
|
||||
`C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8`
|
||||
* **RafaelGSS** <<rafael.nunu@hotmail.com>>
|
||||
`890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4`
|
||||
* **Richard Lau** <<rlau@redhat.com>>
|
||||
`C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C`
|
||||
* **Ruy Adorno** <<ruyadorno@hotmail.com>>
|
||||
`108F52B48DB57BB0CC439B2997B01419BD92F80A`
|
||||
* **Ulises Gascón** <<ulisesgascongonzalez@gmail.com>>
|
||||
`A363A499291CBBC940DD62E41F10027AF002F8B0`
|
||||
|
||||
To import the full set of trusted release keys (including subkeys possibly used
|
||||
to sign releases):
|
||||
|
||||
```bash
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 4ED778F539E3634C779C87C6D7062848A1AB005C
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 141F07595B7B3FFE74309A937405533BE57C7D57
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 74F12602B6F1C4E913FAA37AD3A89613643B6201
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys 108F52B48DB57BB0CC439B2997B01419BD92F80A
|
||||
gpg --keyserver hkps://keys.openpgp.org --recv-keys A363A499291CBBC940DD62E41F10027AF002F8B0
|
||||
```
|
||||
|
||||
See [Verifying binaries](#verifying-binaries) for how to use these keys to
|
||||
verify a downloaded file.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Other keys used to sign some previous releases</summary>
|
||||
|
||||
* **Chris Dickinson** <<christopher.s.dickinson@gmail.com>>
|
||||
`9554F04D7259F04124DE6B476D5A82AC7E37093B`
|
||||
* **Colin Ihrig** <<cjihrig@gmail.com>>
|
||||
`94AE36675C464D64BAFA68DD7434390BDBE9B9C5`
|
||||
* **Danielle Adams** <<adamzdanielle@gmail.com>>
|
||||
`1C050899334244A8AF75E53792EF661D867B9DFA`
|
||||
* **Evan Lucas** <<evanlucas@me.com>>
|
||||
`B9AE9905FFD7803F25714661B63B535A4C206CA9`
|
||||
* **Gibson Fahnestock** <<gibfahn@gmail.com>>
|
||||
`77984A986EBC2AA786BC0F66B01FBB92821C587A`
|
||||
* **Isaac Z. Schlueter** <<i@izs.me>>
|
||||
`93C7E9E91B49E432C2F75674B0A78B0A6C481CF6`
|
||||
* **Italo A. Casas** <<me@italoacasas.com>>
|
||||
`56730D5401028683275BD23C23EFEFE93C4CFFFE`
|
||||
* **James M Snell** <<jasnell@keybase.io>>
|
||||
`71DCFD284A79C3B38668286BC97EC7A07EDE3FC1`
|
||||
* **Jeremiah Senkpiel** <<fishrock@keybase.io>>
|
||||
`FD3A5288F042B6850C66B31F09FE44734EB7990E`
|
||||
* **Juan José Arboleda** <<soyjuanarbol@gmail.com>>
|
||||
`61FC681DFB92A079F1685E77973F295594EC4689`
|
||||
* **Julien Gilli** <<jgilli@fastmail.fm>>
|
||||
`114F43EE0176B71C7BC219DD50A3051F888C628D`
|
||||
* **Rod Vagg** <<rod@vagg.org>>
|
||||
`DD8F2338BAE7501E3DD5AC78C273792F7D83545D`
|
||||
* **Ruben Bridgewater** <<ruben@bridgewater.de>>
|
||||
`A48C2BEE680E841632CD4E44F07496B3EB3C1762`
|
||||
* **Shelley Vohr** <<shelley.vohr@gmail.com>>
|
||||
`B9E2F5981AA6E0CD28160D9FF13993A75599653C`
|
||||
* **Timothy J Fontaine** <<tjfontaine@gmail.com>>
|
||||
`7937DFD2AB06298B2293C3187D33FF9D0246406D`
|
||||
|
||||
</details>
|
||||
|
||||
### Security release stewards
|
||||
|
||||
When possible, the commitment to take slots in the
|
||||
security release steward rotation is made by companies in order
|
||||
to ensure individuals who act as security stewards have the
|
||||
support and recognition from their employer to be able to
|
||||
prioritize security releases. Security release stewards manage security
|
||||
releases on a rotation basis as outlined in the
|
||||
[security release process](./doc/contributing/security-release-process.md).
|
||||
|
||||
* Datadog
|
||||
* [bengl](https://github.com/bengl) -
|
||||
**Bryan English** <<bryan@bryanenglish.com>> (he/him)
|
||||
* NearForm
|
||||
* [RafaelGSS](https://github.com/RafaelGSS) -
|
||||
**Rafael Gonzaga** <<rafael.nunu@hotmail.com>> (he/him)
|
||||
* NodeSource
|
||||
* [juanarbol](https://github.com/juanarbol) -
|
||||
**Juan José Arboleda** <<soyjuanarbol@gmail.com>> (he/him)
|
||||
* Platformatic
|
||||
* [mcollina](https://github.com/mcollina) -
|
||||
**Matteo Collina** <<matteo.collina@gmail.com>> (he/him)
|
||||
* Red Hat and IBM
|
||||
* [joesepi](https://github.com/joesepi) -
|
||||
**Joe Sepi** <<joesepi@ibm.com>> (he/him)
|
||||
* [mhdawson](https://github.com/mhdawson) -
|
||||
**Michael Dawson** <<midawson@redhat.com>> (he/him)
|
||||
|
||||
## License
|
||||
|
||||
Node.js is available under the
|
||||
[MIT license](https://opensource.org/licenses/MIT). Node.js also includes
|
||||
external libraries that are available under a variety of licenses. See
|
||||
[LICENSE](https://github.com/nodejs/node/blob/HEAD/LICENSE) for the full
|
||||
license text.
|
||||
|
||||
[Code of Conduct]: https://github.com/nodejs/admin/blob/HEAD/CODE_OF_CONDUCT.md
|
||||
[Contributing to the project]: CONTRIBUTING.md
|
||||
[Node.js website]: https://nodejs.org/
|
||||
[OpenJS Foundation]: https://openjsf.org/
|
||||
[Strategic initiatives]: doc/contributing/strategic-initiatives.md
|
||||
[Technical values and prioritization]: doc/contributing/technical-values.md
|
||||
[Working Groups]: https://github.com/nodejs/TSC/blob/HEAD/WORKING_GROUPS.md
|
||||
12
.tools/node_tar/node-v20.11.0-win-x64/corepack
Normal file
12
.tools/node_tar/node-v20.11.0-win-x64/corepack
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/node_modules/corepack/dist/corepack.js" "$@"
|
||||
else
|
||||
exec node "$basedir/node_modules/corepack/dist/corepack.js" "$@"
|
||||
fi
|
||||
7
.tools/node_tar/node-v20.11.0-win-x64/corepack.cmd
Normal file
7
.tools/node_tar/node-v20.11.0-win-x64/corepack.cmd
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@SETLOCAL
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\node_modules\corepack\dist\corepack.js" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\node_modules\corepack\dist\corepack.js" %*
|
||||
)
|
||||
55
.tools/node_tar/node-v20.11.0-win-x64/install_tools.bat
Normal file
55
.tools/node_tar/node-v20.11.0-win-x64/install_tools.bat
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
@echo off
|
||||
|
||||
setlocal
|
||||
title Install Additional Tools for Node.js
|
||||
|
||||
cls
|
||||
|
||||
echo ====================================================
|
||||
echo Tools for Node.js Native Modules Installation Script
|
||||
echo ====================================================
|
||||
echo.
|
||||
echo This script will install Python and the Visual Studio Build Tools, necessary
|
||||
echo to compile Node.js native modules. Note that Chocolatey and required Windows
|
||||
echo updates will also be installed.
|
||||
echo.
|
||||
echo This will require about 3 GiB of free disk space, plus any space necessary to
|
||||
echo install Windows updates. This will take a while to run.
|
||||
echo.
|
||||
echo Please close all open programs for the duration of the installation. If the
|
||||
echo installation fails, please ensure Windows is fully updated, reboot your
|
||||
echo computer and try to run this again. This script can be found in the
|
||||
echo Start menu under Node.js.
|
||||
echo.
|
||||
echo You can close this window to stop now. Detailed instructions to install these
|
||||
echo tools manually are available at https://github.com/nodejs/node-gyp#on-windows
|
||||
echo.
|
||||
pause
|
||||
|
||||
cls
|
||||
|
||||
REM Adapted from https://github.com/Microsoft/windows-dev-box-setup-scripts/blob/79bbe5bdc4867088b3e074f9610932f8e4e192c2/README.md#legal
|
||||
echo Using this script downloads third party software
|
||||
echo ------------------------------------------------
|
||||
echo This script will direct to Chocolatey to install packages. By using
|
||||
echo Chocolatey to install a package, you are accepting the license for the
|
||||
echo application, executable(s), or other artifacts delivered to your machine as a
|
||||
echo result of a Chocolatey install. This acceptance occurs whether you know the
|
||||
echo license terms or not. Read and understand the license terms of the packages
|
||||
echo being installed and their dependencies prior to installation:
|
||||
echo - https://chocolatey.org/packages/chocolatey
|
||||
echo - https://chocolatey.org/packages/python
|
||||
echo - https://chocolatey.org/packages/visualstudio2019-workload-vctools
|
||||
echo.
|
||||
echo This script is provided AS-IS without any warranties of any kind
|
||||
echo ----------------------------------------------------------------
|
||||
echo Chocolatey has implemented security safeguards in their process to help
|
||||
echo protect the community from malicious or pirated software, but any use of this
|
||||
echo script is at your own risk. Please read the Chocolatey's legal terms of use
|
||||
echo as well as how the community repository for Chocolatey.org is maintained.
|
||||
echo.
|
||||
pause
|
||||
|
||||
cls
|
||||
|
||||
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command Start-Process '%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe' -ArgumentList '-NoProfile -InputFormat None -ExecutionPolicy Bypass -Command [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; iex ((New-Object System.Net.WebClient).DownloadString(''https://chocolatey.org/install.ps1'')); choco upgrade -y python visualstudio2019-workload-vctools; Read-Host ''Type ENTER to exit'' ' -Verb RunAs
|
||||
BIN
.tools/node_tar/node-v20.11.0-win-x64/node.exe
Normal file
BIN
.tools/node_tar/node-v20.11.0-win-x64/node.exe
Normal file
Binary file not shown.
24
.tools/node_tar/node-v20.11.0-win-x64/nodevars.bat
Normal file
24
.tools/node_tar/node-v20.11.0-win-x64/nodevars.bat
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
@echo off
|
||||
|
||||
rem Ensure this Node.js and npm are first in the PATH
|
||||
set "PATH=%APPDATA%\npm;%~dp0;%PATH%"
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
pushd "%~dp0"
|
||||
|
||||
rem Figure out the Node.js version.
|
||||
set print_version=.\node.exe -p -e "process.versions.node + ' (' + process.arch + ')'"
|
||||
for /F "usebackq delims=" %%v in (`%print_version%`) do set version=%%v
|
||||
|
||||
rem Print message.
|
||||
if exist npm.cmd (
|
||||
echo Your environment has been set up for using Node.js !version! and npm.
|
||||
) else (
|
||||
echo Your environment has been set up for using Node.js !version!.
|
||||
)
|
||||
|
||||
popd
|
||||
endlocal
|
||||
|
||||
rem If we're in the Node.js directory, change to the user's home dir.
|
||||
if "%CD%\"=="%~dp0" cd /d "%HOMEDRIVE%%HOMEPATH%"
|
||||
64
.tools/node_tar/node-v20.11.0-win-x64/npm
Normal file
64
.tools/node_tar/node-v20.11.0-win-x64/npm
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# This is used by the Node.js installer, which expects the cygwin/mingw
|
||||
# shell script to already be present in the npm dependency folder.
|
||||
|
||||
(set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix
|
||||
|
||||
basedir=`dirname "$0"`
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ `uname` = 'Linux' ] && type wslpath &>/dev/null ; then
|
||||
IS_WSL="true"
|
||||
fi
|
||||
|
||||
function no_node_dir {
|
||||
# if this didn't work, then everything else below will fail
|
||||
echo "Could not determine Node.js install directory" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
NODE_EXE="$basedir/node.exe"
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE="$basedir/node"
|
||||
fi
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE=node
|
||||
fi
|
||||
|
||||
# this path is passed to node.exe, so it needs to match whatever
|
||||
# kind of paths Node.js thinks it's using, typically win32 paths.
|
||||
CLI_BASEDIR="$("$NODE_EXE" -p 'require("path").dirname(process.execPath)' 2> /dev/null)"
|
||||
if [ $? -ne 0 ]; then
|
||||
# this fails under WSL 1 so add an additional message. we also suppress stderr above
|
||||
# because the actual error raised is not helpful. in WSL 1 node.exe cannot handle
|
||||
# output redirection properly. See https://github.com/microsoft/WSL/issues/2370
|
||||
if [ "$IS_WSL" == "true" ]; then
|
||||
echo "WSL 1 is not supported. Please upgrade to WSL 2 or above." >&2
|
||||
fi
|
||||
no_node_dir
|
||||
fi
|
||||
NPM_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npm-cli.js"
|
||||
NPM_PREFIX=`"$NODE_EXE" "$NPM_CLI_JS" prefix -g`
|
||||
if [ $? -ne 0 ]; then
|
||||
no_node_dir
|
||||
fi
|
||||
NPM_PREFIX_NPM_CLI_JS="$NPM_PREFIX/node_modules/npm/bin/npm-cli.js"
|
||||
|
||||
# a path that will fail -f test on any posix bash
|
||||
NPM_WSL_PATH="/.."
|
||||
|
||||
# WSL can run Windows binaries, so we have to give it the win32 path
|
||||
# however, WSL bash tests against posix paths, so we need to construct that
|
||||
# to know if npm is installed globally.
|
||||
if [ "$IS_WSL" == "true" ]; then
|
||||
NPM_WSL_PATH=`wslpath "$NPM_PREFIX_NPM_CLI_JS"`
|
||||
fi
|
||||
if [ -f "$NPM_PREFIX_NPM_CLI_JS" ] || [ -f "$NPM_WSL_PATH" ]; then
|
||||
NPM_CLI_JS="$NPM_PREFIX_NPM_CLI_JS"
|
||||
fi
|
||||
|
||||
"$NODE_EXE" "$NPM_CLI_JS" "$@"
|
||||
19
.tools/node_tar/node-v20.11.0-win-x64/npm.cmd
Normal file
19
.tools/node_tar/node-v20.11.0-win-x64/npm.cmd
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
:: Created by npm, please don't edit manually.
|
||||
@ECHO OFF
|
||||
|
||||
SETLOCAL
|
||||
|
||||
SET "NODE_EXE=%~dp0\node.exe"
|
||||
IF NOT EXIST "%NODE_EXE%" (
|
||||
SET "NODE_EXE=node"
|
||||
)
|
||||
|
||||
SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
|
||||
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_CLI_JS%" prefix -g') DO (
|
||||
SET "NPM_PREFIX_NPM_CLI_JS=%%F\node_modules\npm\bin\npm-cli.js"
|
||||
)
|
||||
IF EXIST "%NPM_PREFIX_NPM_CLI_JS%" (
|
||||
SET "NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%"
|
||||
)
|
||||
|
||||
"%NODE_EXE%" "%NPM_CLI_JS%" %*
|
||||
65
.tools/node_tar/node-v20.11.0-win-x64/npx
Normal file
65
.tools/node_tar/node-v20.11.0-win-x64/npx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# This is used by the Node.js installer, which expects the cygwin/mingw
|
||||
# shell script to already be present in the npm dependency folder.
|
||||
|
||||
(set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix
|
||||
|
||||
basedir=`dirname "$0"`
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ `uname` = 'Linux' ] && type wslpath &>/dev/null ; then
|
||||
IS_WSL="true"
|
||||
fi
|
||||
|
||||
function no_node_dir {
|
||||
# if this didn't work, then everything else below will fail
|
||||
echo "Could not determine Node.js install directory" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
NODE_EXE="$basedir/node.exe"
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE="$basedir/node"
|
||||
fi
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE=node
|
||||
fi
|
||||
|
||||
# this path is passed to node.exe, so it needs to match whatever
|
||||
# kind of paths Node.js thinks it's using, typically win32 paths.
|
||||
CLI_BASEDIR="$("$NODE_EXE" -p 'require("path").dirname(process.execPath)' 2> /dev/null)"
|
||||
if [ $? -ne 0 ]; then
|
||||
# this fails under WSL 1 so add an additional message. we also suppress stderr above
|
||||
# because the actual error raised is not helpful. in WSL 1 node.exe cannot handle
|
||||
# output redirection properly. See https://github.com/microsoft/WSL/issues/2370
|
||||
if [ "$IS_WSL" == "true" ]; then
|
||||
echo "WSL 1 is not supported. Please upgrade to WSL 2 or above." >&2
|
||||
fi
|
||||
no_node_dir
|
||||
fi
|
||||
NPM_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npm-cli.js"
|
||||
NPX_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npx-cli.js"
|
||||
NPM_PREFIX=`"$NODE_EXE" "$NPM_CLI_JS" prefix -g`
|
||||
if [ $? -ne 0 ]; then
|
||||
no_node_dir
|
||||
fi
|
||||
NPM_PREFIX_NPX_CLI_JS="$NPM_PREFIX/node_modules/npm/bin/npx-cli.js"
|
||||
|
||||
# a path that will fail -f test on any posix bash
|
||||
NPX_WSL_PATH="/.."
|
||||
|
||||
# WSL can run Windows binaries, so we have to give it the win32 path
|
||||
# however, WSL bash tests against posix paths, so we need to construct that
|
||||
# to know if npm is installed globally.
|
||||
if [ "$IS_WSL" == "true" ]; then
|
||||
NPX_WSL_PATH=`wslpath "$NPM_PREFIX_NPX_CLI_JS"`
|
||||
fi
|
||||
if [ -f "$NPM_PREFIX_NPX_CLI_JS" ] || [ -f "$NPX_WSL_PATH" ]; then
|
||||
NPX_CLI_JS="$NPM_PREFIX_NPX_CLI_JS"
|
||||
fi
|
||||
|
||||
"$NODE_EXE" "$NPX_CLI_JS" "$@"
|
||||
20
.tools/node_tar/node-v20.11.0-win-x64/npx.cmd
Normal file
20
.tools/node_tar/node-v20.11.0-win-x64/npx.cmd
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
:: Created by npm, please don't edit manually.
|
||||
@ECHO OFF
|
||||
|
||||
SETLOCAL
|
||||
|
||||
SET "NODE_EXE=%~dp0\node.exe"
|
||||
IF NOT EXIST "%NODE_EXE%" (
|
||||
SET "NODE_EXE=node"
|
||||
)
|
||||
|
||||
SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
|
||||
SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
|
||||
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_CLI_JS%" prefix -g') DO (
|
||||
SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js"
|
||||
)
|
||||
IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" (
|
||||
SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%"
|
||||
)
|
||||
|
||||
"%NODE_EXE%" "%NPX_CLI_JS%" %*
|
||||
107
Dockerfile
107
Dockerfile
|
|
@ -1,65 +1,70 @@
|
|||
# --- Stage 1: Frontend Builder ---
|
||||
FROM node:18-slim AS builder
|
||||
|
||||
# ---------------------------
|
||||
# Stage 1: Build Frontend
|
||||
# ---------------------------
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
# Install dependencies including sharp for build
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY frontend/ ./
|
||||
# Build with standalone output
|
||||
ENV NEXT_PUBLIC_API_URL=""
|
||||
|
||||
COPY frontend-vite/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend-vite/ .
|
||||
# Ensure production build
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: Final Runtime Image ---
|
||||
FROM python:3.11-slim
|
||||
# ---------------------------
|
||||
# Stage 2: Build Backend
|
||||
# ---------------------------
|
||||
FROM golang:1.24-alpine AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Install build deps if needed (e.g. gcc for cgo, though we try to avoid it)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY backend-go/go.mod backend-go/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY backend-go/ .
|
||||
# Build static binary for linux/amd64
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=amd64
|
||||
RUN go build -o server cmd/server/main.go
|
||||
|
||||
# ---------------------------
|
||||
# Stage 3: Final Runtime
|
||||
# ---------------------------
|
||||
# We use python:3.11-slim because yt-dlp requires Python.
|
||||
# Alpine is smaller but Python/libc compatibility can be tricky for yt-dlp's dependencies.
|
||||
# Slim-bookworm is a safe bet for a "linux/amd64" target.
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Backend Setup
|
||||
COPY backend/requirements.txt ./backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
# Install runtime dependencies for yt-dlp (ffmpeg is crucial)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Frontend Setup (Copy from Builder)
|
||||
# Copy the standalone server
|
||||
COPY --from=builder /app/frontend/.next/standalone /app/frontend
|
||||
# Explicitly install sharp in the standalone folder to ensure compatibility
|
||||
RUN cd /app/frontend && npm install sharp
|
||||
# Copy backend binary
|
||||
COPY --from=backend-builder /app/backend/server /app/server
|
||||
|
||||
# Copy static files (required for standalone)
|
||||
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
|
||||
COPY --from=builder /app/frontend/public /app/frontend/public
|
||||
# Copy frontend build to 'static' folder (backend expects ./static)
|
||||
COPY --from=frontend-builder /app/frontend/dist /app/static
|
||||
|
||||
# Copy Backend Code
|
||||
COPY backend/ ./backend/
|
||||
# Create cache directory for spotdl
|
||||
RUN mkdir -p /tmp/spotify-clone-cache && chmod 777 /tmp/spotify-clone-cache
|
||||
|
||||
# Create start script
|
||||
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
|
||||
# Install yt-dlp (and pip)
|
||||
# We install it systematically via pip to ensure we have a managed version
|
||||
RUN pip install --no-cache-dir -U "yt-dlp[default]"
|
||||
|
||||
# Set Environment Variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
# Environment variables
|
||||
ENV PORT=8080
|
||||
ENV GIN_MODE=release
|
||||
|
||||
# Note: Standalone mode runs with 'node server.js'
|
||||
RUN echo '#!/bin/bash\n\
|
||||
if [ ! -f backend/data/data.json ]; then\n\
|
||||
echo "Data volume appears empty. Seeding with bundled data..."\n\
|
||||
cp -r backend/data_seed/* backend/data/\n\
|
||||
fi\n\
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\
|
||||
cd frontend && node server.js\n\
|
||||
' > start.sh && chmod +x start.sh
|
||||
EXPOSE 8080
|
||||
|
||||
EXPOSE 3000 8000
|
||||
|
||||
CMD ["./start.sh"]
|
||||
CMD ["/app/server"]
|
||||
|
|
|
|||
32
README_MIGRATION.md
Normal file
32
README_MIGRATION.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Migration Guide
|
||||
|
||||
This project has been migrated to **Golang** (Backend) and **Vite/React** (Frontend).
|
||||
|
||||
## Prerequisites
|
||||
- **Go 1.21+**
|
||||
- **Node.js 18+**
|
||||
- **yt-dlp** (Must be in your system PATH)
|
||||
|
||||
## 1. Running the Backend (Go)
|
||||
The backend replaces the FastAPI server. It uses `yt-dlp` CLI for searching and streaming.
|
||||
|
||||
```bash
|
||||
cd backend-go
|
||||
go mod tidy
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
Server will start on `http://localhost:8080`.
|
||||
|
||||
## 2. Running the Frontend (Vite)
|
||||
The frontend replaces Next.js.
|
||||
|
||||
```bash
|
||||
cd frontend-vite
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Frontend will start on `http://localhost:5173`.
|
||||
|
||||
## Notes
|
||||
- The Go backend proxies `yt-dlp` commands. Ensure `yt-dlp` is installed and updated.
|
||||
- The Frontend is configured to proxy `/api` requests to `http://localhost:8080`.
|
||||
25
backend-go/Dockerfile
Normal file
25
backend-go/Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Build Stage
|
||||
FROM golang:1.21-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
# Go mod tidy created go.sum? If not, we run it here.
|
||||
# Copy source
|
||||
COPY . .
|
||||
# Build
|
||||
RUN go mod tidy
|
||||
RUN go build -o server cmd/server/main.go
|
||||
|
||||
# Runtime Stage
|
||||
FROM python:3.11-alpine
|
||||
# We need python for yt-dlp
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies (ffmpeg, yt-dlp)
|
||||
RUN apk add --no-cache ffmpeg curl
|
||||
# Install yt-dlp via pip (often fresher) or use the binary
|
||||
RUN pip install yt-dlp
|
||||
|
||||
COPY --from=builder /app/server .
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]
|
||||
24
backend-go/cmd/server/main.go
Normal file
24
backend-go/cmd/server/main.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"spotify-clone-backend/internal/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
router := api.NewRouter()
|
||||
|
||||
fmt.Printf("Server starting on port %s...\n", port)
|
||||
if err := http.ListenAndServe(":"+port, router); err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
8
backend-go/go.mod
Normal file
8
backend-go/go.mod
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
module spotify-clone-backend
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.0.10
|
||||
github.com/go-chi/cors v1.2.1
|
||||
)
|
||||
4
backend-go/go.sum
Normal file
4
backend-go/go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
52
backend-go/internal/api/api_test.go
Normal file
52
backend-go/internal/api/api_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
// Create a request to pass to our handler.
|
||||
req, err := http.NewRequest("GET", "/api/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Initialize Router
|
||||
router := NewRouter()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
// Check the status code is what we expect.
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
|
||||
// Check the response body is what we expect.
|
||||
expected := "ok"
|
||||
if rr.Body.String() != expected {
|
||||
t.Errorf("handler returned unexpected body: got %v want %v",
|
||||
rr.Body.String(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchValidation(t *testing.T) {
|
||||
// Test missing query parameter
|
||||
req, err := http.NewRequest("GET", "/api/search", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(SearchTracks)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if status := rr.Code; status != http.StatusBadRequest {
|
||||
t.Errorf("handler returned wrong status code for missing query: got %v want %v",
|
||||
status, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
125
backend-go/internal/api/handlers.go
Normal file
125
backend-go/internal/api/handlers.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"spotify-clone-backend/internal/spotdl"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var spotdlService = spotdl.NewService()
|
||||
|
||||
func SearchTracks(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "query parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tracks, err := spotdlService.SearchTracks(query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"tracks": tracks})
|
||||
}
|
||||
|
||||
func StreamAudio(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := spotdlService.GetStreamURL(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers for streaming based on extension
|
||||
ext := filepath.Ext(path)
|
||||
contentType := "audio/mpeg" // default
|
||||
if ext == ".m4a" {
|
||||
contentType = "audio/mp4"
|
||||
} else if ext == ".webm" {
|
||||
contentType = "audio/webm"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Transfer-Encoding", "chunked")
|
||||
|
||||
// Flush headers immediately
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
// Now stream it
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
io.Copy(w, bufio.NewReader(f))
|
||||
}
|
||||
|
||||
func DownloadTrack(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.URL == "" {
|
||||
http.Error(w, "url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := spotdlService.DownloadTrack(body.URL)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"path": path, "status": "downloaded"})
|
||||
}
|
||||
|
||||
func GetArtistImage(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "query required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
imageURL, err := spotdlService.SearchArtist(query)
|
||||
if err != nil {
|
||||
http.Error(w, "artist not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"url": imageURL})
|
||||
}
|
||||
|
||||
func UpdateSimBinary(w http.ResponseWriter, r *http.Request) {
|
||||
output, err := spotdlService.UpdateBinary()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "updated", "output": output})
|
||||
}
|
||||
263
backend-go/internal/api/lyrics.go
Normal file
263
backend-go/internal/api/lyrics.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LyricsResponse struct {
|
||||
ID int `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
SyncedLyrics string `json:"syncedLyrics"` // Time-synced lyrics [mm:ss.xx] text
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
type OvhResponse struct {
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// cleanVideoTitle attempts to extract the actual song title from a YouTube video title
|
||||
func cleanVideoTitle(videoTitle, artistName string) string {
|
||||
// 1. If strict "Artist - Title" format matches, take the title part
|
||||
// Case-insensitive check
|
||||
lowerTitle := strings.ToLower(videoTitle)
|
||||
lowerArtist := strings.ToLower(artistName)
|
||||
|
||||
if strings.Contains(lowerTitle, " - ") {
|
||||
parts := strings.Split(videoTitle, " - ")
|
||||
if len(parts) >= 2 {
|
||||
// Check if first part is artist
|
||||
if strings.Contains(strings.ToLower(parts[0]), lowerArtist) {
|
||||
return cleanMetadata(parts[1])
|
||||
}
|
||||
// Check if second part is artist
|
||||
if strings.Contains(strings.ToLower(parts[1]), lowerArtist) {
|
||||
return cleanMetadata(parts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Separator Strategy ( |, //, -, :, feat. )
|
||||
// Normalize separators to |
|
||||
simplified := videoTitle
|
||||
for _, sep := range []string{"//", " - ", ":", "feat.", "ft.", "|"} {
|
||||
simplified = strings.ReplaceAll(simplified, sep, "|")
|
||||
}
|
||||
|
||||
if strings.Contains(simplified, "|") {
|
||||
parts := strings.Split(simplified, "|")
|
||||
// Filter parts
|
||||
var candidates []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
pLower := strings.ToLower(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip "Official Video", "MV", "Artist Name"
|
||||
if strings.Contains(pLower, "official") || strings.Contains(pLower, "mv") || strings.Contains(pLower, "music video") {
|
||||
continue
|
||||
}
|
||||
// Skip if it is contained in artist name (e.g. "Min" in "Min Official")
|
||||
if pLower == lowerArtist || strings.Contains(lowerArtist, pLower) || strings.Contains(pLower, lowerArtist) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, p)
|
||||
}
|
||||
|
||||
// Heuristic: The Title is usually the FIRST valid part remaining.
|
||||
// However, if we have multiple, and one is very short (< 4 chars) and one is long, pick the long one?
|
||||
// Actually, let's look for the one that looks most like a title.
|
||||
// For now, if we have multiple candidates, let's pick the longest one if the first one is tiny.
|
||||
if len(candidates) > 0 {
|
||||
best := candidates[0]
|
||||
// If first candidate is super short (e.g. "HD"), look for a better one
|
||||
if len(best) < 4 && len(candidates) > 1 {
|
||||
for _, c := range candidates[1:] {
|
||||
if len(c) > len(best) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleanMetadata(best)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanMetadata(videoTitle)
|
||||
}
|
||||
|
||||
func cleanMetadata(title string) string {
|
||||
// Remove parenthetical noise like (feat. X), (Official)
|
||||
// Also remove unparenthesized "feat. X" or "ft. X" at the end of the string
|
||||
re := regexp.MustCompile(`(?i)(\(feat\..*?\)|\[feat\..*?\]|\(remaster.*?\)|- remaster.*| - live.*|\(official.*?\)|\[official.*?\]| - official.*|\sfeat\..*|\sft\..*)`)
|
||||
clean := re.ReplaceAllString(title, "")
|
||||
return strings.TrimSpace(clean)
|
||||
}
|
||||
|
||||
func cleanArtist(artist string) string {
|
||||
// Remove " - Topic", " Official", "VEVO"
|
||||
re := regexp.MustCompile(`(?i)( - topic| official| channel| vevo)`)
|
||||
return strings.TrimSpace(re.ReplaceAllString(artist, ""))
|
||||
}
|
||||
|
||||
func fetchFromLRCLIB(artist, track string) (*LyricsResponse, error) {
|
||||
// 1. Try Specific Get
|
||||
targetURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s", url.QueryEscape(artist), url.QueryEscape(track))
|
||||
resp, err := httpClient.Get(targetURL)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
var lyrics LyricsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lyrics); err == nil && (lyrics.PlainLyrics != "" || lyrics.SyncedLyrics != "") {
|
||||
resp.Body.Close()
|
||||
return &lyrics, nil
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// 2. Try Search (Best Match)
|
||||
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s %s", url.QueryEscape(artist), url.QueryEscape(track))
|
||||
resp2, err := httpClient.Get(searchURL)
|
||||
if err == nil && resp2.StatusCode == 200 {
|
||||
var results []LyricsResponse
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&results); err == nil && len(results) > 0 {
|
||||
resp2.Body.Close()
|
||||
return &results[0], nil
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("not found in lrclib")
|
||||
}
|
||||
|
||||
func fetchFromOVH(artist, track string) (*LyricsResponse, error) {
|
||||
// OVH API: https://api.lyrics.ovh/v1/artist/title
|
||||
targetURL := fmt.Sprintf("https://api.lyrics.ovh/v1/%s/%s", url.QueryEscape(artist), url.QueryEscape(track))
|
||||
resp, err := httpClient.Get(targetURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
var ovh OvhResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ovh); err == nil && ovh.Lyrics != "" {
|
||||
return &LyricsResponse{
|
||||
TrackName: track,
|
||||
ArtistName: artist,
|
||||
PlainLyrics: ovh.Lyrics,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found in ovh")
|
||||
}
|
||||
|
||||
func fetchFromLyrist(track string) (*LyricsResponse, error) {
|
||||
// API: https://lyrist.vercel.app/api/:query
|
||||
// Simple free API wrapper
|
||||
targetURL := fmt.Sprintf("https://lyrist.vercel.app/api/%s", url.QueryEscape(track))
|
||||
resp, err := httpClient.Get(targetURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
var res struct {
|
||||
Lyrics string `json:"lyrics"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err == nil && res.Lyrics != "" {
|
||||
return &LyricsResponse{
|
||||
TrackName: res.Title,
|
||||
ArtistName: res.Artist,
|
||||
PlainLyrics: res.Lyrics,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found in lyrist")
|
||||
}
|
||||
|
||||
func GetLyrics(w http.ResponseWriter, r *http.Request) {
|
||||
// Allow CORS
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
rawArtist := r.URL.Query().Get("artist")
|
||||
rawTrack := r.URL.Query().Get("track")
|
||||
|
||||
if rawTrack == "" {
|
||||
http.Error(w, "track required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Clean Inputs
|
||||
artist := cleanArtist(rawArtist)
|
||||
smartTitle := cleanVideoTitle(rawTrack, artist) // Heuristic extraction
|
||||
dumbTitle := cleanMetadata(rawTrack) // Simple regex cleaning
|
||||
|
||||
fmt.Printf("[Lyrics] Request: %s | %s\n", rawArtist, rawTrack)
|
||||
fmt.Printf("[Lyrics] Cleaned: %s | %s\n", artist, smartTitle)
|
||||
|
||||
// Strategy 1: LRCLIB (Exact Smart)
|
||||
if lyrics, err := fetchFromLRCLIB(artist, smartTitle); err == nil {
|
||||
fmt.Println("[Lyrics] Strategy 1 (Exact Smart) Hit")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(lyrics)
|
||||
return
|
||||
}
|
||||
|
||||
// Strategy 2: LRCLIB (Exact Dumb) - Fallback if our smart extraction failed
|
||||
if smartTitle != dumbTitle {
|
||||
if lyrics, err := fetchFromLRCLIB(artist, dumbTitle); err == nil {
|
||||
fmt.Println("[Lyrics] Strategy 2 (Exact Dumb) Hit")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(lyrics)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Lyrist (Smart Search)
|
||||
if lyrics, err := fetchFromLyrist(fmt.Sprintf("%s %s", artist, smartTitle)); err == nil {
|
||||
fmt.Println("[Lyrics] Strategy 3 (Lyrist) Hit")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(lyrics)
|
||||
return
|
||||
}
|
||||
|
||||
// Strategy 4: OVH (Last Resort)
|
||||
if lyrics, err := fetchFromOVH(artist, smartTitle); err == nil {
|
||||
fmt.Println("[Lyrics] Strategy 4 (OVH) Hit")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(lyrics)
|
||||
return
|
||||
}
|
||||
|
||||
// Strategy 5: Hail Mary Search (Raw-ish)
|
||||
if lyrics, err := fetchFromLRCLIB("", fmt.Sprintf("%s %s", artist, smartTitle)); err == nil {
|
||||
fmt.Println("[Lyrics] Strategy 5 (Hail Mary) Hit")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(lyrics)
|
||||
return
|
||||
}
|
||||
|
||||
// Strategy 6: Title Only (Ignore Artist)
|
||||
// Sometimes artist name is completely different in DB
|
||||
if lyrics, err := fetchFromLRCLIB("", smartTitle); err == nil {
|
||||
fmt.Println("[Lyrics] Strategy 6 (Title Only) Hit")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(lyrics)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[Lyrics] Failed to find lyrics")
|
||||
http.Error(w, "lyrics not found", http.StatusNotFound)
|
||||
}
|
||||
84
backend-go/internal/api/router.go
Normal file
84
backend-go/internal/api/router.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func NewRouter() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(60 * time.Second))
|
||||
|
||||
// CORS
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173"}, // Added Vite default port
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
r.Get("/search", SearchTracks)
|
||||
r.Get("/stream/{id}", StreamAudio)
|
||||
r.Get("/lyrics", GetLyrics)
|
||||
r.Get("/artist-image", GetArtistImage)
|
||||
r.Post("/download", DownloadTrack)
|
||||
r.Post("/settings/update-ytdlp", UpdateSimBinary)
|
||||
})
|
||||
|
||||
// Serve Static Files (SPA)
|
||||
workDir, _ := os.Getwd()
|
||||
filesDir := http.Dir(filepath.Join(workDir, "static"))
|
||||
FileServer(r, "/", filesDir)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// FileServer conveniently sets up a http.FileServer handler to serve
|
||||
// static files from a http.FileSystem.
|
||||
func FileServer(r chi.Router, path string, root http.FileSystem) {
|
||||
if strings.ContainsAny(path, "{}*") {
|
||||
panic("FileServer does not permit any URL parameters.")
|
||||
}
|
||||
|
||||
if path != "/" && path[len(path)-1] != '/' {
|
||||
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
|
||||
path += "/"
|
||||
}
|
||||
path += "*"
|
||||
|
||||
r.Get(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
rctx := chi.RouteContext(r.Context())
|
||||
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
|
||||
fs := http.StripPrefix(pathPrefix, http.FileServer(root))
|
||||
|
||||
// Check if file exists, otherwise serve index.html (SPA)
|
||||
f, err := root.Open(r.URL.Path)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
http.ServeFile(w, r, filepath.Join("static", "index.html"))
|
||||
return
|
||||
}
|
||||
if f != nil {
|
||||
f.Close()
|
||||
}
|
||||
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
25
backend-go/internal/models/models.go
Normal file
25
backend-go/internal/models/models.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package models
|
||||
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
Duration int `json:"duration"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Tracks []Track `json:"tracks"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
410
backend-go/internal/spotdl/client.go
Normal file
410
backend-go/internal/spotdl/client.go
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
package spotdl
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"spotify-clone-backend/internal/models"
|
||||
)
|
||||
|
||||
// ytDlpPath finds the yt-dlp executable
|
||||
func ytDlpPath() string {
|
||||
// Check for local yt-dlp.exe
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
localPath := filepath.Join(filepath.Dir(exe), "yt-dlp.exe")
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath
|
||||
}
|
||||
}
|
||||
|
||||
// Check in working directory
|
||||
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
||||
return "./yt-dlp.exe"
|
||||
}
|
||||
|
||||
// Check Python Scripts directory
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
pythonScriptsPath := filepath.Join(homeDir, "AppData", "Local", "Programs", "Python", "Python312", "Scripts", "yt-dlp.exe")
|
||||
if _, err := os.Stat(pythonScriptsPath); err == nil {
|
||||
return pythonScriptsPath
|
||||
}
|
||||
}
|
||||
|
||||
return "yt-dlp"
|
||||
}
|
||||
|
||||
type CacheItem struct {
|
||||
Tracks []models.Track
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
downloadDir string
|
||||
searchCache map[string]CacheItem
|
||||
cacheMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
downloadDir := filepath.Join(os.TempDir(), "spotify-clone-cache")
|
||||
os.MkdirAll(downloadDir, 0755)
|
||||
return &Service{
|
||||
downloadDir: downloadDir,
|
||||
searchCache: make(map[string]CacheItem),
|
||||
}
|
||||
}
|
||||
|
||||
// YTResult represents yt-dlp JSON output
|
||||
type YTResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Uploader string `json:"uploader"`
|
||||
Duration float64 `json:"duration"`
|
||||
Webpage string `json:"webpage_url"`
|
||||
Thumbnails []struct {
|
||||
URL string `json:"url"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
} `json:"thumbnails"`
|
||||
}
|
||||
|
||||
// SearchTracks uses yt-dlp to search YouTube
|
||||
func (s *Service) SearchTracks(query string) ([]models.Track, error) {
|
||||
// 1. Check Cache
|
||||
s.cacheMutex.RLock()
|
||||
if item, found := s.searchCache[query]; found {
|
||||
if time.Since(item.Timestamp) < 1*time.Hour { // 1 Hour Cache
|
||||
s.cacheMutex.RUnlock()
|
||||
fmt.Printf("Cache Hit: %s\n", query)
|
||||
return item.Tracks, nil
|
||||
}
|
||||
}
|
||||
s.cacheMutex.RUnlock()
|
||||
|
||||
// yt-dlp "ytsearch20:<query>" --dump-json --no-playlist --flat-playlist
|
||||
path := ytDlpPath()
|
||||
searchQuery := fmt.Sprintf("ytsearch20:%s", query)
|
||||
|
||||
// Using --flat-playlist is fast but sometimes lacks thumbnails/details in some versions.
|
||||
// However, the JSON output above showed thumbnails present even with --flat-playlist (or maybe I removed it in previous step? No I added it).
|
||||
// Let's stick to the current command which provided results, just parse better.
|
||||
cmd := exec.Command(path, searchQuery, "--dump-json", "--no-playlist", "--flat-playlist")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
fmt.Printf("Executing: %s %s --dump-json\n", path, searchQuery)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tracks []models.Track
|
||||
scanner := bufio.NewScanner(&stdout)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
var res YTResult
|
||||
if err := json.Unmarshal(line, &res); err == nil {
|
||||
// FILTER: Skip channels and playlists
|
||||
// Channels usually start with UC, Playlists with PL (though IDs varies, channels are distinct)
|
||||
// A safest check is duration > 0, channels have 0 duration in flat sort usually?
|
||||
// Or check ID pattern.
|
||||
if strings.HasPrefix(res.ID, "UC") || strings.HasPrefix(res.ID, "PL") || res.Duration == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Clean artist name (remove " - Topic")
|
||||
artist := strings.Replace(res.Uploader, " - Topic", "", -1)
|
||||
|
||||
// Select best thumbnail (Highest resolution)
|
||||
coverURL := ""
|
||||
// maxArea := 0 // Removed unused variable
|
||||
|
||||
if len(res.Thumbnails) > 0 {
|
||||
bestScore := -1.0
|
||||
|
||||
for _, thumb := range res.Thumbnails {
|
||||
// Calculate score: Area * AspectRatioPenalty
|
||||
// We want square (1.0).
|
||||
// Penalty = 1 / (1 + abs(ratio - 1))
|
||||
|
||||
w := float64(thumb.Width)
|
||||
h := float64(thumb.Height)
|
||||
if w == 0 || h == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
ratio := w / h
|
||||
diff := ratio - 1.0
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
// If strictly square (usually YTM), give huge bonus
|
||||
// YouTube Music covers are often 1:1
|
||||
score := w * h
|
||||
|
||||
if diff < 0.1 { // Close to square
|
||||
score = score * 10 // Boost square images significantly
|
||||
}
|
||||
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
coverURL = thumb.URL
|
||||
}
|
||||
}
|
||||
|
||||
// If specific check failed (e.g. 0 dimensions), fallback to last
|
||||
if coverURL == "" {
|
||||
coverURL = res.Thumbnails[len(res.Thumbnails)-1].URL
|
||||
}
|
||||
} else {
|
||||
// Fallback construction - favor maxres, but hq is safer.
|
||||
// Let's use hqdefault which is effectively standard high quality.
|
||||
coverURL = fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", res.ID)
|
||||
}
|
||||
|
||||
tracks = append(tracks, models.Track{
|
||||
ID: res.ID,
|
||||
Title: res.Title,
|
||||
Artist: artist,
|
||||
Album: "YouTube Music",
|
||||
Duration: int(res.Duration),
|
||||
CoverURL: coverURL,
|
||||
URL: fmt.Sprintf("/api/stream/%s", res.ID), // Use backend stream endpoint
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Save to Cache
|
||||
if len(tracks) > 0 {
|
||||
s.cacheMutex.Lock()
|
||||
s.searchCache[query] = CacheItem{
|
||||
Tracks: tracks,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
s.cacheMutex.Unlock()
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// SearchArtist searches for a channel/artist to get their thumbnail
|
||||
func (s *Service) SearchArtist(query string) (string, error) {
|
||||
// Search for the artist channel specifically
|
||||
path := ytDlpPath()
|
||||
// Increase to 3 results to increase chance of finding the actual channel if a video comes first
|
||||
searchQuery := fmt.Sprintf("ytsearch3:%s official channel", query)
|
||||
|
||||
// Remove --no-playlist to allow channel results
|
||||
cmd := exec.Command(path, searchQuery, "--dump-json", "--flat-playlist")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var bestThumbnail string
|
||||
|
||||
scanner := bufio.NewScanner(&stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
var res struct {
|
||||
ID string `json:"id"`
|
||||
Thumbnails []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"thumbnails"`
|
||||
ChannelThumbnail string `json:"channel_thumbnail"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &res); err == nil {
|
||||
// Check if this is a channel (ID starts with UC)
|
||||
if strings.HasPrefix(res.ID, "UC") {
|
||||
if len(res.Thumbnails) > 0 {
|
||||
// Return immediately if we found a channel
|
||||
// OPTIMIZATION: User requested faster loading/lower quality.
|
||||
// Instead of taking the last (largest), find one that is "good enough" (>= 150px)
|
||||
// Channels usually have s88, s176, s240, s800 etc. s176 is perfect for local 144px display.
|
||||
|
||||
selected := res.Thumbnails[len(res.Thumbnails)-1].URL // Default to largest
|
||||
|
||||
// Simple logic: If we have multiple, pick the second to last? Or just hardcode a preference?
|
||||
// Let's assume the list is sorted size ascending.
|
||||
if len(res.Thumbnails) >= 2 {
|
||||
// Usually [small, medium, large, max].
|
||||
// If > 3 items, pick the one at index 1 or 2.
|
||||
// Let's aim for index 1 (usually ~176px or 300px)
|
||||
idx := 1
|
||||
if idx >= len(res.Thumbnails) {
|
||||
idx = len(res.Thumbnails) - 1
|
||||
}
|
||||
selected = res.Thumbnails[idx].URL
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Keep track of the first valid thumbnail as fallback
|
||||
if bestThumbnail == "" && len(res.Thumbnails) > 0 {
|
||||
// Same logic for fallback
|
||||
idx := 1
|
||||
if len(res.Thumbnails) < 2 {
|
||||
idx = 0
|
||||
}
|
||||
bestThumbnail = res.Thumbnails[idx].URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestThumbnail != "" {
|
||||
return bestThumbnail, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("artist not found")
|
||||
}
|
||||
|
||||
// GetStreamURL downloads the track and returns the local file path
|
||||
func (s *Service) GetStreamURL(videoURL string) (string, error) {
|
||||
// If it's a Spotify URL, we can't handle it directly with yt-dlp in this mode easily
|
||||
// without search. But the frontend now sends YouTube URLs (from SearchTracks).
|
||||
// If ID is passed, construct YouTube URL.
|
||||
|
||||
var targetURL string
|
||||
if strings.HasPrefix(videoURL, "http") {
|
||||
targetURL = videoURL
|
||||
} else {
|
||||
// Assume ID
|
||||
targetURL = "https://www.youtube.com/watch?v=" + videoURL
|
||||
}
|
||||
|
||||
videoID := extractVideoID(targetURL)
|
||||
// Check if already downloaded (check for any audio format)
|
||||
// We prefer m4a or webm since we don't have ffmpeg for mp3 conversion
|
||||
pattern := filepath.Join(s.downloadDir, videoID+".*")
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
if len(matches) > 0 {
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
// Download: yt-dlp -f "bestaudio[ext=m4a]/bestaudio" -o <id>.%(ext)s <url>
|
||||
// This avoids needing ffmpeg for conversion
|
||||
cmd := exec.Command(ytDlpPath(), "-f", "bestaudio[ext=m4a]/bestaudio", "--output", videoID+".%(ext)s", targetURL)
|
||||
cmd.Dir = s.downloadDir
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// Find the downloaded file again
|
||||
matches, _ = filepath.Glob(pattern)
|
||||
if len(matches) > 0 {
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("downloaded file not found")
|
||||
}
|
||||
|
||||
// StreamAudioToWriter streams audio to http writer
|
||||
func (s *Service) StreamAudioToWriter(id string, w io.Writer) error {
|
||||
filePath, err := s.GetStreamURL(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(w, file)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DownloadTrack(url string) (string, error) {
|
||||
return s.GetStreamURL(url)
|
||||
}
|
||||
|
||||
func extractVideoID(url string) string {
|
||||
// Basic extraction for https://www.youtube.com/watch?v=ID
|
||||
if strings.Contains(url, "v=") {
|
||||
parts := strings.Split(url, "v=")
|
||||
if len(parts) > 1 {
|
||||
return strings.Split(parts[1], "&")[0]
|
||||
}
|
||||
}
|
||||
return url // fallback to assume it's an ID or full URL if unique enough
|
||||
}
|
||||
|
||||
// UpdateBinary updates yt-dlp to the latest nightly version
|
||||
func (s *Service) UpdateBinary() (string, error) {
|
||||
path := ytDlpPath()
|
||||
// Command: yt-dlp --update-to nightly
|
||||
cmd := exec.Command(path, "--update-to", "nightly")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
errStr := stderr.String()
|
||||
// 2. Handle Pip Install Error
|
||||
// "ERROR: You installed yt-dlp with pip or using the wheel from PyPi; Use that to update"
|
||||
if strings.Contains(errStr, "pip") || strings.Contains(errStr, "wheel") {
|
||||
// Try to find pip in the same directory as yt-dlp
|
||||
dir := filepath.Dir(path)
|
||||
pipPath := filepath.Join(dir, "pip.exe")
|
||||
|
||||
// If not found, try "pip" from PATH? But earlier checkout failed.
|
||||
// Let's rely on relative path first.
|
||||
if _, statErr := os.Stat(pipPath); statErr != nil {
|
||||
// Try "python -m pip" if python is there?
|
||||
// Or maybe "pip3.exe"
|
||||
pipPath = filepath.Join(dir, "pip3.exe")
|
||||
}
|
||||
|
||||
if _, statErr := os.Stat(pipPath); statErr == nil {
|
||||
// Found pip, try updating via pip
|
||||
// pip install -U --pre "yt-dlp[default]"
|
||||
// We use --pre to get nightly/pre-release builds which user requested
|
||||
pipCmd := exec.Command(pipPath, "install", "--upgrade", "--pre", "yt-dlp[default]")
|
||||
|
||||
// Capture new output
|
||||
pipStdout := &bytes.Buffer{}
|
||||
pipStderr := &bytes.Buffer{}
|
||||
pipCmd.Stdout = pipStdout
|
||||
pipCmd.Stderr = pipStderr
|
||||
|
||||
if pipErr := pipCmd.Run(); pipErr == nil {
|
||||
return fmt.Sprintf("Updated via pip (%s):\n%s", pipPath, pipStdout.String()), nil
|
||||
} else {
|
||||
// Pip failed too
|
||||
return "", fmt.Errorf("pip update failed: %w, stderr: %s", pipErr, pipStderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("update failed: %w, stderr: %s", err, errStr)
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,922 +0,0 @@
|
|||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Response
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from pathlib import Path
|
||||
import yt_dlp
|
||||
import requests
|
||||
from backend.services.spotify import SpotifyService
|
||||
from backend.services.cache import CacheManager
|
||||
from backend.playlist_manager import PlaylistManager
|
||||
from backend.scheduler import update_ytdlp # Import update function
|
||||
|
||||
import re
|
||||
|
||||
router = APIRouter()
|
||||
# Services (Assumed to be initialized elsewhere if not here, adhering to existing patterns)
|
||||
# spotify = SpotifyService() # Commented out as duplicates if already imported
|
||||
if 'CacheManager' in globals():
|
||||
cache = CacheManager()
|
||||
else:
|
||||
from backend.cache_manager import CacheManager
|
||||
cache = CacheManager()
|
||||
|
||||
playlist_manager = PlaylistManager()
|
||||
|
||||
@router.post("/system/update-ytdlp")
|
||||
async def manual_ytdlp_update(background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Trigger a manual update of yt-dlp in the background.
|
||||
"""
|
||||
background_tasks.add_task(update_ytdlp)
|
||||
return {"status": "success", "message": "yt-dlp update started in background"}
|
||||
|
||||
def get_high_res_thumbnail(thumbnails: list) -> str:
|
||||
"""
|
||||
Selects the best thumbnail and attempts to upgrade resolution
|
||||
if it's a Google/YouTube URL.
|
||||
"""
|
||||
if not thumbnails:
|
||||
return "https://placehold.co/300x300"
|
||||
|
||||
# 1. Start with the largest available in the list
|
||||
best_url = thumbnails[-1]['url']
|
||||
|
||||
# 2. Upgrade resolution for Google User Content (lh3.googleusercontent.com, yt3.ggpht.com)
|
||||
# Common patterns:
|
||||
# =w120-h120-l90-rj (Small)
|
||||
# =w544-h544-l90-rj (High Res)
|
||||
# s120-c-k-c0x00ffffff-no-rj (Profile/Avatar)
|
||||
|
||||
if "googleusercontent.com" in best_url or "ggpht.com" in best_url:
|
||||
import re
|
||||
# Replace width/height params with 544 (standard YTM high res)
|
||||
# We look for patterns like =w<num>-h<num>...
|
||||
if "w" in best_url and "h" in best_url:
|
||||
best_url = re.sub(r'=w\d+-h\d+', '=w544-h544', best_url)
|
||||
elif best_url.startswith("https://lh3.googleusercontent.com") and "=" in best_url:
|
||||
# Sometimes it's just URL=...
|
||||
# We can try to force it
|
||||
pass
|
||||
|
||||
return best_url
|
||||
|
||||
def extract_artist_names(track: dict) -> str:
|
||||
"""Safely extracts artist names from track data (dict or str items)."""
|
||||
artists = track.get('artists') or []
|
||||
if isinstance(artists, list):
|
||||
names = []
|
||||
for a in artists:
|
||||
if isinstance(a, dict):
|
||||
names.append(a.get('name', 'Unknown'))
|
||||
elif isinstance(a, str):
|
||||
names.append(a)
|
||||
return ", ".join(names) if names else "Unknown Artist"
|
||||
return "Unknown Artist"
|
||||
|
||||
def extract_album_name(track: dict, default="Single") -> str:
|
||||
"""Safely extracts album name from track data."""
|
||||
album = track.get('album')
|
||||
if isinstance(album, dict):
|
||||
return album.get('name', default)
|
||||
if isinstance(album, str):
|
||||
return album
|
||||
return default
|
||||
|
||||
def clean_text(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
# Remove emojis
|
||||
text = text.encode('ascii', 'ignore').decode('ascii')
|
||||
# Remove text inside * * or similar patterns if they look spammy
|
||||
# Remove excessive punctuation
|
||||
# Example: "THE * VIRAL 50 *" -> "THE VIRAL 50"
|
||||
|
||||
# 1. Remove URLs
|
||||
text = re.sub(r'http\S+|www\.\S+', '', text)
|
||||
|
||||
# 2. Remove "Playlist", "Music Chart", "Full SPOTIFY" spam keywords if desirable,
|
||||
# but that might be too aggressive.
|
||||
# Let's focus on cleaning the "Structure".
|
||||
|
||||
# 3. Truncate Description if too long (e.g. > 300 chars)?
|
||||
# The user example had a MASSIVE description.
|
||||
# Let's just take the first paragraph or chunk?
|
||||
|
||||
# 4. Remove excessive non-alphanumeric separators
|
||||
text = re.sub(r'[*_=]{3,}', '', text) # Remove long separator lines
|
||||
|
||||
# Custom cleaning for the specific example style:
|
||||
# Remove text between asterisks if it looks like garbage? No, sometimes it's emphasis.
|
||||
|
||||
return text.strip()
|
||||
|
||||
def clean_title(title: str) -> str:
|
||||
if not title: return "Playlist"
|
||||
# Remove emojis (simple way)
|
||||
title = title.encode('ascii', 'ignore').decode('ascii')
|
||||
# Remove "Playlist", "Music Chart", "Full Video" spam
|
||||
spam_words = ["Playlist", "Music Chart", "Full SPOTIFY Video", "Updated Weekly", "Official", "Video"]
|
||||
for word in spam_words:
|
||||
title = re.sub(word, "", title, flags=re.IGNORECASE)
|
||||
|
||||
# Remove extra spaces and asterisks
|
||||
title = re.sub(r'\s+', ' ', title).strip()
|
||||
title = title.strip('*- ')
|
||||
return title
|
||||
|
||||
def clean_description(desc: str) -> str:
|
||||
if not desc: return ""
|
||||
# Remove URLs
|
||||
desc = re.sub(r'http\S+', '', desc)
|
||||
# Remove massive divider lines
|
||||
desc = re.sub(r'[*_=]{3,}', '', desc)
|
||||
# Be more aggressive with length?
|
||||
if len(desc) > 300:
|
||||
desc = desc[:300] + "..."
|
||||
return desc.strip()
|
||||
|
||||
CACHE_DIR = Path("backend/cache")
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
class CreatePlaylistRequest(BaseModel):
|
||||
name: str # Renamed from Title to Name to match Sidebar usage more typically, but API expects pydantic model
|
||||
description: str = ""
|
||||
|
||||
@router.get("/browse")
|
||||
async def get_browse_content():
|
||||
"""
|
||||
Returns the real fetched playlists from browse_playlists.json
|
||||
"""
|
||||
try:
|
||||
data_path = Path("backend/data/browse_playlists.json")
|
||||
if data_path.exists():
|
||||
with open(data_path, "r") as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Browse Error: {e}")
|
||||
return []
|
||||
|
||||
CATEGORIES_MAP = {
|
||||
"Trending Vietnam": {"query": "Top 50 Vietnam", "type": "playlists"},
|
||||
"Just released Songs": {"query": "New Released Songs", "type": "playlists"},
|
||||
"Albums": {"query": "New Albums 2024", "type": "albums"},
|
||||
"Vietnamese DJs": {"query": "Vinahouse Remix", "type": "playlists"},
|
||||
"Global Hits": {"query": "Global Top 50", "type": "playlists"},
|
||||
"Chill Vibes": {"query": "Chill Lofi", "type": "playlists"},
|
||||
"Party Time": {"query": "Party EDM Hits", "type": "playlists"},
|
||||
"Best of Ballad": {"query": "Vietnamese Ballad", "type": "playlists"},
|
||||
"Hip Hop & Rap": {"query": "Vietnamese Rap", "type": "playlists"},
|
||||
}
|
||||
|
||||
@router.get("/browse/category")
|
||||
async def get_browse_category(name: str):
|
||||
"""
|
||||
Fetch live data for a specific category (infinite scroll support).
|
||||
Fetches up to 50-100 items.
|
||||
"""
|
||||
if name not in CATEGORIES_MAP:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
info = CATEGORIES_MAP[name]
|
||||
query = info["query"]
|
||||
search_type = info["type"]
|
||||
|
||||
# Check Cache
|
||||
cache_key = f"browse_category:{name}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
from ytmusicapi import YTMusic
|
||||
yt = YTMusic()
|
||||
|
||||
# Search for more items (e.g. 50)
|
||||
results = yt.search(query, filter=search_type, limit=50)
|
||||
|
||||
category_items = []
|
||||
|
||||
for result in results:
|
||||
item_id = result.get('browseId')
|
||||
if not item_id: continue
|
||||
|
||||
title = result.get('title', 'Unknown')
|
||||
|
||||
# Simple item structure for list view (we don't need full track list for every item immediately)
|
||||
# But frontend expects some structure.
|
||||
|
||||
# Extract basic thumbnails
|
||||
thumbnails = result.get('thumbnails', [])
|
||||
cover_url = get_high_res_thumbnail(thumbnails)
|
||||
|
||||
# description logic
|
||||
description = ""
|
||||
if search_type == "albums":
|
||||
artists_text = ", ".join([a.get('name') for a in result.get('artists', [])])
|
||||
year = result.get('year', '')
|
||||
description = f"Album by {artists_text} • {year}"
|
||||
is_album = True
|
||||
else:
|
||||
is_album = False
|
||||
# For playlists result, description might be missing in search result
|
||||
description = f"Playlist • {result.get('itemCount', '')} tracks"
|
||||
|
||||
category_items.append({
|
||||
"id": item_id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"cover_url": cover_url,
|
||||
"type": "album" if is_album else "playlist",
|
||||
# Note: We are NOT fetching full tracks for each item here to save speed/quota.
|
||||
# The frontend only needs cover, title, description, id.
|
||||
# Tracks are fetched when user clicks the item (via get_playlist).
|
||||
"tracks": []
|
||||
})
|
||||
|
||||
cache.set(cache_key, category_items, ttl_seconds=3600) # Cache for 1 hour
|
||||
return category_items
|
||||
|
||||
except Exception as e:
|
||||
print(f"Category Fetch Error: {e}")
|
||||
return []
|
||||
|
||||
@router.get("/playlists")
|
||||
async def get_user_playlists():
|
||||
return playlist_manager.get_all()
|
||||
|
||||
@router.post("/playlists")
|
||||
async def create_user_playlist(playlist: CreatePlaylistRequest):
|
||||
return playlist_manager.create(playlist.name, playlist.description)
|
||||
|
||||
@router.delete("/playlists/{id}")
|
||||
async def delete_user_playlist(id: str):
|
||||
success = playlist_manager.delete(id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/playlists/{id}")
|
||||
async def get_playlist(id: str):
|
||||
"""
|
||||
Get a specific playlist by ID.
|
||||
1. Check if it's a User Playlist.
|
||||
2. If not, fetch from YouTube Music (Browse/External).
|
||||
"""
|
||||
# 1. Try User Playlist
|
||||
user_playlists = playlist_manager.get_all()
|
||||
user_playlist = next((p for p in user_playlists if p['id'] == id), None)
|
||||
if user_playlist:
|
||||
return user_playlist
|
||||
|
||||
# 2. Try External (YouTube Music)
|
||||
# Check Cache first
|
||||
cache_key = f"playlist:{id}"
|
||||
cached_playlist = cache.get(cache_key)
|
||||
if cached_playlist:
|
||||
return cached_playlist
|
||||
|
||||
try:
|
||||
from ytmusicapi import YTMusic
|
||||
yt = YTMusic()
|
||||
|
||||
playlist_data = None
|
||||
is_album = False
|
||||
|
||||
if id.startswith("MPREb"):
|
||||
try:
|
||||
playlist_data = yt.get_album(id)
|
||||
is_album = True
|
||||
except Exception as e:
|
||||
print(f"DEBUG: get_album(1) failed: {e}")
|
||||
pass
|
||||
|
||||
if not playlist_data:
|
||||
try:
|
||||
# ytmusicapi returns a dict with 'tracks' list
|
||||
playlist_data = yt.get_playlist(id, limit=100)
|
||||
except Exception as e:
|
||||
print(f"DEBUG: get_playlist failed: {e}")
|
||||
import traceback, sys
|
||||
traceback.print_exc(file=sys.stdout)
|
||||
# Fallback: Try as album if not tried yet
|
||||
if not is_album:
|
||||
try:
|
||||
playlist_data = yt.get_album(id)
|
||||
is_album = True
|
||||
except Exception as e2:
|
||||
print(f"DEBUG: get_album(2) failed: {e2}")
|
||||
traceback.print_exc(file=sys.stdout)
|
||||
raise e # Re-raise if both fail
|
||||
|
||||
if not isinstance(playlist_data, dict):
|
||||
print(f"DEBUG: Validation Failed! playlist_data type: {type(playlist_data)}", flush=True)
|
||||
raise ValueError(f"Invalid playlist_data: {playlist_data}")
|
||||
|
||||
# Format to match our app's Protocol
|
||||
formatted_tracks = []
|
||||
if 'tracks' in playlist_data:
|
||||
for track in playlist_data['tracks']:
|
||||
artist_names = extract_artist_names(track)
|
||||
|
||||
# Safely extract thumbnails
|
||||
thumbnails = track.get('thumbnails', [])
|
||||
if not thumbnails and is_album:
|
||||
# Albums sometimes have thumbnails at root level, not per track
|
||||
thumbnails = playlist_data.get('thumbnails', [])
|
||||
|
||||
cover_url = get_high_res_thumbnail(thumbnails)
|
||||
|
||||
# Safely extract album
|
||||
album_name = extract_album_name(track, playlist_data.get('title', 'Single'))
|
||||
|
||||
video_id = track.get('videoId')
|
||||
if not video_id:
|
||||
continue
|
||||
|
||||
formatted_tracks.append({
|
||||
"title": track.get('title', 'Unknown Title'),
|
||||
"artist": artist_names,
|
||||
"album": album_name,
|
||||
"duration": track.get('duration_seconds', track.get('length_seconds', 0)),
|
||||
"cover_url": cover_url,
|
||||
"id": video_id,
|
||||
"url": f"https://music.youtube.com/watch?v={video_id}"
|
||||
})
|
||||
|
||||
# Get Playlist Cover (usually highest res)
|
||||
thumbnails = playlist_data.get('thumbnails', [])
|
||||
p_cover = get_high_res_thumbnail(thumbnails)
|
||||
|
||||
# Safely extract author/artists
|
||||
author = "YouTube Music"
|
||||
if is_album:
|
||||
artists = playlist_data.get('artists', [])
|
||||
names = []
|
||||
for a in artists:
|
||||
if isinstance(a, dict): names.append(a.get('name', 'Unknown'))
|
||||
elif isinstance(a, str): names.append(a)
|
||||
author = ", ".join(names)
|
||||
else:
|
||||
author_data = playlist_data.get('author', {})
|
||||
if isinstance(author_data, dict):
|
||||
author = author_data.get('name', 'YouTube Music')
|
||||
else:
|
||||
author = str(author_data)
|
||||
|
||||
formatted_playlist = {
|
||||
"id": playlist_data.get('browseId', playlist_data.get('id')),
|
||||
"title": clean_title(playlist_data.get('title', 'Unknown')),
|
||||
"description": clean_description(playlist_data.get('description', '')),
|
||||
"author": author,
|
||||
"cover_url": p_cover,
|
||||
"tracks": formatted_tracks
|
||||
}
|
||||
|
||||
# Cache it (1 hr)
|
||||
cache.set(cache_key, formatted_playlist, ttl_seconds=3600)
|
||||
return formatted_playlist
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Playlist Fetch Error (NEW CODE): {e}", flush=True)
|
||||
print(traceback.format_exc(), flush=True)
|
||||
try:
|
||||
print(f"Playlist Data Type: {type(playlist_data)}")
|
||||
if 'tracks' in playlist_data and playlist_data['tracks']:
|
||||
print(f"First Track Type: {type(playlist_data['tracks'][0])}")
|
||||
except:
|
||||
pass
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
|
||||
class UpdatePlaylistRequest(BaseModel):
|
||||
name: str = None
|
||||
description: str = None
|
||||
|
||||
@router.put("/playlists/{id}")
|
||||
async def update_user_playlist(id: str, playlist: UpdatePlaylistRequest):
|
||||
updated = playlist_manager.update(id, name=playlist.name, description=playlist.description)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
return updated
|
||||
|
||||
class AddTrackRequest(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
cover_url: str
|
||||
duration: int = 0
|
||||
url: str = ""
|
||||
|
||||
@router.post("/playlists/{id}/tracks")
|
||||
async def add_track_to_playlist(id: str, track: AddTrackRequest):
|
||||
track_data = track.dict()
|
||||
success = playlist_manager.add_track(id, track_data)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_tracks(query: str):
|
||||
"""
|
||||
Search for tracks using ytmusicapi.
|
||||
"""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
# Check Cache
|
||||
cache_key = f"search:{query.lower().strip()}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
print(f"DEBUG: Returning cached search results for '{query}'")
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
from ytmusicapi import YTMusic
|
||||
yt = YTMusic()
|
||||
results = yt.search(query, filter="songs", limit=20)
|
||||
|
||||
tracks = []
|
||||
for track in results:
|
||||
artist_names = extract_artist_names(track)
|
||||
|
||||
# Safely extract thumbnails
|
||||
thumbnails = track.get('thumbnails', [])
|
||||
cover_url = get_high_res_thumbnail(thumbnails)
|
||||
|
||||
album_name = extract_album_name(track, "Single")
|
||||
|
||||
tracks.append({
|
||||
"title": track.get('title', 'Unknown Title'),
|
||||
"artist": artist_names,
|
||||
"album": album_name,
|
||||
"duration": track.get('duration_seconds', 0),
|
||||
"cover_url": cover_url,
|
||||
"id": track.get('videoId'),
|
||||
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
|
||||
})
|
||||
|
||||
response_data = {"tracks": tracks}
|
||||
# Cache for 24 hours (86400 seconds)
|
||||
cache.set(cache_key, response_data, ttl_seconds=86400)
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"Search Error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/recommendations")
|
||||
async def get_recommendations(seed_id: str = None):
|
||||
"""
|
||||
Get recommended tracks (Play History based or Trending).
|
||||
If seed_id is provided, fetches 'Up Next' / 'Radio' tracks for that video.
|
||||
"""
|
||||
try:
|
||||
from ytmusicapi import YTMusic
|
||||
yt = YTMusic()
|
||||
|
||||
if not seed_id:
|
||||
# Fallback to Trending if no history
|
||||
return await get_trending()
|
||||
|
||||
cache_key = f"rec:{seed_id}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Use get_watch_playlist to find similar tracks (Radio)
|
||||
watch_playlist = yt.get_watch_playlist(videoId=seed_id, limit=20)
|
||||
|
||||
tracks = []
|
||||
if 'tracks' in watch_playlist:
|
||||
seen_ids = set()
|
||||
seen_ids.add(seed_id)
|
||||
for track in watch_playlist['tracks']:
|
||||
# Skip if seen or seed
|
||||
t_id = track.get('videoId')
|
||||
if not t_id or t_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(t_id)
|
||||
|
||||
artist_names = extract_artist_names(track)
|
||||
|
||||
thumbnails = track.get('thumbnails') or track.get('thumbnail') or []
|
||||
cover_url = get_high_res_thumbnail(thumbnails)
|
||||
|
||||
album_name = extract_album_name(track, "Single")
|
||||
|
||||
tracks.append({
|
||||
"title": track.get('title', 'Unknown Title'),
|
||||
"artist": artist_names,
|
||||
"album": album_name,
|
||||
"duration": track.get('length_seconds', track.get('duration_seconds', 0)),
|
||||
"cover_url": cover_url,
|
||||
"id": t_id,
|
||||
"url": f"https://music.youtube.com/watch?v={t_id}"
|
||||
})
|
||||
|
||||
response_data = {"tracks": tracks}
|
||||
cache.set(cache_key, response_data, ttl_seconds=3600) # 1 hour cache
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"Recommendation Error: {e}")
|
||||
# Fallback to trending on error
|
||||
return await get_trending()
|
||||
|
||||
@router.get("/recommendations/albums")
|
||||
async def get_recommended_albums(seed_artist: str = None):
|
||||
"""
|
||||
Get recommended albums based on an artist query.
|
||||
"""
|
||||
if not seed_artist:
|
||||
return []
|
||||
|
||||
cache_key = f"rec_albums:{seed_artist.lower().strip()}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
from ytmusicapi import YTMusic
|
||||
yt = YTMusic()
|
||||
|
||||
# Search for albums by this artist
|
||||
results = yt.search(seed_artist, filter="albums", limit=10)
|
||||
|
||||
albums = []
|
||||
for album in results:
|
||||
thumbnails = album.get('thumbnails', [])
|
||||
cover_url = get_high_res_thumbnail(thumbnails)
|
||||
|
||||
albums.append({
|
||||
"title": album.get('title', 'Unknown Album'),
|
||||
"description": album.get('year', '') + " • " + album.get('artist', seed_artist),
|
||||
"cover_url": cover_url,
|
||||
"id": album.get('browseId'),
|
||||
"type": "Album"
|
||||
})
|
||||
|
||||
cache.set(cache_key, albums, ttl_seconds=86400)
|
||||
return albums
|
||||
|
||||
except Exception as e:
|
||||
print(f"Album Rec Error: {e}")
|
||||
return []
|
||||
|
||||
@router.get("/artist/info")
|
||||
async def get_artist_info(name: str):
|
||||
"""
|
||||
Get artist metadata (photo) by name.
|
||||
"""
|
||||
if not name:
|
||||
return {"photo": None}
|
||||
|
||||
cache_key = f"artist_info:{name.lower().strip()}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
from ytmusicapi import YTMusic
|
||||
yt = YTMusic()
|
||||
|
||||
results = yt.search(name, filter="artists", limit=1)
|
||||
if results:
|
||||
artist = results[0]
|
||||
thumbnails = artist.get('thumbnails', [])
|
||||
photo_url = get_high_res_thumbnail(thumbnails)
|
||||
result = {"photo": photo_url}
|
||||
|
||||
cache.set(cache_key, result, ttl_seconds=86400 * 7) # Cache for 1 week
|
||||
return result
|
||||
|
||||
return {"photo": None}
|
||||
except Exception as e:
|
||||
print(f"Artist Info Error: {e}")
|
||||
return {"photo": None}
|
||||
|
||||
@router.get("/trending")
|
||||
async def get_trending():
|
||||
"""
|
||||
Returns the pre-fetched Trending Vietnam playlist.
|
||||
"""
|
||||
try:
|
||||
data_path = Path("backend/data.json")
|
||||
if data_path.exists():
|
||||
with open(data_path, "r") as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
return {"error": "Trending data not found. Run fetch_data.py first."}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream_audio(id: str):
|
||||
"""
|
||||
Stream audio for a given YouTube video ID.
|
||||
Extracts direct URL via yt-dlp and streams it.
|
||||
"""
|
||||
try:
|
||||
# Check Cache for stream URL
|
||||
# Check Cache for stream URL
|
||||
cache_key = f"v10:stream:{id}" # v10 - web_creator client bypass
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
stream_url = None
|
||||
mime_type = "audio/mp4"
|
||||
|
||||
if cached_data:
|
||||
print(f"DEBUG: Using cached stream data for '{id}'")
|
||||
if isinstance(cached_data, dict):
|
||||
stream_url = cached_data.get('url')
|
||||
mime_type = cached_data.get('mime', 'audio/mp4')
|
||||
else:
|
||||
stream_url = cached_data # Legacy fallback
|
||||
|
||||
if not stream_url:
|
||||
print(f"DEBUG: Fetching new stream URL for '{id}'")
|
||||
url = f"https://www.youtube.com/watch?v={id}"
|
||||
ydl_opts = {
|
||||
# Try multiple formats, prefer webm which often works better
|
||||
'format': 'bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio/best',
|
||||
'quiet': False, # Enable output for debugging
|
||||
'noplaylist': True,
|
||||
'nocheckcertificate': True,
|
||||
'geo_bypass': True,
|
||||
'geo_bypass_country': 'US',
|
||||
'socket_timeout': 30,
|
||||
'retries': 5,
|
||||
'force_ipv4': True,
|
||||
# Try web_creator client which sometimes bypasses auth, fallback to ios/android
|
||||
'extractor_args': {'youtube': {'player_client': ['web_creator', 'ios', 'android', 'web']}},
|
||||
# Additional options to avoid bot detection
|
||||
'http_headers': {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
stream_url = info.get('url')
|
||||
ext = info.get('ext')
|
||||
http_headers = info.get('http_headers', {}) # Get headers required for the URL
|
||||
|
||||
# Determine MIME type
|
||||
if ext == 'm4a' or ext == 'mp4':
|
||||
mime_type = "audio/mp4"
|
||||
elif ext == 'webm':
|
||||
mime_type = "audio/webm"
|
||||
else:
|
||||
mime_type = "audio/mpeg"
|
||||
|
||||
print(f"DEBUG: Got stream URL format: {info.get('format')}, ext: {ext}, mime: {mime_type}", flush=True)
|
||||
except Exception as ydl_error:
|
||||
print(f"DEBUG: yt-dlp extraction error: {type(ydl_error).__name__}: {str(ydl_error)}", flush=True)
|
||||
raise ydl_error
|
||||
|
||||
if stream_url:
|
||||
cached_data = {"url": stream_url, "mime": mime_type, "headers": http_headers}
|
||||
cache.set(cache_key, cached_data, ttl_seconds=3600)
|
||||
|
||||
if not stream_url:
|
||||
raise HTTPException(status_code=404, detail="Audio stream not found")
|
||||
|
||||
print(f"Streaming {id} with Content-Type: {mime_type}", flush=True)
|
||||
|
||||
# Pre-open the connection to verify it works and get headers
|
||||
try:
|
||||
# Sanitize headers: prevent Host/Cookie conflicts, but keep User-Agent and Cookies
|
||||
base_headers = {}
|
||||
if 'http_headers' in locals():
|
||||
base_headers = http_headers
|
||||
elif cached_data and isinstance(cached_data, dict):
|
||||
base_headers = cached_data.get('headers', {})
|
||||
|
||||
req_headers = {
|
||||
'User-Agent': base_headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'),
|
||||
'Referer': 'https://www.youtube.com/',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': base_headers.get('Accept-Language', 'en-US,en;q=0.9'),
|
||||
}
|
||||
if 'Cookie' in base_headers:
|
||||
req_headers['Cookie'] = base_headers['Cookie']
|
||||
|
||||
# Disable SSL verify to match yt-dlp 'nocheckcertificate' (fixes NAS CA issues)
|
||||
external_req = requests.get(stream_url, stream=True, timeout=30, headers=req_headers, verify=False)
|
||||
external_req.raise_for_status()
|
||||
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
error_details = f"Upstream error: {http_err.response.status_code}"
|
||||
print(f"Stream Error: {error_details}")
|
||||
# If 403/404/410, invalidate cache
|
||||
if http_err.response.status_code in [403, 404, 410]:
|
||||
cache.delete(cache_key)
|
||||
raise HTTPException(status_code=500, detail=error_details)
|
||||
except Exception as e:
|
||||
print(f"Stream Connection Error: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Stream connection failed: {str(e)}")
|
||||
|
||||
# Forward Content-Length if available
|
||||
headers = {}
|
||||
if "Content-Length" in external_req.headers:
|
||||
headers["Content-Length"] = external_req.headers["Content-Length"]
|
||||
|
||||
def iterfile():
|
||||
try:
|
||||
# Use the already open request
|
||||
for chunk in external_req.iter_content(chunk_size=64*1024):
|
||||
yield chunk
|
||||
external_req.close()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return StreamingResponse(iterfile(), media_type=mime_type, headers=headers)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Stream Error for ID '{id}': {type(e).__name__}: {str(e)}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Stream error: {type(e).__name__}: {str(e)}")
|
||||
|
||||
@router.get("/download")
|
||||
async def download_audio(id: str, title: str = "audio"):
|
||||
"""
|
||||
Download audio for a given YouTube video ID.
|
||||
Proxies the stream content as a file attachment.
|
||||
"""
|
||||
try:
|
||||
# Check Cache for stream URL
|
||||
cache_key = f"stream:{id}"
|
||||
cached_url = cache.get(cache_key)
|
||||
|
||||
stream_url = None
|
||||
if cached_url:
|
||||
stream_url = cached_url
|
||||
else:
|
||||
url = f"https://www.youtube.com/watch?v={id}"
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio/best',
|
||||
'quiet': True,
|
||||
'noplaylist': True,
|
||||
}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
stream_url = info.get('url')
|
||||
|
||||
if stream_url:
|
||||
cache.set(cache_key, stream_url, ttl_seconds=3600)
|
||||
|
||||
if not stream_url:
|
||||
raise HTTPException(status_code=404, detail="Audio stream not found")
|
||||
|
||||
# Stream the content with attachment header
|
||||
def iterfile():
|
||||
with requests.get(stream_url, stream=True) as r:
|
||||
r.raise_for_status()
|
||||
for chunk in r.iter_content(chunk_size=1024*1024):
|
||||
yield chunk
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_')]).strip()
|
||||
headers = {
|
||||
"Content-Disposition": f'attachment; filename="{safe_filename}.mp3"'
|
||||
}
|
||||
|
||||
return StreamingResponse(iterfile(), media_type="audio/mpeg", headers=headers)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Download Error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/lyrics")
|
||||
async def get_lyrics(id: str, title: str = None, artist: str = None):
|
||||
"""
|
||||
Fetch synchronized lyrics using multiple providers hierarchy:
|
||||
1. Cache (fastest)
|
||||
2. yt-dlp (Original Video Captions - best sync for exact video)
|
||||
3. LRCLIB (Open Source Database - good fuzzy match)
|
||||
4. syncedlyrics (Musixmatch/NetEase Aggregator - widest coverage)
|
||||
"""
|
||||
if not id:
|
||||
return []
|
||||
|
||||
cache_key = f"lyrics:{id}"
|
||||
cached_lyrics = cache.get(cache_key)
|
||||
if cached_lyrics:
|
||||
return cached_lyrics
|
||||
|
||||
parsed_lines = []
|
||||
|
||||
# Run heavy IO in threadpool
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
import syncedlyrics
|
||||
|
||||
try:
|
||||
# --- Strategy 1: yt-dlp (Official Captions) ---
|
||||
def fetch_ytdlp_subs():
|
||||
parsed = []
|
||||
try:
|
||||
lyrics_dir = CACHE_DIR / "lyrics"
|
||||
lyrics_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_tmpl = str(lyrics_dir / f"{id}")
|
||||
ydl_opts = {
|
||||
'skip_download': True, 'writesubtitles': True, 'writeautomaticsub': True,
|
||||
'subtitleslangs': ['en', 'vi'], 'subtitlesformat': 'json3',
|
||||
'outtmpl': out_tmpl, 'quiet': True
|
||||
}
|
||||
url = f"https://www.youtube.com/watch?v={id}"
|
||||
import glob
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([url])
|
||||
|
||||
pattern = str(lyrics_dir / f"{id}.*.json3")
|
||||
found_files = glob.glob(pattern)
|
||||
if found_files:
|
||||
best_file = next((f for f in found_files if f.endswith(f"{id}.en.json3")), found_files[0])
|
||||
with open(best_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for event in data.get('events', []):
|
||||
if 'segs' in event and 'tStartMs' in event:
|
||||
text = "".join([s.get('utf8', '') for s in event['segs']]).strip()
|
||||
if text and not text.startswith('[') and text != '\n':
|
||||
parsed.append({"time": float(event['tStartMs']) / 1000.0, "text": text})
|
||||
except Exception as e:
|
||||
print(f"yt-dlp sub error: {e}")
|
||||
return parsed
|
||||
|
||||
parsed_lines = await run_in_threadpool(fetch_ytdlp_subs)
|
||||
|
||||
# --- Strategy 2: LRCLIB (Search API) ---
|
||||
if not parsed_lines and title and artist:
|
||||
print(f"Trying LRCLIB Search for: {title} {artist}")
|
||||
def fetch_lrclib():
|
||||
try:
|
||||
# Fuzzy match using search, not get
|
||||
cleaned_title = re.sub(r'\(.*?\)', '', title)
|
||||
clean_query = f"{artist} {cleaned_title}".strip()
|
||||
resp = requests.get("https://lrclib.net/api/search", params={"q": clean_query}, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
results = resp.json()
|
||||
# Find first result with synced lyrics
|
||||
for item in results:
|
||||
if item.get("syncedLyrics"):
|
||||
return parse_lrc_string(item["syncedLyrics"])
|
||||
except Exception as e:
|
||||
print(f"LRCLIB error: {e}")
|
||||
return []
|
||||
|
||||
parsed_lines = await run_in_threadpool(fetch_lrclib)
|
||||
|
||||
# --- Strategy 3: syncedlyrics (Aggregator) ---
|
||||
if not parsed_lines and title and artist:
|
||||
print(f"Trying SyncedLyrics Aggregator for: {title} {artist}")
|
||||
def fetch_syncedlyrics():
|
||||
try:
|
||||
# syncedlyrics.search returns the LRC string or None
|
||||
clean_query = f"{title} {artist}".strip()
|
||||
lrc_str = syncedlyrics.search(clean_query)
|
||||
if lrc_str:
|
||||
return parse_lrc_string(lrc_str)
|
||||
except Exception as e:
|
||||
print(f"SyncedLyrics error: {e}")
|
||||
return []
|
||||
|
||||
parsed_lines = await run_in_threadpool(fetch_syncedlyrics)
|
||||
|
||||
# Cache Result
|
||||
if parsed_lines:
|
||||
cache.set(cache_key, parsed_lines, ttl_seconds=86400 * 30)
|
||||
return parsed_lines
|
||||
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"Global Lyrics Error: {e}")
|
||||
return []
|
||||
|
||||
def parse_lrc_string(lrc_content: str):
|
||||
"""Parses LRC format string into [{time, text}]"""
|
||||
lines = []
|
||||
if not lrc_content: return lines
|
||||
for line in lrc_content.split('\n'):
|
||||
# Format: [mm:ss.xx] Text
|
||||
match = re.search(r'\[(\d+):(\d+\.?\d*)\](.*)', line)
|
||||
if match:
|
||||
minutes = float(match.group(1))
|
||||
seconds = float(match.group(2))
|
||||
text = match.group(3).strip()
|
||||
total_time = minutes * 60 + seconds
|
||||
if text:
|
||||
lines.append({"time": total_time, "text": text})
|
||||
return lines
|
||||
|
|
@ -1 +0,0 @@
|
|||
[]
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import json
|
||||
import time
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
class CacheManager:
|
||||
def __init__(self, cache_dir: str = "backend/cache"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_path(self, key: str) -> Path:
|
||||
# Create a safe filename from the key
|
||||
hashed_key = hashlib.md5(key.encode()).hexdigest()
|
||||
return self.cache_dir / f"{hashed_key}.json"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Retrieve data from cache if it exists and hasn't expired.
|
||||
"""
|
||||
path = self._get_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check TTL
|
||||
if data["expires_at"] < time.time():
|
||||
# Expired, delete it
|
||||
path.unlink()
|
||||
return None
|
||||
|
||||
return data["value"]
|
||||
except (json.JSONDecodeError, KeyError, OSError):
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl_seconds: int = 3600):
|
||||
"""
|
||||
Save data to cache with a TTL (default 1 hour).
|
||||
"""
|
||||
path = self._get_path(key)
|
||||
data = {
|
||||
"value": value,
|
||||
"expires_at": time.time() + ttl_seconds,
|
||||
"key_debug": key # Store original key for debugging
|
||||
}
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
except OSError as e:
|
||||
print(f"Cache Write Error: {e}")
|
||||
|
|
@ -1,450 +0,0 @@
|
|||
{
|
||||
"id": "VLPLm_fM7dlkg8FbRVCCosRFtDldS74OVSgi",
|
||||
"title": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"description": "THE * VIRAL 50 * https://bit.ly/3SH4lrf\n*****************************************\n*****************************************\nIf u like our playlist, please save it. \nThis playlist is updated weekly, so stay with us for more new hit songs...\n\n*****************************************\n************ 1 HOUR LOOPS ************\nRocket-Media * 1 HOUR LOOPS: https://bit.ly/39T7uj9\nRocket-Media * 1 HOUR LOOPS * HITS: https://bit.ly/3dFFJvw\nRocket-Media * 1 HOUR LOOPS * DANCE: https://bit.ly/3urhN5M\nRocket-Media * 1 HOUR LOOPS * ROCKS: https://bit.ly/3wF8L76\nRocket-Media * 1 HOUR LOOPS * CLASSIC: https://bit.ly/3t375lH\n\n*****************************************\n************** PLAYLISTS **************\nFavorite brobeat playlist: https://www.youtube.com/playlist?list...\nDeezer Playlist: https://www.deezer.com/en/profile/533...\nRocket-Media MTV Dance: https://www.youtube.com/playlist?list...\nRocket-Media MTV Hits: https://www.youtube.com/playlist?list...\nRocket-Media MTV Rocks: https://www.youtube.com/playlist?list...\nRocket-Media MTV Classic: https://www.youtube.com/playlist?list...\nRocket-Media Concerts UltraHD 4K: https://www.youtube.com/playlist?list...\nRocket-Media Mixes UltraHD 4K: https://www.youtube.com/playlist?list...\n\n*****************************************\n*****************************************\n\n************** YOUTUBE **************\n*** Central * Rocket-Media * Central ***\nhttps://www.youtube.com/channel/UCkN5...\n************** YOUTUBE **************\n*****************************************\n01. brobeat: https://www.youtube.com/channel/UC8_2...\n02. remixit: https://www.youtube.com/channel/UCvY5...\n03. remixit * greek: https://www.youtube.com/channel/UC0nw...\n04. Liverpool F.C. Legends: https://www.youtube.com/channel/UCq0J...\n05. Juventus F.C. Legends: https://www.youtube.com/channel/UC6JR...\n06. Chicago Bulls Legends: https://www.youtube.com/channel/UC9hs...\n07. Boston Celtics Legends: https://www.youtube.com/channel/UC83o...\n08. Miami Heat Legends: https://www.youtube.com/channel/UCSrh...\n09. Trail Blazers Legends: https://www.youtube.com/channel/UCw_2...\n10. OKC Thunder Legends: https://www.youtube.com/channel/UCa2y...\n11. Rockets Legends: https://www.youtube.com/channel/UCK8U...\n\n*****************************************\n*****************************************\n\n*** Central * Rocket-Media * Central ***\nhttps://www.youtube.com/channel/UCkN5...\n*****************************************\nViral Instagram Hashtags: https://www.instagram.com/goviralhash...\n*****************************************\nFavorite brobeat playlist: https://www.youtube.com/playlist?list...\nDeezer Playlist: https://www.deezer.com/en/profile/533...\n************** FACEBOOK **************\n01. https://www.facebook.com/rocketmediaw...\n02. https://www.facebook.com/TheViralRock...\n************* INSTAGRAM *************\n01. https://www.instagram.com/brobeat72/\n02. https://www.instagram.com/remixit72/\n*************** TWITTER ***************\n01. https://twitter.com/brobeat72\n02. https://twitter.com/remixit72\n************** PINTEREST **************\nhttps://pinterest.com/brobeat72/\n*************** TUMBLR ***************\nhttps://rocketmedia72.tumblr.com/\n\n*****************************************\n*****************************************\n\ntop vietnamese songs\nvietnam music charts\nvietnam music hits\nvietnam top music charts\nvietnamese music channel\nvietnamese music charts\n2012 chart music\n4music charts\namazon world music chart\nasian music charts\nbest chart music\nbest international music\nbest music usa\nbest music world\nbest songs charts\nbest songs us charts\nbest world music songs\nbiggest music singles\nbillboard charts worldwide\nbillboard world album\nbillboard world album charts\nbillboard world charts\nbillboard world music\nbillboard world music album chart\nbillboard world music chart internet charts\nceltic thunder billboard world music chart\nchart list music\nchart mtv music\nchart music 2014\nchart music chart music\nchart music free online\nchart official music\nchart official us music\nchart song usa\nchart songs\nchartmusic\ncharts all\ncharts all over\ncharts around the world\ncharts around world\ncharts over world\ncharts usa\nchina chart music\nchinese chart music\nclassical music charts\nenglish music chart\nenglish music top chart",
|
||||
"cover_url": "https://yt3.googleusercontent.com/eEjGg43BajytnnS5S0gBc_rhXXhFLBLU-e8QF4jNMxX4W2oqmBhdd5uBzuTu11X5bRqpcX2hHw4=s1200",
|
||||
"tracks": [
|
||||
{
|
||||
"title": "Who",
|
||||
"artist": "Jimin",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 208,
|
||||
"cover_url": "https://i.ytimg.com/vi/Av9DvtlJ9_M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lo74Sku93WaUE-8yH-iP6Zw_M7Uw",
|
||||
"id": "Av9DvtlJ9_M",
|
||||
"url": "https://music.youtube.com/watch?v=Av9DvtlJ9_M"
|
||||
},
|
||||
{
|
||||
"title": "Seven (feat. Latto)",
|
||||
"artist": "Jung Kook",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 227,
|
||||
"cover_url": "https://i.ytimg.com/vi/QU9c0053UAU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3ki23gt683kPaAs_tipdild1wrLTQ",
|
||||
"id": "QU9c0053UAU",
|
||||
"url": "https://music.youtube.com/watch?v=QU9c0053UAU"
|
||||
},
|
||||
{
|
||||
"title": "M\u1ed8NG YU - AMEE x MCK | Official Music Video (from \u2018M\u1ed8NGMEE\u2019 album)",
|
||||
"artist": "AMEE",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 284,
|
||||
"cover_url": "https://i.ytimg.com/vi/09Mh7GgUFFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kOfLMsTFC8tE0kNUwRjBLFPok5BQ",
|
||||
"id": "09Mh7GgUFFA",
|
||||
"url": "https://music.youtube.com/watch?v=09Mh7GgUFFA"
|
||||
},
|
||||
{
|
||||
"title": "Die With A Smile",
|
||||
"artist": "Lady Gaga, Bruno Mars",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 253,
|
||||
"cover_url": "https://i.ytimg.com/vi/kPa7bsKwL-c/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m0dtEFyfTFfbWA9Qj84zDLREtdUw",
|
||||
"id": "kPa7bsKwL-c",
|
||||
"url": "https://music.youtube.com/watch?v=kPa7bsKwL-c"
|
||||
},
|
||||
{
|
||||
"title": "Standing Next to You",
|
||||
"artist": "Jung Kook",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 227,
|
||||
"cover_url": "https://i.ytimg.com/vi/UNo0TG9LwwI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3myS1MPOlFVYv7S9qH2lm9wln3RlQ",
|
||||
"id": "UNo0TG9LwwI",
|
||||
"url": "https://music.youtube.com/watch?v=UNo0TG9LwwI"
|
||||
},
|
||||
{
|
||||
"title": "REGRET - LYRICS",
|
||||
"artist": "VieShows",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 541,
|
||||
"cover_url": "https://i.ytimg.com/vi/LWzxnYB8K08/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m_mMidsikFjBFNkEKN7eK6Bl76iA",
|
||||
"id": "LWzxnYB8K08",
|
||||
"url": "https://music.youtube.com/watch?v=LWzxnYB8K08"
|
||||
},
|
||||
{
|
||||
"title": "NG\u00c1O NG\u01a0- LYRICS | ANH TRAI SAY HI",
|
||||
"artist": "VieShows",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 541,
|
||||
"cover_url": "https://i.ytimg.com/vi/LvWPPjJE-uE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kSDz3hkRb9VfKFGPxHHUaR9bKzOA",
|
||||
"id": "LvWPPjJE-uE",
|
||||
"url": "https://music.youtube.com/watch?v=LvWPPjJE-uE"
|
||||
},
|
||||
{
|
||||
"title": "\u0110\u1eebng L\u00e0m Tr\u00e1i Tim Anh \u0110au",
|
||||
"artist": "S\u01a1n T\u00f9ng M-TP",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 326,
|
||||
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nMzmdGlrfqmf8o9z-E5waTnqFXxA",
|
||||
"id": "abPmZCZZrFA",
|
||||
"url": "https://music.youtube.com/watch?v=abPmZCZZrFA"
|
||||
},
|
||||
{
|
||||
"title": "H\u00c0O QUANG (feat. RHYDER, D\u01af\u01a0NG DOMIC & PH\u00c1P KI\u1ec0U)",
|
||||
"artist": "ANH TRAI \"SAY HI\"",
|
||||
"album": "T\u1eacP 5 - ANH TRAI \"SAY HI\"",
|
||||
"duration": 246,
|
||||
"cover_url": "https://lh3.googleusercontent.com/peH3Ubcoqxirb5EQxA-E0DkZAmQGZX5AiDpBA3Ow6sFUhNcfIAOLJbMzqpL8lGNBAFvEYoeD5xBt-lk=w120-h120-l90-rj",
|
||||
"id": "TMUWRIau4PU",
|
||||
"url": "https://music.youtube.com/watch?v=TMUWRIau4PU"
|
||||
},
|
||||
{
|
||||
"title": "Love Me Again",
|
||||
"artist": "V",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 198,
|
||||
"cover_url": "https://i.ytimg.com/vi/HYzyRHAHJl8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nc6Ad1QlMZEkDRZyM8qr4euI4KtQ",
|
||||
"id": "HYzyRHAHJl8",
|
||||
"url": "https://music.youtube.com/watch?v=HYzyRHAHJl8"
|
||||
},
|
||||
{
|
||||
"title": "2 4 - w/n (3107 - 2024) (LYRICS)",
|
||||
"artist": "Nghe nh\u1ea1c Official",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 194,
|
||||
"cover_url": "https://i.ytimg.com/vi/M7KlePwgtE0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mR1KhgL-_ug9euybHCS0MFHfuuLA",
|
||||
"id": "M7KlePwgtE0",
|
||||
"url": "https://music.youtube.com/watch?v=M7KlePwgtE0"
|
||||
},
|
||||
{
|
||||
"title": "Sau C\u01a1n M\u01b0a",
|
||||
"artist": "COOLKID , RHYDER",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 154,
|
||||
"cover_url": "https://i.ytimg.com/vi/iFoLKvdqXk8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lWsLcVGvFYQwQYKj0QNo-6ZE-19g",
|
||||
"id": "iFoLKvdqXk8",
|
||||
"url": "https://music.youtube.com/watch?v=iFoLKvdqXk8"
|
||||
},
|
||||
{
|
||||
"title": "Exit Sign",
|
||||
"artist": "HIEUTHUHAI",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 202,
|
||||
"cover_url": "https://i.ytimg.com/vi/sJt_i0hOugA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lcKx6N9oT-jvflhlil6p0IMK4WQA",
|
||||
"id": "sJt_i0hOugA",
|
||||
"url": "https://music.youtube.com/watch?v=sJt_i0hOugA"
|
||||
},
|
||||
{
|
||||
"title": "I'M THINKING ABOUT YOU (feat. RHYDER, WEAN, \u0110\u1ee8C PH\u00daC & H\u00d9NG HU\u1ef2NH)",
|
||||
"artist": "ANH TRAI \"SAY HI\"",
|
||||
"album": "T\u1eacP 8 - ANH TRAI \"SAY HI\"",
|
||||
"duration": 279,
|
||||
"cover_url": "https://lh3.googleusercontent.com/248xlXUpIjHgrxGAJwcVxUnRobbqWPo2kbO7byupciekLxOE3ZfL854mWNqB1Bq_aGLwp6hASXknS-Cc=w120-h120-l90-rj",
|
||||
"id": "C2d6C89Erb8",
|
||||
"url": "https://music.youtube.com/watch?v=C2d6C89Erb8"
|
||||
},
|
||||
{
|
||||
"title": "Cu\u1ed9c g\u1ecdi l\u00fac n\u1eeda \u0111\u00eam",
|
||||
"artist": "AMEE",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 183,
|
||||
"cover_url": "https://i.ytimg.com/vi/D64So_vDEZI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nuD-dfRwbfHx70IqrX06Hxwojg3g",
|
||||
"id": "D64So_vDEZI",
|
||||
"url": "https://music.youtube.com/watch?v=D64So_vDEZI"
|
||||
},
|
||||
{
|
||||
"title": "CH\u00c2N TH\u00c0NH (feat. RHYDER, CAPTAIN, QUANG H\u00d9NG MASTERD & WEAN)",
|
||||
"artist": "ANH TRAI \"SAY HI\"",
|
||||
"album": "T\u1eacP 10 - ANH TRAI \"SAY HI\"",
|
||||
"duration": 258,
|
||||
"cover_url": "https://lh3.googleusercontent.com/mcirwaIBLC_TC6bqKsR0YRNIgm64b8FiZNyXEX4fYjplFbLVsJBTTVh9q6xsyjeyoQC9_cAV1NG6YKA=w120-h120-l90-rj",
|
||||
"id": "jMjp_GWggOA",
|
||||
"url": "https://music.youtube.com/watch?v=jMjp_GWggOA"
|
||||
},
|
||||
{
|
||||
"title": "Kh\u00f4ng Th\u1ec3 Say",
|
||||
"artist": "HIEUTHUHAI",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 261,
|
||||
"cover_url": "https://i.ytimg.com/vi/i0nd3NPJ4MI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kpxPJNBIFUrjPyNXj9KSzyZhPacA",
|
||||
"id": "i0nd3NPJ4MI",
|
||||
"url": "https://music.youtube.com/watch?v=i0nd3NPJ4MI"
|
||||
},
|
||||
{
|
||||
"title": "Ch\u1ecbu c\u00e1ch m\u00ecnh n\u00f3i thua",
|
||||
"artist": "COOLKID , RHYDER, BAN",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 193,
|
||||
"cover_url": "https://i.ytimg.com/vi/dm5-tn1Rug0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nFnPXT7foRtqF89FTZzWrkrdGqoA",
|
||||
"id": "dm5-tn1Rug0",
|
||||
"url": "https://music.youtube.com/watch?v=dm5-tn1Rug0"
|
||||
},
|
||||
{
|
||||
"title": "id thang m\u00e1y (feat. 267)",
|
||||
"artist": "W/n",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 132,
|
||||
"cover_url": "https://i.ytimg.com/vi/qDE-veU-roI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mLGvve83scPNVS2RWf0J8FE2eQIQ",
|
||||
"id": "qDE-veU-roI",
|
||||
"url": "https://music.youtube.com/watch?v=qDE-veU-roI"
|
||||
},
|
||||
{
|
||||
"title": "BADBYE",
|
||||
"artist": "WEAN",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 213,
|
||||
"cover_url": "https://i.ytimg.com/vi/yhWCh5IVE04/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m7JjyuA67IUsHF0vJMVhZ6ctvNgw",
|
||||
"id": "yhWCh5IVE04",
|
||||
"url": "https://music.youtube.com/watch?v=yhWCh5IVE04"
|
||||
},
|
||||
{
|
||||
"title": "LOU HO\u00c0NG - NG\u00c0Y \u0110\u1eb8P TR\u1edcI \u0110\u1ec2 N\u00d3I CHIA TAY (Official Music Video)",
|
||||
"artist": "Lou Ho\u00e0ng",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 231,
|
||||
"cover_url": "https://i.ytimg.com/vi/0xAW6MAT_Wo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nPE9mrfq0gILpgA16nZDyF_f41lQ",
|
||||
"id": "0xAW6MAT_Wo",
|
||||
"url": "https://music.youtube.com/watch?v=0xAW6MAT_Wo"
|
||||
},
|
||||
{
|
||||
"title": "Nh\u1eafn nh\u1ee7 | Ronboogz (Lyrics video)",
|
||||
"artist": "Ronboogz",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 242,
|
||||
"cover_url": "https://i.ytimg.com/vi/vfKiaXKO44M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n381KndVeEVyU0CF_eZqSMS-QR6g",
|
||||
"id": "vfKiaXKO44M",
|
||||
"url": "https://music.youtube.com/watch?v=vfKiaXKO44M"
|
||||
},
|
||||
{
|
||||
"title": "id 072019",
|
||||
"artist": "W/n",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 303,
|
||||
"cover_url": "https://i.ytimg.com/vi/leJb3VhQCrg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWBTudc9VK3UqnpCgc_j8QYH3ugg",
|
||||
"id": "leJb3VhQCrg",
|
||||
"url": "https://music.youtube.com/watch?v=leJb3VhQCrg"
|
||||
},
|
||||
{
|
||||
"title": "Chuy\u1ec7n \u0110\u00f4i Ta - Emcee L (Da LAB) ft Mu\u1ed9ii (Official MV)",
|
||||
"artist": "Da LAB",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 226,
|
||||
"cover_url": "https://i.ytimg.com/vi/6eONmnFB9sw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mVwjdT_-mZ2QAlwE7xqAKAQAVAlA",
|
||||
"id": "6eONmnFB9sw",
|
||||
"url": "https://music.youtube.com/watch?v=6eONmnFB9sw"
|
||||
},
|
||||
{
|
||||
"title": "MI\u00caN MAN",
|
||||
"artist": "Minh Huy",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 205,
|
||||
"cover_url": "https://i.ytimg.com/vi/7uX_f8YzEiI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mNH-OfD4NzEJA1co0Lc1fikXUKpw",
|
||||
"id": "7uX_f8YzEiI",
|
||||
"url": "https://music.youtube.com/watch?v=7uX_f8YzEiI"
|
||||
},
|
||||
{
|
||||
"title": "CH\u00daNG TA C\u1ee6A T\u01af\u01a0NG LAI",
|
||||
"artist": "S\u01a1n T\u00f9ng M-TP",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 277,
|
||||
"cover_url": "https://i.ytimg.com/vi/zoEtcR5EW08/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lv4_jfNfESnK0mh8F5gKgJ7h1vUw",
|
||||
"id": "zoEtcR5EW08",
|
||||
"url": "https://music.youtube.com/watch?v=zoEtcR5EW08"
|
||||
},
|
||||
{
|
||||
"title": "NOLOVENOLIFE",
|
||||
"artist": "HIEUTHUHAI",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 172,
|
||||
"cover_url": "https://i.ytimg.com/vi/F084mTHtBpI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhKw7o_AGrSUJzBbFcDXst7hK2jA",
|
||||
"id": "F084mTHtBpI",
|
||||
"url": "https://music.youtube.com/watch?v=F084mTHtBpI"
|
||||
},
|
||||
{
|
||||
"title": "Mo",
|
||||
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 334,
|
||||
"cover_url": "https://i.ytimg.com/vi/2YM4j-oP_qQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lzpPjF9OCGDVvOwuwbLtyGa7Pi4A",
|
||||
"id": "2YM4j-oP_qQ",
|
||||
"url": "https://music.youtube.com/watch?v=2YM4j-oP_qQ"
|
||||
},
|
||||
{
|
||||
"title": "WEAN \u2013 shhhhhhh.. feat tlinh (Official Lyrics Video)",
|
||||
"artist": "WEAN",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 238,
|
||||
"cover_url": "https://i.ytimg.com/vi/Pys2iOT9rpw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mXx9k9v4Z61a5lDYIb6s5wJ829XQ",
|
||||
"id": "Pys2iOT9rpw",
|
||||
"url": "https://music.youtube.com/watch?v=Pys2iOT9rpw"
|
||||
},
|
||||
{
|
||||
"title": "HURRYKNG, HIEUTHUHAI, MANBO | H\u1eb9n G\u1eb7p Em D\u01b0\u1edbi \u00c1nh Tr\u0103ng | Official Video",
|
||||
"artist": "GERDNANG",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 232,
|
||||
"cover_url": "https://i.ytimg.com/vi/dLmczwDCEZI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kSMrrgkOYzN73RZZ9YG9WoUKG5xg",
|
||||
"id": "dLmczwDCEZI",
|
||||
"url": "https://music.youtube.com/watch?v=dLmczwDCEZI"
|
||||
},
|
||||
{
|
||||
"title": "Obito - H\u00e0 N\u1ed9i ft. VSTRA",
|
||||
"artist": "Obito",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 166,
|
||||
"cover_url": "https://i.ytimg.com/vi/OerAX-zKyvg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nO_yPfoJxp7C-FpHpc8p9sN47q1A",
|
||||
"id": "OerAX-zKyvg",
|
||||
"url": "https://music.youtube.com/watch?v=OerAX-zKyvg"
|
||||
},
|
||||
{
|
||||
"title": "LOVE SAND (feat. HIEUTHUHAI, JSOL, ALI HO\u00c0NG D\u01af\u01a0NG & V\u0168 TH\u1ecaNH)",
|
||||
"artist": "ANH TRAI \"SAY HI\"",
|
||||
"album": "T\u1eacP 4 - ANH TRAI \"SAY HI\"",
|
||||
"duration": 236,
|
||||
"cover_url": "https://lh3.googleusercontent.com/OWVfxVgRseYQVQcPzWcQ1bHhiYSfCxxiMqK5HDH7JXFdbRoo9RNr2-YbjdSwBGjk3Cz5l9DAetYOprVG=w120-h120-l90-rj",
|
||||
"id": "cSjF9UkTWqg",
|
||||
"url": "https://music.youtube.com/watch?v=cSjF9UkTWqg"
|
||||
},
|
||||
{
|
||||
"title": "An Th\u1ea7n (ft. Th\u1eafng) | Low G | Rap Nh\u00e0 L\u00e0m",
|
||||
"artist": "Rap Nh\u00e0 L\u00e0m",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 216,
|
||||
"cover_url": "https://i.ytimg.com/vi/J7eYhM6wXPo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kPN37KoE_QGfGty9ZPpJj2tYxa0A",
|
||||
"id": "J7eYhM6wXPo",
|
||||
"url": "https://music.youtube.com/watch?v=J7eYhM6wXPo"
|
||||
},
|
||||
{
|
||||
"title": "T\u1eebng quen",
|
||||
"artist": "itsnk, Wren Evans",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 175,
|
||||
"cover_url": "https://i.ytimg.com/vi/zepHPnUDROE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kQphjp4tiW4vFcaXJBk1wMtsk9Kg",
|
||||
"id": "zepHPnUDROE",
|
||||
"url": "https://music.youtube.com/watch?v=zepHPnUDROE"
|
||||
},
|
||||
{
|
||||
"title": "T\u1eebng L\u00e0",
|
||||
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 277,
|
||||
"cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw",
|
||||
"id": "i4qZmKSFYvI",
|
||||
"url": "https://music.youtube.com/watch?v=i4qZmKSFYvI"
|
||||
},
|
||||
{
|
||||
"title": "CATCH ME IF YOU CAN - NEGAV x Quang H\u00f9ng MasterD x Nicky x C\u00f4ng D\u01b0\u01a1ng | ANH TRAI SAY HI",
|
||||
"artist": "Negav",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 282,
|
||||
"cover_url": "https://i.ytimg.com/vi/WUbTGHzxeDI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3njQTKSHjLv_2NRhqxzSlOiwryGAA",
|
||||
"id": "WUbTGHzxeDI",
|
||||
"url": "https://music.youtube.com/watch?v=WUbTGHzxeDI"
|
||||
},
|
||||
{
|
||||
"title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean",
|
||||
"artist": "V\u0169., Dear Jane",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 259,
|
||||
"cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw",
|
||||
"id": "h6RONxjPBf4",
|
||||
"url": "https://music.youtube.com/watch?v=h6RONxjPBf4"
|
||||
},
|
||||
{
|
||||
"title": "m\u1ed9t \u0111\u1eddi (feat. buitruonglinh)",
|
||||
"artist": "Bon Nghi\u00eam, 14 Casper",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 329,
|
||||
"cover_url": "https://i.ytimg.com/vi/JgTZvDbaTtg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lEKS8TNud8_GWknaWc0IQEQWBTgw",
|
||||
"id": "JgTZvDbaTtg",
|
||||
"url": "https://music.youtube.com/watch?v=JgTZvDbaTtg"
|
||||
},
|
||||
{
|
||||
"title": "puppy & @Dangrangto - Wrong Times ( ft. FOWLEX Snowz ) [OFFICIAL LYRICS VIDEO]",
|
||||
"artist": "Ocean Waves",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 214,
|
||||
"cover_url": "https://i.ytimg.com/vi/O3pj32O5WN4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kIT8pcPmY-AZV1W4Znp0b8wlgdoQ",
|
||||
"id": "O3pj32O5WN4",
|
||||
"url": "https://music.youtube.com/watch?v=O3pj32O5WN4"
|
||||
},
|
||||
{
|
||||
"title": "Sinh ra \u0111\u00e3 l\u00e0 th\u1ee9 \u0111\u1ed1i l\u1eadp nhau (feat. Badbies)",
|
||||
"artist": "Emcee L",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 248,
|
||||
"cover_url": "https://i.ytimg.com/vi/redFrGBZoJY/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kHwvlcYNZc52dc1dMwoR7acMguXQ",
|
||||
"id": "redFrGBZoJY",
|
||||
"url": "https://music.youtube.com/watch?v=redFrGBZoJY"
|
||||
},
|
||||
{
|
||||
"title": "Ch\u1ea1y Kh\u1ecfi Th\u1ebf Gi\u1edbi N\u00e0y (Instrumental)",
|
||||
"artist": "Da LAB",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 284,
|
||||
"cover_url": "https://i.ytimg.com/vi/hYYMF3VtOjE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3naDMhRqjY3rGBYbSzMd7JJzIVYtg",
|
||||
"id": "hYYMF3VtOjE",
|
||||
"url": "https://music.youtube.com/watch?v=hYYMF3VtOjE"
|
||||
},
|
||||
{
|
||||
"title": "Haegeum",
|
||||
"artist": "Agust D & SUGA",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 275,
|
||||
"cover_url": "https://i.ytimg.com/vi/iy9qZR_OGa0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mCUEnijJVusKHFSq4NRWJ99K7u9w",
|
||||
"id": "iy9qZR_OGa0",
|
||||
"url": "https://music.youtube.com/watch?v=iy9qZR_OGa0"
|
||||
},
|
||||
{
|
||||
"title": "1000 \u00c1nh M\u1eaft",
|
||||
"artist": "Shiki, Obito",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 152,
|
||||
"cover_url": "https://i.ytimg.com/vi/AJDEu1-nSTI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nDeVx4U7u37ehqM43JjDTxqd_txA",
|
||||
"id": "AJDEu1-nSTI",
|
||||
"url": "https://music.youtube.com/watch?v=AJDEu1-nSTI"
|
||||
},
|
||||
{
|
||||
"title": "n\u1ebfu l\u00fac \u0111\u00f3 (feat. 2pillz)",
|
||||
"artist": "Tlinh",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 325,
|
||||
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kNXGGAK5wy2ix4mQ1pNwlGLYUg0Q",
|
||||
"id": "fyMgBQioTLo",
|
||||
"url": "https://music.youtube.com/watch?v=fyMgBQioTLo"
|
||||
},
|
||||
{
|
||||
"title": "FRI(END)S",
|
||||
"artist": "V",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 254,
|
||||
"cover_url": "https://i.ytimg.com/vi/62peQdQv4uo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kdJHc4aKKdSbjWA95DPVNDZ9eCgA",
|
||||
"id": "62peQdQv4uo",
|
||||
"url": "https://music.youtube.com/watch?v=62peQdQv4uo"
|
||||
},
|
||||
{
|
||||
"title": "Ch\u00ecm S\u00e2u (feat. Trung Tr\u1ea7n)",
|
||||
"artist": "RPT MCK",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 159,
|
||||
"cover_url": "https://i.ytimg.com/vi/Yw9Ra2UiVLw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kJVIY2E4nN72epESsrE4a8_0Ybhg",
|
||||
"id": "Yw9Ra2UiVLw",
|
||||
"url": "https://music.youtube.com/watch?v=Yw9Ra2UiVLw"
|
||||
},
|
||||
{
|
||||
"title": "WALK",
|
||||
"artist": "HURRYKNG, HIEUTHUHAI, Negav, Ph\u00e1p Ki\u1ec1u, and Isaac",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 349,
|
||||
"cover_url": "https://i.ytimg.com/vi/iiL1XDZe-JM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kPkUKHjm0Yy3CSzyRGycAiIas7Ew",
|
||||
"id": "iiL1XDZe-JM",
|
||||
"url": "https://music.youtube.com/watch?v=iiL1XDZe-JM"
|
||||
},
|
||||
{
|
||||
"title": "Ch\u00fang Ta C\u1ee7a Hi\u1ec7n T\u1ea1i",
|
||||
"artist": "S\u01a1n T\u00f9ng M-TP",
|
||||
"album": "Ch\u00fang Ta C\u1ee7a Hi\u1ec7n T\u1ea1i",
|
||||
"duration": 302,
|
||||
"cover_url": "https://lh3.googleusercontent.com/R96C4cCNuVOuaKpo8AfoM2ienXSOY3rhljcOi2_7Cg1KnjyZ3hr1X_A5Z8G5vOg645yG6P8txcu1r5kI=w120-h120-l90-rj",
|
||||
"id": "bNp9pn0ni3I",
|
||||
"url": "https://music.youtube.com/watch?v=bNp9pn0ni3I"
|
||||
},
|
||||
{
|
||||
"title": "Mi\u1ec1n M\u1ed9ng M\u1ecb",
|
||||
"artist": "AMEE",
|
||||
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
|
||||
"duration": 167,
|
||||
"cover_url": "https://i.ytimg.com/vi/8ItcR_8NkP8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k60wEuxjklJhJjGOsZHrGHoPDxSw",
|
||||
"id": "8ItcR_8NkP8",
|
||||
"url": "https://music.youtube.com/watch?v=8ItcR_8NkP8"
|
||||
}
|
||||
],
|
||||
"type": "playlist"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,65 +0,0 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from backend.api.routes import router as api_router
|
||||
from backend.scheduler import start_scheduler
|
||||
import os
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Start scheduler
|
||||
scheduler = start_scheduler()
|
||||
yield
|
||||
# Shutdown: Scheduler shuts down automatically with process, or we can explicit shutdown if needed
|
||||
scheduler.shutdown()
|
||||
|
||||
app = FastAPI(title="Spotify Clone Backend", lifespan=lifespan)
|
||||
|
||||
# CORS setup
|
||||
origins = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Serve Static Frontend (Production Mode)
|
||||
STATIC_DIR = "static"
|
||||
if os.path.exists(STATIC_DIR):
|
||||
app.mount("/_next", StaticFiles(directory=os.path.join(STATIC_DIR, "_next")), name="next_assets")
|
||||
|
||||
# Serve other static files (favicons etc) if they exist in root of static
|
||||
# Or just fallback everything else to index.html for SPA
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
# Check if file exists in static folder
|
||||
file_path = os.path.join(STATIC_DIR, full_path)
|
||||
if os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
|
||||
# Otherwise return index.html
|
||||
index_path = os.path.join(STATIC_DIR, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
|
||||
return {"error": "Frontend not found"}
|
||||
else:
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Spotify Clone Backend Running (Frontend not built/mounted)"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
DATA_FILE = Path("backend/data/user_playlists.json")
|
||||
|
||||
class PlaylistManager:
|
||||
def __init__(self):
|
||||
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not DATA_FILE.exists():
|
||||
self._save_data([])
|
||||
|
||||
def _load_data(self) -> List[Dict]:
|
||||
try:
|
||||
with open(DATA_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
def _save_data(self, data: List[Dict]):
|
||||
with open(DATA_FILE, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
def get_all(self) -> List[Dict]:
|
||||
return self._load_data()
|
||||
|
||||
def get_by_id(self, playlist_id: str) -> Optional[Dict]:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
def create(self, name: str, description: str = "") -> Dict:
|
||||
playlists = self._load_data()
|
||||
new_playlist = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"title": name,
|
||||
"description": description,
|
||||
"tracks": [],
|
||||
"cover_url": "https://placehold.co/400?text=Playlist", # Default placeholder
|
||||
"is_user_created": True
|
||||
}
|
||||
playlists.append(new_playlist)
|
||||
self._save_data(playlists)
|
||||
return new_playlist
|
||||
|
||||
def update(self, playlist_id: str, name: str = None, description: str = None) -> Optional[Dict]:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
if name: p["title"] = name
|
||||
if description: p["description"] = description
|
||||
self._save_data(playlists)
|
||||
return p
|
||||
return None
|
||||
|
||||
def delete(self, playlist_id: str) -> bool:
|
||||
playlists = self._load_data()
|
||||
initial_len = len(playlists)
|
||||
playlists = [p for p in playlists if p["id"] != playlist_id]
|
||||
if len(playlists) < initial_len:
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_track(self, playlist_id: str, track: Dict) -> bool:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
# Check for duplicates? For now allow.
|
||||
p["tracks"].append(track)
|
||||
# Update cover if it's the first track
|
||||
if len(p["tracks"]) == 1 and track.get("cover_url"):
|
||||
p["cover_url"] = track["cover_url"]
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_track(self, playlist_id: str, track_id: str) -> bool:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
p["tracks"] = [t for t in p["tracks"] if t.get("id") != track_id]
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
spotdl
|
||||
pydantic==2.10.4
|
||||
python-multipart==0.0.20
|
||||
APScheduler>=3.10
|
||||
requests==2.32.3
|
||||
yt-dlp @ https://github.com/yt-dlp/yt-dlp/archive/master.zip
|
||||
ytmusicapi==1.9.1
|
||||
syncedlyrics
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import subprocess
|
||||
import logging
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def update_ytdlp():
|
||||
"""
|
||||
Check for and install the latest version of yt-dlp.
|
||||
"""
|
||||
logger.info("Scheduler: Checking for yt-dlp updates...")
|
||||
try:
|
||||
# Run pip install --upgrade yt-dlp
|
||||
result = subprocess.run(
|
||||
["pip", "install", "--upgrade", "yt-dlp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
logger.info(f"Scheduler: yt-dlp update completed.\n{result.stdout}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Scheduler: Failed to update yt-dlp.\nError: {e.stderr}")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Unexpected error during update: {str(e)}")
|
||||
|
||||
def start_scheduler():
|
||||
"""
|
||||
Initialize and start the background scheduler.
|
||||
"""
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
# Schedule yt-dlp update every 24 hours
|
||||
trigger = IntervalTrigger(days=1)
|
||||
scheduler.add_job(
|
||||
update_ytdlp,
|
||||
trigger=trigger,
|
||||
id="update_ytdlp_job",
|
||||
name="Update yt-dlp daily",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
logger.info("Scheduler: Started background scheduler. yt-dlp will update every 24 hours.")
|
||||
|
||||
# Run once on startup to ensure we are up to date immediately
|
||||
# update_ytdlp() # Optional: Uncomment if we want strict enforcement on boot
|
||||
|
||||
return scheduler
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
from ytmusicapi import YTMusic
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def fetch_content():
|
||||
yt = YTMusic()
|
||||
|
||||
# Categorized Queries
|
||||
CATEGORIES = {
|
||||
"Vietnam Top": ["Vietnam Top 50", "V-Pop Hot", "Rap Viet", "Indie Vietnam"],
|
||||
"Global Top": ["Global Top 50", "US-UK Top Hits", "Pop Rising", "Viral 50 Global"],
|
||||
"K-Pop": ["K-Pop Hits", "Best of K-Pop", "K-Pop Rising", "BLACKPINK Essentials"],
|
||||
"Chill": ["Lofi Girl", "Coffee Shop Vibes", "Piano Relax", "Sleep Sounds"],
|
||||
"Party": ["Party Hits", "EDM Best", "Workout Motivation", "Vinahouse Beat"]
|
||||
}
|
||||
|
||||
segmented_content = {}
|
||||
seen_ids = set()
|
||||
|
||||
print("Fetching Browse Content...")
|
||||
|
||||
for category, queries in CATEGORIES.items():
|
||||
print(f"--- Processing Category: {category} ---")
|
||||
category_playlists = []
|
||||
|
||||
for q in queries:
|
||||
try:
|
||||
print(f"Searching for: {q}")
|
||||
# Fetch more results to ensure we get good matches
|
||||
results = yt.search(q, filter="playlists", limit=4)
|
||||
|
||||
for res in results:
|
||||
pid = res.get("browseId")
|
||||
if pid and pid not in seen_ids:
|
||||
seen_ids.add(pid)
|
||||
|
||||
# Store minimal info for the card
|
||||
category_playlists.append({
|
||||
"id": pid,
|
||||
"title": res.get("title"),
|
||||
"description": f"Based on '{q}'",
|
||||
"cover_url": res.get("thumbnails")[-1]["url"] if res.get("thumbnails") else "",
|
||||
"author": res.get("author") or "YouTube Music"
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error serving {q}: {e}")
|
||||
|
||||
segmented_content[category] = category_playlists
|
||||
|
||||
output_path = Path("backend/data/browse_playlists.json")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(segmented_content, f, indent=4)
|
||||
|
||||
total_playlists = sum(len(p) for p in segmented_content.values())
|
||||
print(f"Successfully saved {total_playlists} playlists across {len(segmented_content)} categories to {output_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
fetch_content()
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
from ytmusicapi import YTMusic
|
||||
import json
|
||||
|
||||
yt = YTMusic()
|
||||
|
||||
# Example ID (Son Tung M-TP - Chung Ta Cua Hien Tai)
|
||||
video_id = "lTdoH5uL6Ew"
|
||||
|
||||
print(f"Fetching lyrics for {video_id}...")
|
||||
try:
|
||||
watch_playlist = yt.get_watch_playlist(videoId=video_id)
|
||||
if 'lyrics' in watch_playlist:
|
||||
lyrics_id = watch_playlist['lyrics']
|
||||
print(f"Found Lyrics ID: {lyrics_id}")
|
||||
lyrics = yt.get_lyrics(lyrics_id)
|
||||
print(json.dumps(lyrics, indent=2))
|
||||
else:
|
||||
print("No lyrics found in watch playlist.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Services Package
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# Cache Service - Re-export CacheManager from backend.cache_manager
|
||||
from backend.cache_manager import CacheManager
|
||||
|
||||
__all__ = ['CacheManager']
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# Spotify Service - Placeholder for YouTube Music API interactions
|
||||
# Currently uses yt-dlp directly in routes.py
|
||||
|
||||
class SpotifyService:
|
||||
"""
|
||||
Placeholder service for Spotify/YouTube Music integration.
|
||||
Currently, all music operations are handled directly in routes.py using yt-dlp.
|
||||
This class exists to satisfy imports but has minimal functionality.
|
||||
"""
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def search(self, query: str, limit: int = 20):
|
||||
"""Search for music - placeholder"""
|
||||
return []
|
||||
|
||||
def get_track(self, track_id: str):
|
||||
"""Get track info - placeholder"""
|
||||
return None
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
[]
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
from ytmusicapi import YTMusic
|
||||
import json
|
||||
|
||||
yt = YTMusic()
|
||||
seed_id = "hDrFd1W8fvU"
|
||||
print(f"Fetching watch playlist for {seed_id}...")
|
||||
results = yt.get_watch_playlist(videoId=seed_id, limit=5)
|
||||
|
||||
if 'tracks' in results:
|
||||
print(f"Found {len(results['tracks'])} tracks.")
|
||||
if len(results['tracks']) > 0:
|
||||
first_track = results['tracks'][0]
|
||||
print(json.dumps(first_track, indent=2))
|
||||
print("Keys:", first_track.keys())
|
||||
else:
|
||||
print("No 'tracks' key in results")
|
||||
print(results.keys())
|
||||
132
fetch_data.py
132
fetch_data.py
|
|
@ -1,132 +0,0 @@
|
|||
from ytmusicapi import YTMusic
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
yt = YTMusic()
|
||||
|
||||
# Define diverse categories to fetch
|
||||
CATEGORIES = {
|
||||
"Trending Vietnam": {"query": "Top 50 Vietnam", "type": "playlists"},
|
||||
"Just released Songs": {"query": "New Released Songs", "type": "playlists"},
|
||||
"Albums": {"query": "New Albums 2024", "type": "albums"},
|
||||
"Vietnamese DJs": {"query": "Vinahouse Remix", "type": "playlists"},
|
||||
"Global Hits": {"query": "Global Top 50", "type": "playlists"},
|
||||
"Chill Vibes": {"query": "Chill Lofi", "type": "playlists"},
|
||||
"Party Time": {"query": "Party EDM Hits", "type": "playlists"},
|
||||
"Best of Ballad": {"query": "Vietnamese Ballad", "type": "playlists"},
|
||||
"Hip Hop & Rap": {"query": "Vietnamese Rap", "type": "playlists"},
|
||||
}
|
||||
|
||||
browse_data = {}
|
||||
|
||||
print("Starting diverse data fetch...")
|
||||
|
||||
def get_thumbnail(thumbnails):
|
||||
if not thumbnails:
|
||||
return "https://placehold.co/300x300"
|
||||
return thumbnails[-1]['url']
|
||||
|
||||
for category_name, info in CATEGORIES.items():
|
||||
query = info["query"]
|
||||
search_type = info["type"]
|
||||
print(f"\n--- Fetching Category: {category_name} (Query: '{query}', Type: {search_type}) ---")
|
||||
|
||||
try:
|
||||
results = yt.search(query, filter=search_type, limit=25)
|
||||
|
||||
category_items = []
|
||||
|
||||
for result in results[:20]: # Limit to 20 items per category
|
||||
item_id = result['browseId']
|
||||
title = result['title']
|
||||
print(f" > Processing: {title}")
|
||||
|
||||
try:
|
||||
# Fetch details based on type
|
||||
if search_type == "albums":
|
||||
# Use get_album
|
||||
details = yt.get_album(item_id)
|
||||
tracks_source = details.get('tracks', [])
|
||||
is_album = True
|
||||
description = f"Album by {', '.join([a.get('name') for a in details.get('artists', [])])} • {details.get('year')}"
|
||||
else:
|
||||
# Use get_playlist
|
||||
details = yt.get_playlist(item_id, limit=50)
|
||||
tracks_source = details.get('tracks', [])
|
||||
is_album = False
|
||||
description = details.get('description', '')
|
||||
|
||||
# Process Tracks
|
||||
output_tracks = []
|
||||
for track in tracks_source:
|
||||
artists_list = track.get('artists') or []
|
||||
if isinstance(artists_list, list):
|
||||
artists = ", ".join([a.get('name', 'Unknown') for a in artists_list])
|
||||
else:
|
||||
artists = "Unknown Artist"
|
||||
|
||||
thumbnails = track.get('thumbnails', [])
|
||||
# Fallback for album tracks which might not have thumbnails
|
||||
if not thumbnails and is_album:
|
||||
thumbnails = details.get('thumbnails', [])
|
||||
|
||||
cover_url = get_thumbnail(thumbnails)
|
||||
|
||||
album_info = track.get('album')
|
||||
# Use playlist/album title as album name if missing
|
||||
album_name = album_info.get('name', title) if album_info else title
|
||||
|
||||
# Track ID can be missing in some album views (very rare)
|
||||
track_id = track.get('videoId')
|
||||
if not track_id: continue
|
||||
|
||||
output_tracks.append({
|
||||
"title": track.get('title', 'Unknown Title'),
|
||||
"artist": artists,
|
||||
"album": album_name,
|
||||
"duration": track.get('duration_seconds', track.get('length_seconds', 0)),
|
||||
"cover_url": cover_url,
|
||||
"id": track_id,
|
||||
"url": f"https://music.youtube.com/watch?v={track_id}"
|
||||
})
|
||||
|
||||
if not output_tracks:
|
||||
print(f" Skipping empty item: {title}")
|
||||
continue
|
||||
|
||||
# Final Item Object
|
||||
category_items.append({
|
||||
"id": item_id,
|
||||
"title": title,
|
||||
"description": description or f"Best of {category_name}",
|
||||
"cover_url": get_thumbnail(details.get('thumbnails', result.get('thumbnails'))),
|
||||
"tracks": output_tracks,
|
||||
"type": "album" if is_album else "playlist"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error processing {item_id}: {e}")
|
||||
continue
|
||||
|
||||
if category_items:
|
||||
browse_data[category_name] = category_items
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching category {category_name}: {e}")
|
||||
|
||||
# Save to backend/data/browse_playlists.json
|
||||
output_path = Path("backend/data/browse_playlists.json")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, "w", encoding='utf-8') as f:
|
||||
json.dump(browse_data, f, indent=2)
|
||||
|
||||
# Also save a flat list for Trending (backward compatibility)
|
||||
if "Trending Vietnam" in browse_data and browse_data["Trending Vietnam"]:
|
||||
flat_trending = browse_data["Trending Vietnam"][0]
|
||||
with open("backend/data.json", "w", encoding='utf-8') as f:
|
||||
json.dump(flat_trending, f, indent=2)
|
||||
|
||||
print("\nAll Done! Saved to backend/data/browse_playlists.json")
|
||||
19
frontend-vite/index.html
Normal file
19
frontend-vite/index.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#121212" />
|
||||
<meta name="description" content="A modern music streaming experience." />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Spotify Clone</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
7418
frontend/package-lock.json → frontend-vite/package-lock.json
generated
7418
frontend/package-lock.json → frontend-vite/package-lock.json
generated
File diff suppressed because it is too large
Load diff
39
frontend-vite/package.json
Normal file
39
frontend-vite/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "frontend-vite",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^11.2.11",
|
||||
"idb": "^8.0.0",
|
||||
"lucide-react": "^0.395.0",
|
||||
"music-metadata-browser": "^2.5.10",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"postcss": "^8.4.38",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.1",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
BIN
frontend-vite/public/apple-touch-icon.png
Normal file
BIN
frontend-vite/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
frontend-vite/public/favicon.ico
Normal file
BIN
frontend-vite/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend-vite/public/favicon.png
Normal file
BIN
frontend-vite/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
52
frontend-vite/public/logo.svg
Normal file
52
frontend-vite/public/logo.svg
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="barGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="6" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background: Deep Black Squircle -->
|
||||
<rect x="0" y="0" width="512" height="512" rx="100" fill="#121212"/>
|
||||
|
||||
<!-- Content Group: Equalizer Bars -->
|
||||
<g transform="translate(106, 126)" filter="url(#glow)">
|
||||
<!-- Bar 1 -->
|
||||
<rect x="0" y="100" width="40" height="160" rx="20" fill="url(#barGrad)" opacity="0.8">
|
||||
<animate attributeName="height" values="160;100;160" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="y" values="100;130;100" dur="2s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
|
||||
<!-- Bar 2 -->
|
||||
<rect x="65" y="40" width="40" height="220" rx="20" fill="url(#barGrad)">
|
||||
<animate attributeName="height" values="220;250;220" dur="1.5s" repeatCount="indefinite" />
|
||||
<animate attributeName="y" values="40;25;40" dur="1.5s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
|
||||
<!-- Bar 3 (Center - Tallest) -->
|
||||
<rect x="130" y="0" width="40" height="260" rx="20" fill="url(#barGrad)">
|
||||
<animate attributeName="height" values="260;180;260" dur="1.8s" repeatCount="indefinite" />
|
||||
<animate attributeName="y" values="0;40;0" dur="1.8s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
|
||||
<!-- Bar 4 -->
|
||||
<rect x="195" y="60" width="40" height="200" rx="20" fill="url(#barGrad)">
|
||||
<animate attributeName="height" values="200;240;200" dur="2.2s" repeatCount="indefinite" />
|
||||
<animate attributeName="y" values="60;40;60" dur="2.2s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
|
||||
<!-- Bar 5 -->
|
||||
<rect x="260" y="120" width="40" height="140" rx="20" fill="url(#barGrad)" opacity="0.8">
|
||||
<animate attributeName="height" values="140;90;140" dur="1.7s" repeatCount="indefinite" />
|
||||
<animate attributeName="y" values="120;145;120" dur="1.7s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend-vite/public/pwa-192x192.png
Normal file
BIN
frontend-vite/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
BIN
frontend-vite/public/pwa-512x512.png
Normal file
BIN
frontend-vite/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
218
frontend-vite/scripts/fetch_real_data.js
Normal file
218
frontend-vite/scripts/fetch_real_data.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { exec } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Target Top 20 items for "Real" feel
|
||||
const TOP_ARTISTS = [
|
||||
// V-Rap / Hip Hop
|
||||
"Sơn Tùng M-TP", "Đen Vâu", "HIEUTHUHAI", "Hoàng Thùy Linh", "Wren Evans",
|
||||
"MCK", "Tlinh", "Mono", "Binz", "JustaTee", "Karik", "Suboi", "Rhymastic",
|
||||
"Obito", "Wxrdie", "Andree Right Hand", "B Ray", "BigDaddy", "Emily", "Low G",
|
||||
|
||||
// V-Pop / Ballad / Indie
|
||||
"Phan Mạnh Quỳnh", "Mỹ Tâm", "Hà Anh Tuấn", "Vũ.", "Đức Phúc", "Erik", "Min",
|
||||
"Chillies", "Ngọt", "Cá Hồi Hoang", "Da LAB", "Tăng Duy Tân", "Trúc Nhân",
|
||||
"Noo Phước Thịnh", "Đông Nhi", "Soobin Hoàng Sơn", "Orange", "LyLy", "Amee",
|
||||
"Văn Mai Hương", "Phương Mỹ Chi",
|
||||
|
||||
// International / K-Pop
|
||||
"Taylor Swift", "The Weeknd", "Ariana Grande", "Justin Bieber",
|
||||
"BTS", "BLACKPINK", "Charlie Puth", "Ed Sheeran", "Bruno Mars",
|
||||
"Post Malone", "Drake", "Kendrick Lamar", "Billie Eilish", "Olivia Rodrigo"
|
||||
];
|
||||
|
||||
const TOP_ALBUMS = [
|
||||
{ title: "Sky Tour", artist: "Sơn Tùng M-TP" },
|
||||
{ title: "LINK", artist: "Hoàng Thùy Linh" },
|
||||
{ title: "99%", artist: "MCK" },
|
||||
{ title: "Loi Choi", artist: "Wren Evans" },
|
||||
{ title: "Ai Cũng Phải Bắt Đầu Từ Đâu Đó", artist: "HIEUTHUHAI" },
|
||||
{ title: "Cong", artist: "Tóc Tiên" },
|
||||
{ title: "Citopia", artist: "Phùng Khánh Linh" },
|
||||
{ title: "Vũ Trụ Cò Bay", artist: "Phương Mỹ Chi" },
|
||||
{ title: "Yên", artist: "Hoàng Dũng" },
|
||||
{ title: "Một Vạn Năm", artist: "Vũ." },
|
||||
{ title: "Stardom", artist: "Vũ Cát Tường" },
|
||||
{ title: "DreAMEE", artist: "Amee" },
|
||||
{ title: "Hương", artist: "Văn Mai Hương" },
|
||||
{ title: "Diệu Kỳ Việt Nam", artist: "Various Artists" },
|
||||
{ title: "Rap Việt Season 3", artist: "Various Artists" },
|
||||
{ title: "Hidden Gem", artist: "Various Artists" },
|
||||
{ title: "WeChoice Awards 2023", artist: "Various Artists" },
|
||||
{ title: "Human", artist: "Tùng Dương" },
|
||||
{ title: "Midnights", artist: "Taylor Swift" },
|
||||
{ title: "After Hours", artist: "The Weeknd" }
|
||||
];
|
||||
|
||||
const TOP_PLAYLISTS = [
|
||||
"Vietnam Top Hits 2024", "Nhac Tre Moi Nhat", "V-Pop Rising", "Indie Vietnam",
|
||||
"Rap Viet All Stars", "Lofi Chill Vietnam", "Top 50 Vietnam", "Viral Hits Vietnam",
|
||||
"Bolero Tru Tinh", "Nhac Trinh Cong Son", "Acoustic Thu Gian", "Piano Focus",
|
||||
"Workout Energy", "Beast Mode", "Sleep Sounds", "Party Anthems",
|
||||
"K-Pop ON!", "K-Pop Daebak", "Anime Hits", "Gaming Music"
|
||||
];
|
||||
|
||||
// Path to yt-dlp - trying relative to script location (frontend-vite/scripts -> root)
|
||||
// Or assume in PATH if not found
|
||||
const ROOT_DIR = path.resolve(__dirname, '../../');
|
||||
const YT_DLP_PATH = path.join(ROOT_DIR, 'yt-dlp.exe');
|
||||
const CMD_BASE = fs.existsSync(YT_DLP_PATH) ? `"${YT_DLP_PATH}"` : 'yt-dlp';
|
||||
|
||||
console.log(`Using yt-dlp at: ${CMD_BASE}`);
|
||||
|
||||
async function fetchMetadata(query, type) {
|
||||
return new Promise((resolve) => {
|
||||
let searchQ = query;
|
||||
let isChannelSearch = false;
|
||||
|
||||
// Adding "Topic" often finds the auto-generated art track which has the square album cover
|
||||
// For Artists -> We want FACES, so we search for the Channel.
|
||||
if (type === 'Artist') {
|
||||
searchQ = `${query} official channel`;
|
||||
isChannelSearch = true;
|
||||
}
|
||||
if (type === 'Album') searchQ = `${query} full album topic`;
|
||||
if (type === 'Playlist') searchQ = `${query} playlist`;
|
||||
|
||||
// --flat-playlist to just get list, but we want the FIRST result metadata
|
||||
// ytsearch1: returns a playlist of 1 result. dump-json gives us that result.
|
||||
const cmd = `${CMD_BASE} "ytsearch2:${searchQ}" --dump-json --flat-playlist --no-playlist --skip-download`;
|
||||
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 10 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error(`Failed to fetch: ${query}`);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// yt-dlp might output multiple lines. We only want the first valid JSON.
|
||||
const lines = stdout.trim().split('\n');
|
||||
let data = null;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// Check if it's a valid result object or contains entries
|
||||
if (parsed.id || parsed.entries) {
|
||||
data = parsed;
|
||||
break;
|
||||
}
|
||||
} catch (e) { continue; }
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For a search result, it might be an object or entries
|
||||
let entry = data;
|
||||
if (data.entries && data.entries.length > 0) {
|
||||
entry = data.entries[0];
|
||||
}
|
||||
|
||||
let cover = null;
|
||||
|
||||
// PRIORITY: Channel Thumbnail (Face)
|
||||
if (isChannelSearch && entry.channel_thumbnail) {
|
||||
cover = entry.channel_thumbnail;
|
||||
}
|
||||
// Fallback / Standard
|
||||
else if (entry.thumbnails && entry.thumbnails.length > 0) {
|
||||
// Get last (usually highest res)
|
||||
cover = entry.thumbnails[entry.thumbnails.length - 1].url;
|
||||
} else if (entry.thumbnail) {
|
||||
cover = entry.thumbnail;
|
||||
}
|
||||
|
||||
resolve({
|
||||
title: entry.title || query,
|
||||
cover_url: cover,
|
||||
original_query: query
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Processing error for ${query}:`, e.message);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log("Fetching Real Data... this will take a moment.");
|
||||
|
||||
const results = {};
|
||||
|
||||
// Parallelize with chunks to speed up?
|
||||
// Let's do huge parallelism (all at once) since it's just network requests for metadata
|
||||
// But limit it slightly to avoid IP ban. Let's do batches of 5.
|
||||
|
||||
const processBatch = async (items, type, formatFn) => {
|
||||
const promises = items.map(async (item) => {
|
||||
const label = type === 'Album' ? item.title : item;
|
||||
const meta = await fetchMetadata(type === 'Album' ? `${item.artist} - ${item.title}` : item, type);
|
||||
|
||||
if (meta && meta.cover_url) {
|
||||
if (type === 'Artist') {
|
||||
results[label] = {
|
||||
id: `artist-${label.replace(/\s+/g, '-')}`,
|
||||
title: label,
|
||||
description: 'Artist',
|
||||
cover_url: meta.cover_url,
|
||||
type: 'Artist',
|
||||
creator: label,
|
||||
tracks: []
|
||||
};
|
||||
} else if (type === 'Album') {
|
||||
results[`${item.title} Album`] = {
|
||||
id: `album-${item.title.replace(/\s+/g, '-')}`,
|
||||
title: item.title,
|
||||
description: `Album • ${item.artist}`,
|
||||
cover_url: meta.cover_url,
|
||||
type: 'Album',
|
||||
creator: item.artist,
|
||||
tracks: []
|
||||
};
|
||||
} else {
|
||||
results[label] = {
|
||||
id: `playlist-${label.replace(/\s+/g, '-')}`,
|
||||
title: label,
|
||||
description: `Playlist • Trending`,
|
||||
cover_url: meta.cover_url,
|
||||
type: 'Playlist',
|
||||
tracks: []
|
||||
};
|
||||
}
|
||||
process.stdout.write('.'); // Progress dot
|
||||
} else {
|
||||
process.stdout.write('x');
|
||||
}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
console.log("\nFetching Artists...");
|
||||
await processBatch(TOP_ARTISTS, 'Artist');
|
||||
|
||||
console.log("\nFetching Albums...");
|
||||
await processBatch(TOP_ALBUMS, 'Album');
|
||||
|
||||
console.log("\nFetching Playlists...");
|
||||
await processBatch(TOP_PLAYLISTS, 'Playlist');
|
||||
|
||||
console.log("\nWriting file...");
|
||||
|
||||
const tsCode = `import { StaticPlaylist } from "../types";
|
||||
|
||||
export const GENERATED_CONTENT: Record<string, StaticPlaylist> = ${JSON.stringify(results, null, 4)};`;
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, '../src/data/seed_data_real.ts'), tsCode);
|
||||
console.log("\nDone! Written to seed_data_real.ts");
|
||||
}
|
||||
|
||||
run();
|
||||
51
frontend-vite/scripts/generate-icons.js
Normal file
51
frontend-vite/scripts/generate-icons.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||
const SVG_Source = path.join(PUBLIC_DIR, 'logo.svg');
|
||||
|
||||
const sizes = [
|
||||
{ name: 'pwa-192x192.png', size: 192 },
|
||||
{ name: 'pwa-512x512.png', size: 512 },
|
||||
{ name: 'apple-touch-icon.png', size: 180 },
|
||||
{ name: 'favicon.png', size: 64 }
|
||||
];
|
||||
|
||||
async function generate() {
|
||||
console.log(`Generating icons from ${SVG_Source}...`);
|
||||
|
||||
if (!fs.existsSync(SVG_Source)) {
|
||||
console.error("Source SVG not found!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Force background to match index.html
|
||||
const bg = { r: 18, g: 18, b: 18, alpha: 1 }; // #121212
|
||||
|
||||
for (const icon of sizes) {
|
||||
const dest = path.join(PUBLIC_DIR, icon.name);
|
||||
console.log(`Creating ${icon.name} (${icon.size}x${icon.size})...`);
|
||||
|
||||
await sharp(SVG_Source)
|
||||
.resize(icon.size, icon.size)
|
||||
.png()
|
||||
.toFile(dest);
|
||||
}
|
||||
|
||||
// Also copy favicon.png to favicon.ico for legacy compatibility (just a copy, standard practice now)
|
||||
// Or we can just leave it as png.
|
||||
// Let's create a specific .ico if possible, but sharp defaults to png.
|
||||
// We'll just copy favicon.png to favicon.ico as a fallback.
|
||||
fs.copyFileSync(path.join(PUBLIC_DIR, 'favicon.png'), path.join(PUBLIC_DIR, 'favicon.ico'));
|
||||
|
||||
console.log("Icons generated successfully!");
|
||||
}
|
||||
|
||||
generate().catch(err => {
|
||||
console.error("Error generating icons:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
41
frontend-vite/src/App.tsx
Normal file
41
frontend-vite/src/App.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import Home from './pages/Home';
|
||||
import Search from './pages/Search';
|
||||
import Library from './pages/Library';
|
||||
import Playlist from './pages/Playlist';
|
||||
import Artist from './pages/Artist';
|
||||
import Album from './pages/Album';
|
||||
import Collection from './pages/Collection';
|
||||
import Section from './pages/Section';
|
||||
|
||||
import { PlayerProvider } from './context/PlayerContext';
|
||||
import { LibraryProvider } from './context/LibraryContext';
|
||||
import AnimatedBackground from './components/AnimatedBackground';
|
||||
|
||||
// Force HMR Remount (v7)
|
||||
function App() {
|
||||
return (
|
||||
<PlayerProvider>
|
||||
<AnimatedBackground />
|
||||
<LibraryProvider>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="search" element={<Search />} />
|
||||
<Route path="library" element={<Library />} />
|
||||
<Route path="playlist/:id" element={<Playlist />} />
|
||||
<Route path="artist/:id" element={<Artist />} />
|
||||
<Route path="album/:id" element={<Album />} />
|
||||
<Route path="collection/tracks" element={<Collection />} />
|
||||
<Route path="section" element={<Section />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</LibraryProvider>
|
||||
</PlayerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
133
frontend-vite/src/components/AddToPlaylistModal.tsx
Normal file
133
frontend-vite/src/components/AddToPlaylistModal.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useLibrary } from '../context/LibraryContext';
|
||||
import { dbService } from '../services/db';
|
||||
import { Track, Playlist } from '../types';
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
track: Track;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddToPlaylistModal({ track, isOpen, onClose }: AddToPlaylistModalProps) {
|
||||
const { userPlaylists, refreshLibrary } = useLibrary();
|
||||
const [newPlaylistName, setNewPlaylistName] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
refreshLibrary();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddToPlaylist = async (playlist: Playlist) => {
|
||||
await dbService.addToPlaylist(playlist.id, track);
|
||||
await refreshLibrary();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateAndAdd = async () => {
|
||||
if (!newPlaylistName.trim()) return;
|
||||
const newPlaylist = await dbService.createPlaylist(newPlaylistName.trim());
|
||||
await dbService.addToPlaylist(newPlaylist.id, track);
|
||||
await refreshLibrary();
|
||||
setNewPlaylistName('');
|
||||
setShowCreate(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-[#282828] rounded-lg w-full max-w-sm shadow-2xl animate-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-lg font-bold">Add to Playlist</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-white transition"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Track Preview */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center gap-3">
|
||||
<img
|
||||
src={track.cover_url}
|
||||
alt={track.title}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{track.title}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist List */}
|
||||
<div className="max-h-64 overflow-y-auto no-scrollbar">
|
||||
{/* Create New Option */}
|
||||
{showCreate ? (
|
||||
<div className="p-4 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newPlaylistName}
|
||||
onChange={(e) => setNewPlaylistName(e.target.value)}
|
||||
placeholder="Playlist name"
|
||||
className="flex-1 px-3 py-2 bg-neutral-700 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#1DB954]"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateAndAdd()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateAndAdd}
|
||||
className="px-4 py-2 bg-[#1DB954] text-black font-bold rounded hover:scale-105 transition"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="w-full p-4 flex items-center gap-3 hover:bg-white/5 transition text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-neutral-700 rounded flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium">Create new playlist</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Existing Playlists */}
|
||||
{userPlaylists.map((playlist) => (
|
||||
<button
|
||||
key={playlist.id}
|
||||
onClick={() => handleAddToPlaylist(playlist)}
|
||||
className="w-full p-4 flex items-center gap-3 hover:bg-white/5 transition text-left"
|
||||
>
|
||||
<img
|
||||
src={playlist.cover_url || `https://placehold.co/40/222/fff?text=${playlist.title?.charAt(0) || '?'}`}
|
||||
alt={playlist.title}
|
||||
className="w-10 h-10 rounded object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{playlist.title}</p>
|
||||
<p className="text-sm text-neutral-400">{playlist.tracks?.length || 0} songs</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend-vite/src/components/AnimatedBackground.tsx
Normal file
110
frontend-vite/src/components/AnimatedBackground.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
|
||||
export default function AnimatedBackground() {
|
||||
const { theme } = useTheme();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'apple') return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let t = 0;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
const render = () => {
|
||||
if (!ctx || !canvas) return;
|
||||
t += 0.005;
|
||||
|
||||
// Create a mesh gradient effect
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Base background
|
||||
ctx.fillStyle = '#1f1f1f'; // Dark base
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Blobs
|
||||
// 1. Pink/Red (Apple Music primary)
|
||||
const x1 = w * 0.5 + Math.sin(t) * w * 0.3;
|
||||
const y1 = h * 0.4 + Math.cos(t * 1.2) * h * 0.2;
|
||||
const r1 = Math.min(w, h) * 0.6;
|
||||
|
||||
const g1 = ctx.createRadialGradient(x1, y1, 0, x1, y1, r1);
|
||||
g1.addColorStop(0, 'rgba(250, 45, 72, 0.4)'); // #fa2d48
|
||||
g1.addColorStop(1, 'rgba(250, 45, 72, 0)');
|
||||
|
||||
ctx.fillStyle = g1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x1, y1, r1, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 2. Purple/Blue secondary
|
||||
const x2 = w * 0.2 + Math.cos(t * 0.8) * w * 0.2;
|
||||
const y2 = h * 0.7 + Math.sin(t * 1.1) * h * 0.2;
|
||||
const r2 = Math.min(w, h) * 0.7;
|
||||
|
||||
const g2 = ctx.createRadialGradient(x2, y2, 0, x2, y2, r2);
|
||||
g2.addColorStop(0, 'rgba(88, 86, 214, 0.3)'); // Purple
|
||||
g2.addColorStop(1, 'rgba(88, 86, 214, 0)');
|
||||
|
||||
ctx.fillStyle = g2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x2, y2, r2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 3. Orange/Yellow tertiary
|
||||
const x3 = w * 0.8 + Math.sin(t * 1.3) * w * 0.2;
|
||||
const y3 = h * 0.2 + Math.cos(t * 0.9) * h * 0.2;
|
||||
const r3 = Math.min(w, h) * 0.5;
|
||||
|
||||
const g3 = ctx.createRadialGradient(x3, y3, 0, x3, y3, r3);
|
||||
g3.addColorStop(0, 'rgba(255, 149, 0, 0.2)'); // Orange
|
||||
g3.addColorStop(1, 'rgba(255, 149, 0, 0)');
|
||||
|
||||
ctx.fillStyle = g3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x3, y3, r3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Overlay blur
|
||||
// We use CSS backdrop-filter for the actual blur effect on components,
|
||||
// but we can draw a light noise overlay here if we want.
|
||||
|
||||
animationFrameId = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
if (theme !== 'apple') return null;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 z-[-1] pointer-events-none"
|
||||
style={{ filter: 'blur(60px) saturate(150%)' }} // Heavy blur for liquid effect
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
frontend-vite/src/components/BottomNav.tsx
Normal file
49
frontend-vite/src/components/BottomNav.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Home, Search, Library, Settings } from 'lucide-react';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default function BottomNav() {
|
||||
const location = useLocation();
|
||||
const path = location.pathname;
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
const tabs = [
|
||||
{ name: 'Home', icon: Home, path: '/' },
|
||||
{ name: 'Search', icon: Search, path: '/search' },
|
||||
{ name: 'Library', icon: Library, path: '/library' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-neutral-900 border-t border-neutral-800 pb-safe md:hidden z-50">
|
||||
<div className="flex justify-around items-center h-16">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = path === tab.path;
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.path}
|
||||
className={`flex flex-col items-center justify-center w-full h-full transition-colors ${isActive ? 'text-white' : 'text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-6 h-6 mb-1" strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span className="text-[10px] uppercase font-medium tracking-wide">{tab.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="flex flex-col items-center justify-center w-full h-full transition-colors text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
<Settings className="w-6 h-6 mb-1" strokeWidth={2} />
|
||||
<span className="text-[10px] uppercase font-medium tracking-wide">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend-vite/src/components/CoverImage.tsx
Normal file
41
frontend-vite/src/components/CoverImage.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
interface CoverImageProps {
|
||||
src?: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
fallbackText?: string;
|
||||
}
|
||||
|
||||
export default function CoverImage({ src, alt, className = "", fallbackText = "♪" }: CoverImageProps) {
|
||||
const [error, setError] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
if (!src || error) {
|
||||
// Fallback placeholder with gradient
|
||||
return (
|
||||
<div
|
||||
className={`bg-gradient-to-br from-neutral-700 to-neutral-900 flex items-center justify-center text-2xl font-bold text-white/60 ${className}`}
|
||||
aria-label={alt}
|
||||
>
|
||||
{fallbackText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 bg-neutral-800 animate-pulse" />
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover ${loaded ? 'opacity-100' : 'opacity-0'} transition-opacity duration-300`}
|
||||
onError={() => setError(true)}
|
||||
onLoad={() => setLoaded(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend-vite/src/components/CreatePlaylistModal.tsx
Normal file
63
frontend-vite/src/components/CreatePlaylistModal.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
interface CreatePlaylistModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string) => void;
|
||||
}
|
||||
|
||||
export default function CreatePlaylistModal({ isOpen, onClose, onCreate }: CreatePlaylistModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onCreate(name.trim());
|
||||
setName('');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-[#282828] rounded-lg w-full max-w-sm shadow-2xl animate-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Create Playlist</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Playlist"
|
||||
className="w-full px-4 py-3 bg-neutral-700 rounded-lg text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-[#1DB954] mb-4"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 text-neutral-400 hover:text-white font-medium transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-[#1DB954] text-black font-bold rounded-full hover:scale-105 transition"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend-vite/src/components/Layout.tsx
Normal file
27
frontend-vite/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Outlet } from 'react-router-dom';
|
||||
import BottomNav from './BottomNav';
|
||||
import Sidebar from './Sidebar';
|
||||
import PlayerBar from './PlayerBar';
|
||||
import AnimatedBackground from './AnimatedBackground';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="h-screen w-screen flex overflow-hidden bg-spotify-base text-spotify-text-main transition-colors duration-500 relative">
|
||||
<AnimatedBackground />
|
||||
{/* Desktop Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content Area - YouTube Music Style (Pure Black with subtle top fade) */}
|
||||
<main className="flex-1 overflow-y-auto pb-24 fold:pb-[90px] relative bg-spotify-base text-spotify-text-main scroll-smooth">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Audio Player */}
|
||||
<PlayerBar />
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
17
frontend-vite/src/components/Logo.tsx
Normal file
17
frontend-vite/src/components/Logo.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export default function Logo() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Animated Soundwave Icon */}
|
||||
<div className="flex items-end gap-[2px] h-6">
|
||||
<div className="w-[3px] bg-[#1DB954] rounded-full animate-soundwave-1" />
|
||||
<div className="w-[3px] bg-[#1DB954] rounded-full animate-soundwave-2" />
|
||||
<div className="w-[3px] bg-[#1DB954] rounded-full animate-soundwave-3" />
|
||||
<div className="w-[3px] bg-[#1DB954] rounded-full animate-soundwave-4" />
|
||||
</div>
|
||||
{/* Text */}
|
||||
<span className="text-xl font-bold tracking-tight">
|
||||
Spotify <span className="text-[#1DB954]">Clone</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
frontend-vite/src/components/Lyrics.tsx
Normal file
130
frontend-vite/src/components/Lyrics.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { libraryService } from '../services/library';
|
||||
|
||||
interface LyricsProps {
|
||||
trackTitle: string;
|
||||
artistName: string;
|
||||
currentTime: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface LyricLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, onClose }: LyricsProps) {
|
||||
const [lyrics, setLyrics] = useState<string | null>(null);
|
||||
const [syncedLines, setSyncedLines] = useState<LyricLine[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const activeLineRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && trackTitle) {
|
||||
setLoading(true);
|
||||
setLyrics(null);
|
||||
setSyncedLines([]);
|
||||
|
||||
libraryService.getLyrics(trackTitle, artistName)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
if (data.syncedLyrics) {
|
||||
setSyncedLines(parseSyncedLyrics(data.syncedLyrics));
|
||||
} else {
|
||||
setLyrics(data.plainLyrics || "No lyrics available.");
|
||||
}
|
||||
} else {
|
||||
setLyrics("Lyrics not found.");
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLyrics("Failed to load lyrics.");
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [trackTitle, artistName, isOpen]);
|
||||
|
||||
// Find active line
|
||||
const activeIndex = syncedLines.findIndex((line, i) => {
|
||||
const nextLine = syncedLines[i + 1];
|
||||
return currentTime >= line.time && (!nextLine || currentTime < nextLine.time);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLineRef.current) {
|
||||
activeLineRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [activeIndex]); // Only scroll when line changes!
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/95 z-[80] flex flex-col animate-in slide-in-from-bottom">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-neutral-400 uppercase tracking-widest">Lyrics</span>
|
||||
<h2 className="text-xl font-bold">{trackTitle}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition z-[90]"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 text-center no-scrollbar mask-gradient">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-white"></div>
|
||||
</div>
|
||||
) : syncedLines.length > 0 ? (
|
||||
<div className="space-y-6 py-[50vh]">
|
||||
{syncedLines.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
ref={i === activeIndex ? activeLineRef : null}
|
||||
className={`text-2xl md:text-4xl font-bold transition-all duration-500 cursor-pointer py-2 ${i === activeIndex
|
||||
? 'text-white scale-110 origin-center'
|
||||
: 'text-neutral-500/60 blur-[1px] hover:text-neutral-300 hover:blur-none'
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center whitespace-pre-wrap text-lg md:text-2xl leading-relaxed text-neutral-300">
|
||||
{lyrics}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseSyncedLyrics(lrc: string): LyricLine[] {
|
||||
const lines = lrc.split('\n');
|
||||
const result: LyricLine[] = [];
|
||||
const regex = /\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/;
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(regex);
|
||||
if (match) {
|
||||
const min = parseInt(match[1]);
|
||||
const sec = parseInt(match[2]);
|
||||
const ms = parseInt(match[3].length === 2 ? match[3] + '0' : match[3]); // Normalize ms
|
||||
const time = min * 60 + sec + ms / 1000;
|
||||
const text = match[4].trim();
|
||||
if (text) result.push({ time, text });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
627
frontend-vite/src/components/PlayerBar.tsx
Normal file
627
frontend-vite/src/components/PlayerBar.tsx
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Volume2, Download, PlusCircle, Mic2, Heart, Loader2, ListMusic, MonitorSpeaker, Maximize2, MoreHorizontal, Info, ChevronUp } from 'lucide-react';
|
||||
import { usePlayer } from "../context/PlayerContext";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import TechSpecs from './TechSpecs';
|
||||
import AddToPlaylistModal from "./AddToPlaylistModal";
|
||||
import Lyrics from './Lyrics';
|
||||
import QueueModal from './QueueModal';
|
||||
import { useDominantColor } from '../hooks/useDominantColor';
|
||||
import { useLyrics } from '../hooks/useLyrics';
|
||||
|
||||
export default function PlayerBar() {
|
||||
const {
|
||||
currentTrack, isPlaying, isBuffering, togglePlay, setBuffering,
|
||||
likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle,
|
||||
repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics, closeLyrics, openLyrics
|
||||
} = usePlayer();
|
||||
|
||||
const dominantColor = useDominantColor(currentTrack?.cover_url);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
const [hasInteractedWithLyrics, setHasInteractedWithLyrics] = useState(false);
|
||||
const { currentLine } = useLyrics(
|
||||
currentTrack?.title || '',
|
||||
currentTrack?.artist || '',
|
||||
progress,
|
||||
isLyricsOpen || hasInteractedWithLyrics // Only fetch if opened or previously interacted
|
||||
);
|
||||
|
||||
// Swipe Logic
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartY.current === null) return;
|
||||
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
const diffY = touchStartY.current - touchEndY;
|
||||
|
||||
// Swipe Up (positive diff) > 100px (Increased threshold to prevent accidental triggers)
|
||||
if (diffY > 100) {
|
||||
setHasInteractedWithLyrics(true);
|
||||
openLyrics(); // Explicitly Open Lyrics
|
||||
}
|
||||
|
||||
touchStartY.current = null;
|
||||
};
|
||||
|
||||
const [volume, setVolume] = useState(1);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Modal State
|
||||
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
|
||||
const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false);
|
||||
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
|
||||
const [isCoverModalOpen, setIsCoverModalOpen] = useState(false);
|
||||
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
||||
const [isInfoOpen, setIsInfoOpen] = useState(false);
|
||||
const [playerMode, setPlayerMode] = useState<'audio' | 'video'>('audio');
|
||||
|
||||
// Force close lyrics on mount (Defensive fix for "Open on first play")
|
||||
useEffect(() => {
|
||||
closeLyrics();
|
||||
}, []);
|
||||
|
||||
// Reset to audio mode when track changes
|
||||
useEffect(() => {
|
||||
setPlayerMode('audio');
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Handle audio/video mode switching
|
||||
const handleModeSwitch = (mode: 'audio' | 'video') => {
|
||||
if (mode === 'video') {
|
||||
audioRef.current?.pause();
|
||||
if (isPlaying) togglePlay(); // Update state to paused
|
||||
} else {
|
||||
// Switching back to audio
|
||||
// Optionally sync time if we could get it from video, but for now just resume
|
||||
if (!isPlaying) togglePlay();
|
||||
}
|
||||
setPlayerMode(mode);
|
||||
};
|
||||
|
||||
// ... (rest of useEffects)
|
||||
|
||||
// ... inside return ...
|
||||
|
||||
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// Audio source effect
|
||||
useEffect(() => {
|
||||
if (currentTrack && audioRef.current && currentTrack.url) {
|
||||
const isSameUrl = audioRef.current.src === currentTrack.url ||
|
||||
(currentTrack.url.startsWith('/') && audioRef.current.src.endsWith(currentTrack.url)) ||
|
||||
(audioRef.current.src.includes(currentTrack.id));
|
||||
|
||||
if (isSameUrl) return;
|
||||
|
||||
audioRef.current.src = currentTrack.url;
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.error("Play error:", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentTrack?.url]);
|
||||
|
||||
// Play/Pause effect
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.error("Play error:", e);
|
||||
});
|
||||
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = "playing";
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = "paused";
|
||||
}
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Volume Effect
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
// Sync Play/Pause with YouTube Iframe
|
||||
useEffect(() => {
|
||||
if (playerMode === 'video' && iframeRef.current && iframeRef.current.contentWindow) {
|
||||
const action = isPlaying ? 'playVideo' : 'pauseVideo';
|
||||
iframeRef.current.contentWindow.postMessage(JSON.stringify({
|
||||
event: 'command',
|
||||
func: action
|
||||
}), '*');
|
||||
}
|
||||
}, [isPlaying, playerMode]);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current) {
|
||||
// Only update progress if NOT dragging, to prevent stutter/fighting
|
||||
if (!isDragging.current) {
|
||||
setProgress(audioRef.current.currentTime);
|
||||
}
|
||||
|
||||
if (!isNaN(audioRef.current.duration)) {
|
||||
setDuration(audioRef.current.duration);
|
||||
}
|
||||
// Update position state for lock screen
|
||||
if ('mediaSession' in navigator && !isNaN(audioRef.current.duration)) {
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: audioRef.current.duration,
|
||||
playbackRate: audioRef.current.playbackRate,
|
||||
position: audioRef.current.currentTime
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Called while dragging - updates visual slider only
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
isDragging.current = true;
|
||||
const time = parseFloat(e.target.value);
|
||||
setProgress(time);
|
||||
};
|
||||
|
||||
// Called on release - commits the seek to audio engine
|
||||
const handleSeekCommit = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = progress;
|
||||
}
|
||||
// Small delay to prevent onTimeUpdate from jumping back immediately
|
||||
setTimeout(() => {
|
||||
isDragging.current = false;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!currentTrack) return;
|
||||
const url = `/api/download?id=${currentTrack.id}&title=${encodeURIComponent(currentTrack.title)}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time)) return "0:00";
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!currentTrack) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<footer
|
||||
className="fixed bottom-[calc(4rem+env(safe-area-inset-bottom))] left-2 right-2 fold:left-0 fold:right-0 fold:bottom-0 h-16 fold:h-[90px] bg-spotify-player border-t-0 fold:border-t border-white/5 flex items-center justify-between z-[60] rounded-lg fold:rounded-none shadow-xl fold:shadow-none transition-all duration-300 backdrop-blur-xl"
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setIsFullScreenPlayerOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
preload="auto"
|
||||
onEnded={nextTrack}
|
||||
onWaiting={() => setBuffering(true)}
|
||||
onPlaying={() => setBuffering(false)}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleTimeUpdate}
|
||||
/>
|
||||
|
||||
{/* Mobile Progress Bar */}
|
||||
<div className="absolute bottom-0 left-1 right-1 h-[2px] fold:hidden">
|
||||
<div className="absolute inset-0 bg-white/20 rounded-full overflow-hidden pointer-events-none">
|
||||
<div
|
||||
className="h-full bg-white rounded-full transition-all duration-300 ease-linear"
|
||||
style={{ width: `${(progress / (duration || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
value={progress}
|
||||
onChange={(e) => { e.stopPropagation(); handleSeek(e); }}
|
||||
onMouseUp={(e) => { e.stopPropagation(); handleSeekCommit(); }}
|
||||
onTouchEnd={(e) => { e.stopPropagation(); handleSeekCommit(); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute -bottom-1 -left-1 -right-1 h-4 w-[calc(100%+8px)] opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Left: Now Playing */}
|
||||
<div className="flex items-center gap-3 fold:gap-4 flex-1 min-w-0 fold:w-[30%] text-white fold:pl-4">
|
||||
<img
|
||||
src={currentTrack.cover_url}
|
||||
alt="Cover"
|
||||
className="h-14 w-14 fold:h-14 fold:w-14 rounded-xl object-cover ml-1 fold:ml-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.innerWidth >= 700) setIsCoverModalOpen(true);
|
||||
else setIsFullScreenPlayerOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col justify-center overflow-hidden min-w-0">
|
||||
<span className="text-[11px] fold:text-xs font-bold truncate leading-tight hover:underline cursor-pointer">{currentTrack.title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] fold:text-xs text-neutral-400 truncate leading-tight hover:underline cursor-pointer">{currentTrack.artist}</span>
|
||||
{audioQuality && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setIsTechSpecsOpen(true); }}
|
||||
className="text-[10px] bg-white/10 px-1 rounded text-green-400 font-bold hover:bg-white/20 transition border border-green-400/20"
|
||||
>
|
||||
HI-RES
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Heart */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleLike(currentTrack); }}
|
||||
className={`fold:hidden ml-2 ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-neutral-400'}`}
|
||||
>
|
||||
<Heart size={20} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
|
||||
{/* Desktop Heart */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleLike(currentTrack); }}
|
||||
className={`hidden fold:block hover:scale-110 transition ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-neutral-400 hover:text-white'}`}
|
||||
>
|
||||
<Heart className={`w-5 h-5 ${likedTracks.has(currentTrack.id) ? 'fill-green-500' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Add to Playlist (Desktop) */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setIsAddToPlaylistOpen(true); }}
|
||||
className="hidden fold:block text-neutral-400 hover:text-white hover:scale-110 transition"
|
||||
title="Add to Playlist"
|
||||
>
|
||||
<PlusCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center: Controls */}
|
||||
<div className="flex fold:flex-col items-center justify-end fold:justify-center fold:max-w-[40%] w-auto fold:w-full gap-2 pr-3 fold:pr-0">
|
||||
{/* Mobile: Play/Pause + Lyrics */}
|
||||
<div className="flex items-center gap-3 fold:hidden">
|
||||
<button
|
||||
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-neutral-300'}`}
|
||||
onClick={(e) => { e.stopPropagation(); toggleLyrics(); }}
|
||||
>
|
||||
<Mic2 size={22} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
||||
className="text-white"
|
||||
>
|
||||
{isBuffering ? <Loader2 size={24} className="animate-spin" /> : (isPlaying ? <Pause size={24} fill="currentColor" /> : <Play size={24} fill="currentColor" />)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Full Controls */}
|
||||
<div className="hidden fold:flex items-center gap-6">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`transition ${shuffle ? 'text-green-500' : 'text-neutral-400 hover:text-white'}`}>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={prevTrack} className="text-neutral-400 hover:text-white transition"><SkipBack className="w-5 h-5 fill-current" /></button>
|
||||
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition">
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-4 h-4 text-black animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-4 h-4 text-black fill-black" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 text-black fill-black ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button onClick={nextTrack} className="text-neutral-400 hover:text-white transition"><SkipForward className="w-5 h-5 fill-current" /></button>
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
className={`transition ${repeatMode !== 'none' ? 'text-green-500' : 'text-neutral-400 hover:text-white'} relative`}>
|
||||
<Repeat className="w-4 h-4" />
|
||||
{repeatMode === 'one' && <span className="absolute -top-1 -right-1 text-[8px] font-bold text-black bg-green-500 rounded-full w-3 h-3 flex items-center justify-center">1</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Seek Bar */}
|
||||
<div className="hidden fold:flex items-center gap-2 w-full text-xs text-neutral-400">
|
||||
<span>{formatTime(progress)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
onMouseUp={handleSeekCommit}
|
||||
onTouchEnd={handleSeekCommit}
|
||||
className="w-full h-1 bg-[#4d4d4d] rounded-lg appearance-none cursor-pointer accent-white hover:accent-green-500"
|
||||
/>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Right: Volume & Extras (Desktop) */}
|
||||
<div className="hidden fold:flex items-center justify-end gap-3 w-[30%] text-neutral-400 pr-4">
|
||||
<button
|
||||
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-zinc-400 hover:text-white'}`}
|
||||
onClick={toggleLyrics}
|
||||
title="Lyrics"
|
||||
>
|
||||
<Mic2 size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="text-zinc-400 hover:text-white transition"
|
||||
onClick={handleDownload}
|
||||
title="Download MP3"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`transition ${isQueueOpen ? 'text-green-500' : 'text-zinc-400 hover:text-white'}`}
|
||||
onClick={() => setIsQueueOpen(true)}
|
||||
title="Queue"
|
||||
>
|
||||
<ListMusic className="w-4 h-4" />
|
||||
</button>
|
||||
<MonitorSpeaker className="w-4 h-4 hover:text-white cursor-pointer" onClick={() => setIsTechSpecsOpen(true)} />
|
||||
<div className="flex items-center gap-2 w-24 group">
|
||||
<Volume2 className="w-4 h-4 group-hover:text-white" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
className="w-full h-1 bg-[#4d4d4d] rounded-lg appearance-none cursor-pointer accent-white group-hover:accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => setIsCoverModalOpen(true)} title="Full Screen" className="text-zinc-400 hover:text-white">
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
{/* Mobile Full Screen Player Overlay */}
|
||||
<div
|
||||
className={`fixed inset-0 z-[70] flex flex-col transition-transform duration-300 ${isFullScreenPlayerOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
||||
style={{ background: `linear-gradient(to bottom, ${dominantColor}, #121212)` }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Header / Close */}
|
||||
<div className="flex items-center justify-between p-4 pt-8">
|
||||
<div onClick={() => setIsFullScreenPlayerOpen(false)} className="text-white">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Song / Video Toggle */}
|
||||
<div className="flex bg-[#1a1a1a] rounded-full p-1">
|
||||
<button
|
||||
onClick={() => handleModeSwitch('audio')}
|
||||
className={`px-6 py-1 rounded-full text-xs font-bold transition ${playerMode === 'audio' ? 'bg-[#333] text-white' : 'text-neutral-500'}`}
|
||||
>
|
||||
Song
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeSwitch('video')}
|
||||
className={`px-6 py-1 rounded-full text-xs font-bold transition ${playerMode === 'video' ? 'bg-[#333] text-white' : 'text-neutral-500'}`}
|
||||
>
|
||||
Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-6" />
|
||||
</div>
|
||||
|
||||
{/* Responsive Split View Container */}
|
||||
<div className="flex-1 flex flex-col md:flex-row w-full overflow-hidden">
|
||||
{/* Left/Top: Art or Video */}
|
||||
<div className="flex-1 flex items-center justify-center p-8 md:p-12">
|
||||
{playerMode === 'audio' ? (
|
||||
<img
|
||||
src={currentTrack.cover_url}
|
||||
alt={currentTrack.title}
|
||||
className="w-full aspect-square object-cover rounded-3xl shadow-2xl max-h-[50vh] md:max-h-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-video rounded-3xl overflow-hidden shadow-2xl bg-black">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`https://www.youtube.com/embed/${currentTrack.id}?autoplay=1&start=${Math.floor(progress)}&playsinline=1&modestbranding=1&rel=0&controls=1&enablejsapi=1`}
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right/Bottom: Controls */}
|
||||
<div className="flex-1 flex flex-col justify-center px-8 pb-12 md:pb-0 md:pr-12 overflow-y-auto no-scrollbar">
|
||||
{/* Track Info */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1 mr-4">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-white line-clamp-2 md:mb-2">{currentTrack.title}</h2>
|
||||
<p
|
||||
onClick={() => { setIsFullScreenPlayerOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
|
||||
className="text-lg md:text-xl text-neutral-400 line-clamp-1 cursor-pointer hover:text-white hover:underline transition"
|
||||
>
|
||||
{currentTrack.artist}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button onClick={() => toggleLike(currentTrack)} className={likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-neutral-400'}>
|
||||
<Heart size={28} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<button onClick={() => setIsInfoOpen(true)} className="text-neutral-400 hover:text-white">
|
||||
<Info size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-8">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
onMouseUp={handleSeekCommit}
|
||||
onTouchEnd={handleSeekCommit}
|
||||
className="w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-white mb-2"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-neutral-400 font-medium font-mono">
|
||||
<span>{formatTime(progress)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between mb-8 max-w-md mx-auto w-full">
|
||||
<button onClick={toggleShuffle} className={shuffle ? 'text-green-500' : 'text-neutral-400'}>
|
||||
<Shuffle size={24} />
|
||||
</button>
|
||||
<button onClick={prevTrack} className="text-white hover:text-neutral-300 transition">
|
||||
<SkipBack size={32} fill="currentColor" />
|
||||
</button>
|
||||
<button onClick={togglePlay} className="w-16 h-16 bg-white rounded-full flex items-center justify-center text-black hover:scale-105 transition shadow-lg">
|
||||
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" className="ml-1" />}
|
||||
</button>
|
||||
<button onClick={nextTrack} className="text-white hover:text-neutral-300 transition">
|
||||
<SkipForward size={32} fill="currentColor" />
|
||||
</button>
|
||||
<button onClick={toggleRepeat} className={repeatMode !== 'none' ? 'text-green-500' : 'text-neutral-400'}>
|
||||
<Repeat size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lyric Peek (Tablet optimized) */}
|
||||
<div
|
||||
className={`h-16 flex items-center justify-center overflow-hidden cursor-pointer active:scale-95 transition bg-white/5 rounded-xl p-4 hover:bg-white/10 ${!hasInteractedWithLyrics ? 'opacity-50' : 'opacity-100'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHasInteractedWithLyrics(true);
|
||||
openLyrics();
|
||||
}}
|
||||
>
|
||||
{currentLine ? (
|
||||
<p className="text-white font-bold text-lg text-center animate-in fade-in slide-in-from-bottom-2 line-clamp-2">
|
||||
"{currentLine.text}"
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Mic2 size={16} />
|
||||
<span className="text-sm font-bold">Tap for Lyrics</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song Info Modal (Mobile) */}
|
||||
{isInfoOpen && (
|
||||
<div className="fixed inset-0 bg-black/80 z-[80] flex items-center justify-center p-6 backdrop-blur-sm animate-in">
|
||||
<div className="bg-[#282828] w-full max-w-sm rounded-2xl p-6 shadow-2xl border border-white/10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">Song Info</h2>
|
||||
<button onClick={() => setIsInfoOpen(false)} className="p-2 bg-white/10 rounded-full hover:bg-white/20">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-400">Title</p>
|
||||
<p className="font-medium text-lg">{currentTrack.title}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-neutral-400">Artist</p>
|
||||
<p
|
||||
className="font-medium text-lg text-spotify-highlight cursor-pointer hover:underline"
|
||||
onClick={() => { setIsInfoOpen(false); setIsFullScreenPlayerOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
|
||||
>
|
||||
{currentTrack.artist}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-neutral-400">Album</p>
|
||||
<p className="font-medium text-lg">{currentTrack.album || 'Single'}</p>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<p className="text-xs text-neutral-500">Source: YouTube Music</p>
|
||||
{currentTrack.duration && <p className="text-xs text-neutral-500">Duration: {formatTime(currentTrack.duration)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<QueueModal
|
||||
isOpen={isQueueOpen}
|
||||
onClose={() => setIsQueueOpen(false)}
|
||||
/>
|
||||
|
||||
<TechSpecs
|
||||
isOpen={isTechSpecsOpen}
|
||||
onClose={() => setIsTechSpecsOpen(false)}
|
||||
quality={audioQuality}
|
||||
trackTitle={currentTrack?.title || ''}
|
||||
/>
|
||||
|
||||
{isAddToPlaylistOpen && currentTrack && (
|
||||
<AddToPlaylistModal
|
||||
track={currentTrack}
|
||||
isOpen={true}
|
||||
onClose={() => setIsAddToPlaylistOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLyricsOpen && (
|
||||
<Lyrics
|
||||
trackTitle={currentTrack.title}
|
||||
artistName={currentTrack.artist}
|
||||
currentTime={progress}
|
||||
isOpen={isLyricsOpen}
|
||||
onClose={closeLyrics}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
frontend-vite/src/components/QueueModal.tsx
Normal file
99
frontend-vite/src/components/QueueModal.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { X, Play, Pause } from 'lucide-react';
|
||||
import { usePlayer } from '../context/PlayerContext';
|
||||
import CoverImage from './CoverImage';
|
||||
import { Track } from '../types';
|
||||
|
||||
interface QueueModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
|
||||
const { queue, currentTrack, playTrack, isPlaying, togglePlay } = usePlayer();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] bg-black/80 backdrop-blur-sm flex justify-end animate-in slide-in-from-right duration-300">
|
||||
<div className="w-full max-w-md h-full bg-[#121212] border-l border-white/10 flex flex-col shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
||||
<h2 className="text-xl font-bold text-white">Queue</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-white transition">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-bold text-neutral-400 uppercase tracking-widest mb-2 px-2">Now Playing</h3>
|
||||
{currentTrack && (
|
||||
<QueueItem
|
||||
track={currentTrack}
|
||||
isCurrent={true}
|
||||
isPlaying={isPlaying}
|
||||
onClick={() => togglePlay()} // Toggle play for current
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-neutral-400 uppercase tracking-widest mb-2 px-2">Next Up</h3>
|
||||
{queue.length === 0 ? (
|
||||
<div className="text-neutral-500 text-sm px-2">Queue is empty</div>
|
||||
) : (
|
||||
queue.map((track, i) => {
|
||||
// Skip current track in "Next Up" visual if it's the one playing?
|
||||
// Actually queue usually contains the current track.
|
||||
// Let's filter out current track visually or just show whole queue?
|
||||
// Spotify shows "Next In Queue".
|
||||
if (track.id === currentTrack?.id) return null;
|
||||
|
||||
return (
|
||||
<QueueItem
|
||||
key={`${track.id}-${i}`}
|
||||
track={track}
|
||||
isCurrent={false}
|
||||
onClick={() => playTrack(track, queue)} // Jump to track
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueItem({ track, isCurrent, isPlaying, onClick }: { track: Track, isCurrent: boolean, isPlaying?: boolean, onClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 p-2 rounded-md transition cursor-pointer group ${isCurrent ? 'bg-white/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<div className="relative w-10 h-10 flex-shrink-0">
|
||||
<CoverImage src={track.cover_url} alt={track.title} className="w-full h-full rounded object-cover" fallbackText="♪" />
|
||||
{isCurrent && isPlaying && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<div className="flex items-end gap-[2px] h-3">
|
||||
<div className="w-[2px] bg-[#1DB954] rounded-full animate-soundwave-1" />
|
||||
<div className="w-[2px] bg-[#1DB954] rounded-full animate-soundwave-2" />
|
||||
<div className="w-[2px] bg-[#1DB954] rounded-full animate-soundwave-3" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center">
|
||||
<Play size={16} className="text-white fill-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`font-medium truncate text-sm ${isCurrent ? 'text-[#1DB954]' : 'text-white'}`}>{track.title}</p>
|
||||
<p className="text-xs text-neutral-400 truncate">{track.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
frontend-vite/src/components/SettingsModal.tsx
Normal file
144
frontend-vite/src/components/SettingsModal.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
import { X, RefreshCcw, Check, CheckCircle2, Circle, Smartphone, Monitor } from 'lucide-react';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [updateLog, setUpdateLog] = useState<string>('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleUpdateYtdlp = async () => {
|
||||
if (isUpdating) return;
|
||||
setIsUpdating(true);
|
||||
setUpdateStatus('loading');
|
||||
setUpdateLog('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/update-ytdlp', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setUpdateStatus('success');
|
||||
setUpdateLog(data.output || 'Update successful.');
|
||||
} else {
|
||||
setUpdateStatus('error');
|
||||
setUpdateLog(data.error || 'Update failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
setUpdateStatus('error');
|
||||
setUpdateLog('Network error occurred.');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className={`relative w-full max-w-2xl overflow-hidden rounded-2xl shadow-2xl border transition-colors duration-300 ${theme === 'apple' ? 'bg-[#1c1c1e]/80 border-white/10 text-white' : 'bg-[#121212] border-[#282828] text-white'}`}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-bold">Settings</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-white/10 transition">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-6 max-h-[70vh] overflow-y-auto no-scrollbar">
|
||||
|
||||
{/* Appearance Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3 text-neutral-400 uppercase tracking-wider text-xs">Appearance</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Spotify Theme Option */}
|
||||
<button
|
||||
onClick={() => toggleTheme('spotify')}
|
||||
className={`relative group p-3 rounded-xl border-2 transition-all duration-300 flex items-center gap-3 text-left ${theme === 'spotify' ? 'border-green-500 bg-[#181818]' : 'border-transparent bg-[#181818] hover:bg-[#282828]'}`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-[#121212] flex items-center justify-center border border-[#282828]">
|
||||
<div className="w-5 h-5 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-base">Spotify</div>
|
||||
<div className="text-xs text-neutral-400">Classic Dark Mode</div>
|
||||
</div>
|
||||
{theme === 'spotify' && <CheckCircle2 className="w-5 h-5 text-green-500" />}
|
||||
</button>
|
||||
|
||||
{/* Apple Music Theme Option */}
|
||||
<button
|
||||
onClick={() => toggleTheme('apple')}
|
||||
className={`relative group p-3 rounded-xl border-2 transition-all duration-300 flex items-center gap-3 text-left ${theme === 'apple' ? 'border-[#fa2d48] bg-[#2c2c2e]' : 'border-transparent bg-[#181818] hover:bg-[#282828]'}`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#fa2d48] to-[#5856d6] flex items-center justify-center">
|
||||
<div className="w-5 h-5 text-white"></div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-base">Apple Music</div>
|
||||
<div className="text-xs text-neutral-400">Liquid Glass & Blur</div>
|
||||
</div>
|
||||
{theme === 'apple' && <CheckCircle2 className="w-5 h-5 text-[#fa2d48]" />}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* System Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3 text-neutral-400 uppercase tracking-wider text-xs">System</h3>
|
||||
|
||||
<div className={`p-4 rounded-xl border ${theme === 'apple' ? 'bg-[#2c2c2e] border-white/5' : 'bg-[#181818] border-[#282828]'}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-semibold text-base flex items-center gap-2">
|
||||
Core Update
|
||||
<span className="text-[10px] bg-white/10 px-1.5 py-0.5 rounded text-neutral-400">yt-dlp nightly</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">Updates the underlying download engine.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdateYtdlp}
|
||||
disabled={isUpdating}
|
||||
className={`px-3 py-1.5 rounded-lg font-bold text-sm flex items-center gap-2 transition ${isUpdating ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105'} ${theme === 'apple' ? 'bg-[#fa2d48] text-white' : 'bg-green-500 text-black'}`}
|
||||
>
|
||||
<RefreshCcw className={`w-3.5 h-3.5 ${isUpdating ? 'animate-spin' : ''}`} />
|
||||
{isUpdating ? 'Updating...' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
{(updateStatus !== 'idle' || updateLog) && (
|
||||
<div className="mt-3 p-3 bg-black/50 rounded-lg font-mono text-[10px] text-neutral-300 max-h-24 overflow-y-auto whitespace-pre-wrap">
|
||||
{updateStatus === 'loading' && <span className="text-blue-400">Executing update command...{'\n'}</span>}
|
||||
{updateLog}
|
||||
{updateStatus === 'success' && <span className="text-green-400">{'\n'}Done!</span>}
|
||||
{updateStatus === 'error' && <span className="text-red-400">{'\n'}Error Occurred.</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="text-center text-[10px] text-neutral-500 pt-4">
|
||||
KV Spotify Clone v1.0.0
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,232 +1,222 @@
|
|||
"use client";
|
||||
|
||||
import { Home, Search, Library, Plus, Heart, RefreshCcw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePlayer } from "@/context/PlayerContext";
|
||||
import { useState } from "react";
|
||||
import CreatePlaylistModal from "./CreatePlaylistModal";
|
||||
import { dbService } from "@/services/db";
|
||||
import { useLibrary } from "@/context/LibraryContext";
|
||||
import Logo from "./Logo";
|
||||
import CoverImage from "./CoverImage";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { likedTracks } = usePlayer();
|
||||
const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const handleCreatePlaylist = async (name: string) => {
|
||||
await dbService.createPlaylist(name);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleDeletePlaylist = async (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (confirm("Delete this playlist?")) {
|
||||
await dbService.deletePlaylist(id);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateYtdlp = async () => {
|
||||
if (isUpdating) return;
|
||||
setIsUpdating(true);
|
||||
setUpdateStatus('loading');
|
||||
try {
|
||||
const response = await fetch('/api/system/update-ytdlp', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
setUpdateStatus('success');
|
||||
setTimeout(() => setUpdateStatus('idle'), 5000);
|
||||
} else {
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update yt-dlp:", error);
|
||||
setUpdateStatus('error');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtering Logic
|
||||
const showPlaylists = activeFilter === 'all' || activeFilter === 'playlists';
|
||||
const showArtists = activeFilter === 'all' || activeFilter === 'artists';
|
||||
const showAlbums = activeFilter === 'all' || activeFilter === 'albums';
|
||||
|
||||
const artists = libraryItems.filter(i => i.type === 'Artist');
|
||||
const albums = libraryItems.filter(i => i.type === 'Album');
|
||||
const browsePlaylists = libraryItems.filter(i => i.type === 'Playlist');
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex flex-col w-[280px] bg-black h-full gap-2 p-2">
|
||||
<div className="bg-[#121212] rounded-lg p-4 flex flex-col gap-4">
|
||||
{/* Logo replaces Home link */}
|
||||
<Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||
<Logo />
|
||||
</Link>
|
||||
<Link href="/search" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||
<Search className="w-6 h-6" />
|
||||
<span className="font-bold">Search</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden">
|
||||
<div className="p-4 shadow-md z-10">
|
||||
<div className="flex items-center justify-between text-spotify-text-muted mb-4">
|
||||
<Link href="/library" className="flex items-center gap-2 hover:text-white transition cursor-pointer">
|
||||
<Library className="w-6 h-6" />
|
||||
<span className="font-bold">Your Library</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setIsCreateModalOpen(true)} className="hover:text-white hover:scale-110 transition">
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mt-4 overflow-x-auto no-scrollbar">
|
||||
{['Playlists', 'Artists', 'Albums'].map((filter) => {
|
||||
const key = filter.toLowerCase() as any;
|
||||
const isActive = activeFilter === key;
|
||||
return (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setActiveFilter(isActive ? 'all' : key)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition whitespace-nowrap ${isActive ? 'bg-white text-black' : 'bg-[#2a2a2a] text-white hover:bg-[#3a3a3a]'}`}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 no-scrollbar">
|
||||
{/* Liked Songs (Always top if 'Playlists' or 'All') */}
|
||||
{showPlaylists && (
|
||||
<Link href="/collection/tracks">
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer group mb-2">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-700 to-blue-300 rounded flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-white fill-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">Liked Songs</h3>
|
||||
<p className="text-sm text-spotify-text-muted">Playlist • {likedTracks.size} songs</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Playlists */}
|
||||
{showPlaylists && userPlaylists.map((playlist) => (
|
||||
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Playlist • You</p>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => handleDeletePlaylist(e, playlist.id)}
|
||||
className="absolute right-2 text-zinc-400 hover:text-white opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Fake/Browse Playlists */}
|
||||
{showPlaylists && browsePlaylists.map((playlist) => (
|
||||
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Playlist • Made for you</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Artists */}
|
||||
{showArtists && artists.map((artist) => (
|
||||
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<CoverImage
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{artist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Artist</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Albums */}
|
||||
{showAlbums && albums.map((album) => (
|
||||
<Link href={`/search?q=${encodeURIComponent(album.title)}`} key={album.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
fallbackText="💿"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{album.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Album • {album.creator || 'Spotify'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Section */}
|
||||
<div className="bg-[#121212] rounded-lg p-2 mt-auto">
|
||||
<button
|
||||
onClick={handleUpdateYtdlp}
|
||||
disabled={isUpdating}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-md transition-all duration-300 ${updateStatus === 'success' ? 'bg-green-600/20 text-green-400' :
|
||||
updateStatus === 'error' ? 'bg-red-600/20 text-red-400' :
|
||||
'text-spotify-text-muted hover:text-white hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
title="Update Core (yt-dlp) to fix playback errors"
|
||||
>
|
||||
<RefreshCcw className={`w-5 h-5 ${isUpdating ? 'animate-spin' : ''}`} />
|
||||
<span className="text-sm font-bold">
|
||||
{updateStatus === 'loading' ? 'Updating...' :
|
||||
updateStatus === 'success' ? 'Core Updated!' :
|
||||
updateStatus === 'error' ? 'Update Failed' : 'Update Core'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreatePlaylistModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreatePlaylist}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
import { Home, Search, Library, Plus, Heart, Settings } from "lucide-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { usePlayer } from "../context/PlayerContext";
|
||||
import { useLibrary } from "../context/LibraryContext";
|
||||
import { useState } from "react";
|
||||
import CreatePlaylistModal from "./CreatePlaylistModal";
|
||||
import { dbService } from "../services/db";
|
||||
import Logo from "./Logo";
|
||||
import CoverImage from "./CoverImage";
|
||||
import SettingsModal from "./SettingsModal";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { likedTracks } = usePlayer();
|
||||
const { userPlaylists, libraryItems, refreshLibrary, activeFilter, setActiveFilter } = useLibrary();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
const handleCreatePlaylist = async (name: string) => {
|
||||
await dbService.createPlaylist(name);
|
||||
refreshLibrary();
|
||||
};
|
||||
|
||||
const handleDeletePlaylist = async (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (confirm("Delete this playlist?")) {
|
||||
await dbService.deletePlaylist(id);
|
||||
refreshLibrary();
|
||||
}
|
||||
};
|
||||
|
||||
// Filtering Logic
|
||||
const showPlaylists = activeFilter === 'all' || activeFilter === 'playlists';
|
||||
const showArtists = activeFilter === 'all' || activeFilter === 'artists';
|
||||
const showAlbums = activeFilter === 'all' || activeFilter === 'albums';
|
||||
|
||||
const artists = libraryItems.filter(i => i.type === 'Artist');
|
||||
const albums = libraryItems.filter(i => i.type === 'Album');
|
||||
const browsePlaylists = libraryItems.filter(i => i.type === 'Playlist');
|
||||
|
||||
return (
|
||||
<aside className="hidden fold:flex flex-col w-[240px] bg-spotify-sidebar backdrop-blur-xl border-r border-white/5 h-full gap-2 p-4 flex-shrink-0 transition-colors duration-500 z-50">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-1 text-white hover:text-white transition cursor-pointer px-2">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
to="/"
|
||||
className={`flex items-center gap-4 p-3 rounded-lg transition cursor-pointer font-medium ${isActive('/') ? 'bg-spotify-card-hover text-spotify-highlight' : 'text-neutral-400 hover:text-white hover:bg-spotify-card-hover'}`}
|
||||
>
|
||||
<Home className={`w-6 h-6 ${isActive('/') ? 'fill-current' : ''}`} />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/search"
|
||||
className={`flex items-center gap-4 p-3 rounded-lg transition cursor-pointer font-medium ${isActive('/search') ? 'bg-spotify-card-hover text-spotify-highlight' : 'text-neutral-400 hover:text-white hover:bg-spotify-card-hover'}`}
|
||||
>
|
||||
<Search className={`w-6 h-6 ${isActive('/search') ? 'stroke-[2.5px]' : ''}`} />
|
||||
<span>Search</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/library"
|
||||
className={`flex items-center gap-4 p-3 rounded-lg transition cursor-pointer font-medium ${isActive('/library') ? 'bg-spotify-card-hover text-spotify-highlight' : 'text-neutral-400 hover:text-white hover:bg-spotify-card-hover'}`}
|
||||
>
|
||||
<Library className={`w-6 h-6 ${isActive('/library') ? 'fill-current' : ''}`} />
|
||||
<span>Library</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 my-2 mx-2"></div>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-2 mb-4">
|
||||
{/* Filters - YTM Style (Pills) */}
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mt-4 overflow-x-auto no-scrollbar">
|
||||
{(['Playlists', 'Artists', 'Albums'] as const).map((filter) => {
|
||||
const key = filter.toLowerCase() as 'playlists' | 'artists' | 'albums';
|
||||
const isActive = activeFilter === key;
|
||||
return (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setActiveFilter(isActive ? 'all' : key)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition whitespace-nowrap ${isActive ? 'bg-white text-black' : 'bg-spotify-card text-white hover:bg-spotify-card-hover'}`}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 no-scrollbar">
|
||||
{/* Liked Songs */}
|
||||
{showPlaylists && (
|
||||
<Link to="/collection/tracks">
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer group mb-2">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-700 to-blue-300 rounded flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-white fill-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">Liked Songs</h3>
|
||||
<p className="text-sm text-neutral-400">Playlist • {likedTracks.size} songs</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Playlists */}
|
||||
{showPlaylists && userPlaylists.map((playlist) => (
|
||||
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<Link to={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-neutral-400 truncate">Playlist • You</p>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => handleDeletePlaylist(e, playlist.id)}
|
||||
className="absolute right-2 text-zinc-400 hover:text-white opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Browse Playlists */}
|
||||
{showPlaylists && browsePlaylists.slice(0, 10).map((playlist) => (
|
||||
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<Link to={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-neutral-400 truncate">Playlist • Made for you</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Artists */}
|
||||
{showArtists && artists.slice(0, 10).map((artist) => (
|
||||
<Link to={`/artist/${encodeURIComponent(artist.title)}`} key={artist.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<CoverImage
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-12 h-12 rounded-md"
|
||||
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{artist.title}</h3>
|
||||
<p className="text-sm text-neutral-400 truncate">Artist</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Albums */}
|
||||
{showAlbums && albums.slice(0, 10).map((album) => (
|
||||
<Link to={`/search?q=${encodeURIComponent(album.title)}`} key={album.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-12 h-12 rounded"
|
||||
fallbackText="💿"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{album.title}</h3>
|
||||
<p className="text-sm text-neutral-400 truncate">Album • {album.creator || 'Spotify'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Section */}
|
||||
<div className="bg-spotify-card rounded-lg p-2 mt-auto">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-md transition-all duration-300 text-neutral-400 hover:text-white hover:bg-spotify-card-hover"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="text-sm font-bold">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreatePlaylistModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreatePlaylist}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import React from 'react';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -7,7 +5,8 @@ interface SkeletonProps {
|
|||
export default function Skeleton({ className = "" }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse bg-gray-700/50 rounded-md ${className}`}
|
||||
className={`bg-neutral-800 animate-pulse rounded ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
111
frontend-vite/src/components/TechSpecs.tsx
Normal file
111
frontend-vite/src/components/TechSpecs.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { AudioQuality } from '../types';
|
||||
|
||||
interface TechSpecsProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
quality: AudioQuality | null;
|
||||
trackTitle: string;
|
||||
}
|
||||
|
||||
export default function TechSpecs({ isOpen, onClose, quality, trackTitle }: TechSpecsProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const formatBitrate = (bitrate: number) => {
|
||||
return bitrate > 1000 ? `${(bitrate / 1000).toFixed(0)} kbps` : `${bitrate} bps`;
|
||||
};
|
||||
|
||||
const formatSampleRate = (rate: number) => {
|
||||
return rate >= 1000 ? `${(rate / 1000).toFixed(1)} kHz` : `${rate} Hz`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gradient-to-b from-[#2a2a2a] to-[#1a1a1a] rounded-xl w-full max-w-sm shadow-2xl animate-in overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">Audio Quality</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-white transition"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-400 mt-1 truncate">{trackTitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Specs Grid */}
|
||||
<div className="p-6 space-y-4">
|
||||
{quality ? (
|
||||
<>
|
||||
{/* Hi-Res Badge */}
|
||||
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<div className="w-8 h-8 bg-green-500 rounded flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-black">
|
||||
<path d="M9 18V5l12-2v13M9 9h12" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-green-400">HI-RES AUDIO</p>
|
||||
<p className="text-xs text-neutral-400">Lossless Quality</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specs List */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Format</span>
|
||||
<span className="font-medium">{quality.format}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Codec</span>
|
||||
<span className="font-medium">{quality.codec || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Sample Rate</span>
|
||||
<span className="font-medium">{formatSampleRate(quality.sampleRate)}</span>
|
||||
</div>
|
||||
{quality.bitDepth && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Bit Depth</span>
|
||||
<span className="font-medium">{quality.bitDepth}-bit</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Bitrate</span>
|
||||
<span className="font-medium">{formatBitrate(quality.bitrate)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Channels</span>
|
||||
<span className="font-medium">{quality.channels === 2 ? 'Stereo' : quality.channels === 1 ? 'Mono' : `${quality.channels}ch`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-neutral-700 rounded-full flex items-center justify-center mx-auto mb-3 animate-pulse">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-neutral-400">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-neutral-400">Analyzing audio...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
frontend-vite/src/context/LibraryContext.tsx
Normal file
126
frontend-vite/src/context/LibraryContext.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import { dbService, Playlist } from "../services/db";
|
||||
import { libraryService } from "../services/library";
|
||||
import { Track } from "../types";
|
||||
|
||||
type FilterType = 'all' | 'playlists' | 'artists' | 'albums';
|
||||
|
||||
interface LibraryContextType {
|
||||
userPlaylists: Playlist[];
|
||||
libraryItems: LibraryItem[];
|
||||
activeFilter: FilterType;
|
||||
setActiveFilter: (filter: FilterType) => void;
|
||||
refreshLibrary: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface LibraryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'Playlist' | 'Artist' | 'Album';
|
||||
cover_url?: string;
|
||||
creator?: string;
|
||||
tracks?: Track[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const LibraryContext = createContext<LibraryContextType | undefined>(undefined);
|
||||
|
||||
export function LibraryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]);
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||
|
||||
const fetchAllData = async () => {
|
||||
try {
|
||||
// 1. User Playlists from IndexedDB
|
||||
const playlists = await dbService.getPlaylists() || [];
|
||||
setUserPlaylists(playlists);
|
||||
|
||||
// 2. Browse Content from Backend
|
||||
const browse = await libraryService.getBrowseContent();
|
||||
|
||||
// Explicitly handle categories
|
||||
const seedPlaylists = browse['Top Playlists'] || [];
|
||||
const seedAlbums = browse['Top Albums'] || [];
|
||||
const seedArtists = browse['Popular Artists'] || [];
|
||||
|
||||
// 3. Extract metadata from tracks (Only if we have tracks to parse)
|
||||
// But mostly we typically rely on Seed Data now.
|
||||
// We can still try to discover more from whatever tracks we have.
|
||||
|
||||
const allItems: LibraryItem[] = [];
|
||||
|
||||
// Add Seed Artists
|
||||
seedArtists.forEach(a => {
|
||||
allItems.push({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
type: 'Artist',
|
||||
cover_url: a.cover_url,
|
||||
description: 'Artist'
|
||||
});
|
||||
});
|
||||
|
||||
// Add Seed Albums
|
||||
seedAlbums.forEach(a => {
|
||||
allItems.push({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
type: 'Album',
|
||||
cover_url: a.cover_url,
|
||||
creator: a.creator,
|
||||
description: a.description
|
||||
});
|
||||
});
|
||||
|
||||
// Add Seed Playlists
|
||||
seedPlaylists.forEach(p => {
|
||||
allItems.push({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
type: 'Playlist',
|
||||
cover_url: p.cover_url,
|
||||
description: p.description,
|
||||
tracks: p.tracks
|
||||
});
|
||||
});
|
||||
|
||||
// Deduplicate
|
||||
const seenIds = new Set();
|
||||
const uniqueItems = allItems.filter(item => {
|
||||
if (seenIds.has(item.id)) return false;
|
||||
seenIds.add(item.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
setLibraryItems(uniqueItems);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LibraryContext.Provider value={{
|
||||
userPlaylists,
|
||||
libraryItems,
|
||||
activeFilter,
|
||||
setActiveFilter,
|
||||
refreshLibrary: fetchAllData
|
||||
}}>
|
||||
{children}
|
||||
</LibraryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLibrary() {
|
||||
const context = useContext(LibraryContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useLibrary must be used within a LibraryProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,279 +1,220 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { dbService } from "@/services/db";
|
||||
import { Track, AudioQuality } from "@/types";
|
||||
import * as mm from 'music-metadata-browser';
|
||||
|
||||
interface PlayerContextType {
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
likedTracks: Set<string>;
|
||||
likedTracksData: Track[];
|
||||
shuffle: boolean;
|
||||
repeatMode: 'none' | 'all' | 'one';
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
togglePlay: () => void;
|
||||
nextTrack: () => void;
|
||||
prevTrack: () => void;
|
||||
toggleShuffle: () => void;
|
||||
toggleRepeat: () => void;
|
||||
setBuffering: (state: boolean) => void;
|
||||
toggleLike: (track: Track) => void;
|
||||
playHistory: Track[];
|
||||
audioQuality: AudioQuality | null;
|
||||
// Lyrics panel state
|
||||
isLyricsOpen: boolean;
|
||||
toggleLyrics: () => void;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
|
||||
|
||||
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [likedTracks, setLikedTracks] = useState<Set<string>>(new Set());
|
||||
const [likedTracksData, setLikedTracksData] = useState<Track[]>([]);
|
||||
|
||||
// Audio Engine State
|
||||
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
|
||||
const [preloadedBlobs, setPreloadedBlobs] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Queue State
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||
const [shuffle, setShuffle] = useState(false);
|
||||
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none');
|
||||
|
||||
// History State
|
||||
const [playHistory, setPlayHistory] = useState<Track[]>([]);
|
||||
|
||||
// Lyrics Panel State
|
||||
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
|
||||
const toggleLyrics = () => setIsLyricsOpen(prev => !prev);
|
||||
|
||||
// Load Likes from DB
|
||||
useEffect(() => {
|
||||
dbService.getLikedSongs().then(tracks => {
|
||||
setLikedTracks(new Set(tracks.map(t => t.id)));
|
||||
setLikedTracksData(tracks);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load History from LocalStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('playHistory');
|
||||
if (saved) {
|
||||
setPlayHistory(JSON.parse(saved));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load history", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save History
|
||||
useEffect(() => {
|
||||
localStorage.setItem('playHistory', JSON.stringify(playHistory));
|
||||
}, [playHistory]);
|
||||
|
||||
// Metadata & Preloading Effect
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
|
||||
// 1. Reset Quality
|
||||
setAudioQuality(null);
|
||||
|
||||
// 2. Parse Metadata for Current Track
|
||||
const parseMetadata = async () => {
|
||||
try {
|
||||
// Skip metadata parsing for backend streams AND external URLs (YouTube) to avoid CORS/Double-fetch
|
||||
if (currentTrack.url && (currentTrack.url.includes('/api/stream') || currentTrack.url.startsWith('http'))) {
|
||||
setAudioQuality({
|
||||
format: 'WEBM/OPUS', // YT Music typically
|
||||
sampleRate: 48000,
|
||||
bitrate: 128000,
|
||||
channels: 2,
|
||||
codec: 'Opus'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTrack.url) {
|
||||
// Note: In a real scenario, we might need a proxy or CORS-enabled server.
|
||||
// music-metadata-browser fetches the file.
|
||||
const metadata = await mm.fetchFromUrl(currentTrack.url);
|
||||
setAudioQuality({
|
||||
format: metadata.format.container || 'Unknown',
|
||||
sampleRate: metadata.format.sampleRate || 44100,
|
||||
bitDepth: metadata.format.bitsPerSample,
|
||||
bitrate: metadata.format.bitrate || 0,
|
||||
channels: metadata.format.numberOfChannels || 2,
|
||||
codec: metadata.format.codec
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse metadata", e);
|
||||
// Fallback mock if parsing fails (likely due to CORS on sample URL)
|
||||
setAudioQuality({
|
||||
format: 'MP3',
|
||||
sampleRate: 44100,
|
||||
bitrate: 320000,
|
||||
channels: 2,
|
||||
codec: 'MPEG-1 Layer 3'
|
||||
});
|
||||
}
|
||||
};
|
||||
parseMetadata();
|
||||
|
||||
// 3. Smart Buffering (Preload Next 2 Tracks)
|
||||
const preloadNext = async () => {
|
||||
if (queue.length === 0) return;
|
||||
const index = queue.findIndex(t => t.id === currentTrack.id);
|
||||
if (index === -1) return;
|
||||
|
||||
const nextTracks = queue.slice(index + 1, index + 3);
|
||||
nextTracks.forEach(async (track) => {
|
||||
if (!preloadedBlobs.has(track.id) && track.url) {
|
||||
try {
|
||||
// Construct the correct stream URL for preloading if it's external
|
||||
const fetchUrl = track.url.startsWith('http') ? `/api/stream?id=${track.id}` : track.url;
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
if (!res.ok) throw new Error("Fetch failed");
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreloadedBlobs(prev => new Map(prev).set(track.id, blobUrl));
|
||||
console.log(`Buffered ${track.title}`);
|
||||
} catch (e) {
|
||||
// console.warn(`Failed to buffer ${track.title}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
preloadNext();
|
||||
|
||||
}, [currentTrack, queue, preloadedBlobs]);
|
||||
|
||||
|
||||
const playTrack = (track: Track, newQueue?: Track[]) => {
|
||||
if (currentTrack?.id !== track.id) {
|
||||
setIsBuffering(true);
|
||||
|
||||
// Add to History (prevent duplicates at top)
|
||||
setPlayHistory(prev => {
|
||||
const filtered = prev.filter(t => t.id !== track.id);
|
||||
return [track, ...filtered].slice(0, 20); // Keep last 20
|
||||
});
|
||||
}
|
||||
setCurrentTrack(track);
|
||||
setIsPlaying(true);
|
||||
|
||||
if (newQueue) {
|
||||
setQueue(newQueue);
|
||||
const index = newQueue.findIndex(t => t.id === track.id);
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
setIsPlaying((prev) => !prev);
|
||||
};
|
||||
|
||||
const nextTrack = () => {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
let nextIndex = currentIndex + 1;
|
||||
if (shuffle) {
|
||||
nextIndex = Math.floor(Math.random() * queue.length);
|
||||
} else if (nextIndex >= queue.length) {
|
||||
if (repeatMode === 'all') nextIndex = 0;
|
||||
else return; // Stop if end of queue and no repeat
|
||||
}
|
||||
|
||||
playTrack(queue[nextIndex]);
|
||||
setCurrentIndex(nextIndex);
|
||||
};
|
||||
|
||||
const prevTrack = () => {
|
||||
if (queue.length === 0) return;
|
||||
let prevIndex = currentIndex - 1;
|
||||
if (prevIndex < 0) prevIndex = 0; // Or wrap around if desired
|
||||
playTrack(queue[prevIndex]);
|
||||
setCurrentIndex(prevIndex);
|
||||
};
|
||||
|
||||
const toggleShuffle = () => setShuffle(prev => !prev);
|
||||
|
||||
const toggleRepeat = () => {
|
||||
setRepeatMode(prev => {
|
||||
if (prev === 'none') return 'all';
|
||||
if (prev === 'all') return 'one';
|
||||
return 'none';
|
||||
});
|
||||
};
|
||||
|
||||
const setBuffering = (state: boolean) => setIsBuffering(state);
|
||||
|
||||
const toggleLike = async (track: Track) => {
|
||||
const isNowLiked = await dbService.toggleLike(track);
|
||||
|
||||
setLikedTracks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (isNowLiked) next.add(track.id);
|
||||
else next.delete(track.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
setLikedTracksData(prev => {
|
||||
if (!isNowLiked) {
|
||||
return prev.filter(t => t.id !== track.id);
|
||||
} else {
|
||||
return [...prev, track];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const effectiveCurrentTrack = currentTrack ? {
|
||||
...currentTrack,
|
||||
// improved URL logic: usage of backend API if no local blob
|
||||
url: preloadedBlobs.get(currentTrack.id) ||
|
||||
(currentTrack.url && currentTrack.url.startsWith('/') ? currentTrack.url : `/api/stream?id=${currentTrack.id}`)
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<PlayerContext.Provider value={{
|
||||
currentTrack: effectiveCurrentTrack,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
likedTracks,
|
||||
likedTracksData,
|
||||
shuffle,
|
||||
repeatMode,
|
||||
playTrack,
|
||||
togglePlay,
|
||||
nextTrack,
|
||||
prevTrack,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setBuffering,
|
||||
toggleLike,
|
||||
playHistory,
|
||||
audioQuality,
|
||||
isLyricsOpen,
|
||||
toggleLyrics
|
||||
}}>
|
||||
{children}
|
||||
</PlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlayer() {
|
||||
const context = useContext(PlayerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePlayer must be used within a PlayerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { dbService } from "../services/db";
|
||||
import { Track, AudioQuality } from "../types";
|
||||
|
||||
interface PlayerContextType {
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
likedTracks: Set<string>;
|
||||
likedTracksData: Track[];
|
||||
shuffle: boolean;
|
||||
repeatMode: 'none' | 'all' | 'one';
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
togglePlay: () => void;
|
||||
nextTrack: () => void;
|
||||
prevTrack: () => void;
|
||||
toggleShuffle: () => void;
|
||||
toggleRepeat: () => void;
|
||||
setBuffering: (state: boolean) => void;
|
||||
toggleLike: (track: Track) => void;
|
||||
playHistory: Track[];
|
||||
audioQuality: AudioQuality | null;
|
||||
isLyricsOpen: boolean;
|
||||
toggleLyrics: () => void;
|
||||
closeLyrics: () => void;
|
||||
openLyrics: () => void;
|
||||
queue: Track[];
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
|
||||
|
||||
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [likedTracks, setLikedTracks] = useState<Set<string>>(new Set());
|
||||
const [likedTracksData, setLikedTracksData] = useState<Track[]>([]);
|
||||
|
||||
// Audio Engine State
|
||||
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
|
||||
|
||||
// Queue State
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||
const [shuffle, setShuffle] = useState(false);
|
||||
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none');
|
||||
|
||||
// History State
|
||||
const [playHistory, setPlayHistory] = useState<Track[]>([]);
|
||||
|
||||
// Lyrics Panel State
|
||||
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
|
||||
const toggleLyrics = () => setIsLyricsOpen(prev => !prev);
|
||||
const closeLyrics = () => setIsLyricsOpen(false);
|
||||
const openLyrics = () => setIsLyricsOpen(true);
|
||||
|
||||
// Load Likes from DB
|
||||
useEffect(() => {
|
||||
dbService.getLikedSongs().then(tracks => {
|
||||
setLikedTracks(new Set(tracks.map(t => t.id)));
|
||||
setLikedTracksData(tracks);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load History from LocalStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('playHistory');
|
||||
if (saved) {
|
||||
setPlayHistory(JSON.parse(saved));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load history", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save History
|
||||
useEffect(() => {
|
||||
localStorage.setItem('playHistory', JSON.stringify(playHistory));
|
||||
}, [playHistory]);
|
||||
|
||||
// Set default audio quality for streams
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
setAudioQuality(null);
|
||||
|
||||
// Default quality for YouTube streams
|
||||
if (currentTrack.url && (currentTrack.url.includes('/api/stream') || currentTrack.url.startsWith('http'))) {
|
||||
setAudioQuality({
|
||||
format: 'WEBM/OPUS',
|
||||
sampleRate: 48000,
|
||||
bitrate: 128000,
|
||||
channels: 2,
|
||||
codec: 'Opus'
|
||||
});
|
||||
}
|
||||
}, [currentTrack]);
|
||||
|
||||
const playTrack = (track: Track, newQueue?: Track[]) => {
|
||||
if (currentTrack?.id !== track.id) {
|
||||
setIsBuffering(true);
|
||||
|
||||
// Add to History (prevent duplicates at top)
|
||||
setPlayHistory(prev => {
|
||||
const filtered = prev.filter(t => t.id !== track.id);
|
||||
return [track, ...filtered].slice(0, 20);
|
||||
});
|
||||
}
|
||||
setCurrentTrack(track);
|
||||
setIsPlaying(true);
|
||||
|
||||
if (newQueue) {
|
||||
setQueue(newQueue);
|
||||
const index = newQueue.findIndex(t => t.id === track.id);
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
setIsPlaying((prev) => !prev);
|
||||
};
|
||||
|
||||
const nextTrack = () => {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
let nextIndex = currentIndex + 1;
|
||||
if (shuffle) {
|
||||
nextIndex = Math.floor(Math.random() * queue.length);
|
||||
} else if (nextIndex >= queue.length) {
|
||||
if (repeatMode === 'all') nextIndex = 0;
|
||||
else return;
|
||||
}
|
||||
|
||||
playTrack(queue[nextIndex]);
|
||||
setCurrentIndex(nextIndex);
|
||||
};
|
||||
|
||||
const prevTrack = () => {
|
||||
if (queue.length === 0) return;
|
||||
let prevIndex = currentIndex - 1;
|
||||
if (prevIndex < 0) prevIndex = 0;
|
||||
playTrack(queue[prevIndex]);
|
||||
setCurrentIndex(prevIndex);
|
||||
};
|
||||
|
||||
const toggleShuffle = () => setShuffle(prev => !prev);
|
||||
|
||||
const toggleRepeat = () => {
|
||||
setRepeatMode(prev => {
|
||||
if (prev === 'none') return 'all';
|
||||
if (prev === 'all') return 'one';
|
||||
return 'none';
|
||||
});
|
||||
};
|
||||
|
||||
const setBufferingState = (state: boolean) => setIsBuffering(state);
|
||||
|
||||
const toggleLike = async (track: Track) => {
|
||||
const isNowLiked = await dbService.toggleLike(track);
|
||||
|
||||
setLikedTracks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (isNowLiked) next.add(track.id);
|
||||
else next.delete(track.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
setLikedTracksData(prev => {
|
||||
if (!isNowLiked) {
|
||||
return prev.filter(t => t.id !== track.id);
|
||||
} else {
|
||||
return [...prev, track];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const effectiveCurrentTrack = currentTrack ? {
|
||||
...currentTrack,
|
||||
url: currentTrack.url && (currentTrack.url.startsWith('/') || currentTrack.url.startsWith('http'))
|
||||
? currentTrack.url
|
||||
: `/api/stream/${currentTrack.id}`
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<PlayerContext.Provider value={{
|
||||
currentTrack: effectiveCurrentTrack,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
likedTracks,
|
||||
likedTracksData,
|
||||
shuffle,
|
||||
repeatMode,
|
||||
playTrack,
|
||||
togglePlay,
|
||||
nextTrack,
|
||||
prevTrack,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setBuffering: setBufferingState,
|
||||
toggleLike,
|
||||
playHistory,
|
||||
audioQuality,
|
||||
isLyricsOpen,
|
||||
toggleLyrics,
|
||||
closeLyrics,
|
||||
openLyrics,
|
||||
queue
|
||||
}}>
|
||||
{children}
|
||||
</PlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlayer() {
|
||||
const context = useContext(PlayerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePlayer must be used within a PlayerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
41
frontend-vite/src/context/ThemeContext.tsx
Normal file
41
frontend-vite/src/context/ThemeContext.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'spotify' | 'apple';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('app-theme');
|
||||
return (saved as Theme) || 'spotify';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('app-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
839
frontend-vite/src/data/seed_data.ts
Normal file
839
frontend-vite/src/data/seed_data.ts
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
import { StaticPlaylist } from "../types";
|
||||
|
||||
export const GENERATED_CONTENT: Record<string, StaticPlaylist> = {
|
||||
"Noo Phước Thịnh": {
|
||||
"id": "artist-Noo-Phước-Thịnh",
|
||||
"title": "Noo Phước Thịnh",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/_E-7A81Ac8U/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBnhD_3sy8Si9qk-sZumXoebde6OA",
|
||||
"type": "Artist",
|
||||
"creator": "Noo Phước Thịnh",
|
||||
"tracks": []
|
||||
},
|
||||
"Obito": {
|
||||
"id": "artist-Obito",
|
||||
"title": "Obito",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/lUrmyU1cnxU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLArOPSXlEyOow0yD7n6i0_4q9BnAQ",
|
||||
"type": "Artist",
|
||||
"creator": "Obito",
|
||||
"tracks": []
|
||||
},
|
||||
"BLACKPINK": {
|
||||
"id": "artist-BLACKPINK",
|
||||
"title": "BLACKPINK",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/ioNng23DkIM/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBnvs20jQWj1VeUiDa_HDmpI5Mzog",
|
||||
"type": "Artist",
|
||||
"creator": "BLACKPINK",
|
||||
"tracks": []
|
||||
},
|
||||
"Soobin Hoàng Sơn": {
|
||||
"id": "artist-Soobin-Hoàng-Sơn",
|
||||
"title": "Soobin Hoàng Sơn",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/uFHyAxPhCKk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAC54CcJKkIiD1-WzD21wfXlt76Bw",
|
||||
"type": "Artist",
|
||||
"creator": "Soobin Hoàng Sơn",
|
||||
"tracks": []
|
||||
},
|
||||
"Đông Nhi": {
|
||||
"id": "artist-Đông-Nhi",
|
||||
"title": "Đông Nhi",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/7i6icKvZuuQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDpllCsFsLto35dQJ0kSN_J9OZnlg",
|
||||
"type": "Artist",
|
||||
"creator": "Đông Nhi",
|
||||
"tracks": []
|
||||
},
|
||||
"Min": {
|
||||
"id": "artist-Min",
|
||||
"title": "Min",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/VH3mWd28Ndg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDL1IMnoWCgq4vg4OXk27kO31h3VA",
|
||||
"type": "Artist",
|
||||
"creator": "Min",
|
||||
"tracks": []
|
||||
},
|
||||
"Justin Bieber": {
|
||||
"id": "artist-Justin-Bieber",
|
||||
"title": "Justin Bieber",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/fXivMSJm_kA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCUtGsU7kyB52jWqGkqkXXXWTjQZw",
|
||||
"type": "Artist",
|
||||
"creator": "Justin Bieber",
|
||||
"tracks": []
|
||||
},
|
||||
"Billie Eilish": {
|
||||
"id": "artist-Billie-Eilish",
|
||||
"title": "Billie Eilish",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/V9PVRfjEBTI/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBFbDPFc1NFarkEge0ZOXKeg7gJrw",
|
||||
"type": "Artist",
|
||||
"creator": "Billie Eilish",
|
||||
"tracks": []
|
||||
},
|
||||
"Kendrick Lamar": {
|
||||
"id": "artist-Kendrick-Lamar",
|
||||
"title": "Kendrick Lamar",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/U8F5G5wR1mk/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhlIGUoZTAP&rs=AOn4CLCAriNIWkifAul8L6NTgFvEqOrXWQ",
|
||||
"type": "Artist",
|
||||
"creator": "Kendrick Lamar",
|
||||
"tracks": []
|
||||
},
|
||||
"Olivia Rodrigo": {
|
||||
"id": "artist-Olivia-Rodrigo",
|
||||
"title": "Olivia Rodrigo",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/cii6ruuycQA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAtzRzOkqh9tmiujehjmxXx8dbFRw",
|
||||
"type": "Artist",
|
||||
"creator": "Olivia Rodrigo",
|
||||
"tracks": []
|
||||
},
|
||||
"Phan Mạnh Quỳnh": {
|
||||
"id": "artist-Phan-Mạnh-Quỳnh",
|
||||
"title": "Phan Mạnh Quỳnh",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/JlQSAS_ccxw/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBNpPsLgPOV9OSjemdScHSsBLCbzg",
|
||||
"type": "Artist",
|
||||
"creator": "Phan Mạnh Quỳnh",
|
||||
"tracks": []
|
||||
},
|
||||
"Ariana Grande": {
|
||||
"id": "artist-Ariana-Grande",
|
||||
"title": "Ariana Grande",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/hKwizc5nsWY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBD5zUOR_8GLbxh6gvg7wjbL3veEA",
|
||||
"type": "Artist",
|
||||
"creator": "Ariana Grande",
|
||||
"tracks": []
|
||||
},
|
||||
"Post Malone": {
|
||||
"id": "artist-Post-Malone",
|
||||
"title": "Post Malone",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/7aekxC_monc/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB7edrWBLVQVm8m66dSpRPjAVMV9A",
|
||||
"type": "Artist",
|
||||
"creator": "Post Malone",
|
||||
"tracks": []
|
||||
},
|
||||
"BigDaddy": {
|
||||
"id": "artist-BigDaddy",
|
||||
"title": "BigDaddy",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/o3Ui6lbvRSo/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDgLlalmwcftOI_eCf9g5NypoRPig",
|
||||
"type": "Artist",
|
||||
"creator": "BigDaddy",
|
||||
"tracks": []
|
||||
},
|
||||
"Drake": {
|
||||
"id": "artist-Drake",
|
||||
"title": "Drake",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/xpVfcZ0ZcFM/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDtjCxJ3y8riQElZ7JCOJu058rftA",
|
||||
"type": "Artist",
|
||||
"creator": "Drake",
|
||||
"tracks": []
|
||||
},
|
||||
"JustaTee": {
|
||||
"id": "artist-JustaTee",
|
||||
"title": "JustaTee",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/FfUeTzwYUJk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC5YpThVpv3NoAwHQ8QiT0z9XFSvA",
|
||||
"type": "Artist",
|
||||
"creator": "JustaTee",
|
||||
"tracks": []
|
||||
},
|
||||
"Đen Vâu": {
|
||||
"id": "artist-Đen-Vâu",
|
||||
"title": "Đen Vâu",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Hqmbo0ROBQw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA9lSyi9M9k24HQhgobFPMEgQEKVA",
|
||||
"type": "Artist",
|
||||
"creator": "Đen Vâu",
|
||||
"tracks": []
|
||||
},
|
||||
"Emily": {
|
||||
"id": "artist-Emily",
|
||||
"title": "Emily",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/ixleWbPl4is/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARgTIEoofzAP&rs=AOn4CLAuTsNzWFh-HMrdg2tdoS-tMjDeOA",
|
||||
"type": "Artist",
|
||||
"creator": "Emily",
|
||||
"tracks": []
|
||||
},
|
||||
"LyLy": {
|
||||
"id": "artist-LyLy",
|
||||
"title": "LyLy",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/IpniN1Wq68Y/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDtzI23cjX9W-4LliFstESlIpYczw",
|
||||
"type": "Artist",
|
||||
"creator": "LyLy",
|
||||
"tracks": []
|
||||
},
|
||||
"The Weeknd": {
|
||||
"id": "artist-The-Weeknd",
|
||||
"title": "The Weeknd",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Mx92lTYxrJQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAL8NG5JoNndy3_nxIFDT3q4aD9Ow",
|
||||
"type": "Artist",
|
||||
"creator": "The Weeknd",
|
||||
"tracks": []
|
||||
},
|
||||
"Karik": {
|
||||
"id": "artist-Karik",
|
||||
"title": "Karik",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/9XoOCDoxyKw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDo_mVzAjDx0GIsdfDFFg9JrCjiJw",
|
||||
"type": "Artist",
|
||||
"creator": "Karik",
|
||||
"tracks": []
|
||||
},
|
||||
"Sơn Tùng M-TP": {
|
||||
"id": "artist-Sơn-Tùng-M-TP",
|
||||
"title": "Sơn Tùng M-TP",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAKKkxyzooXkasYWnLdJchzMqBICg",
|
||||
"type": "Artist",
|
||||
"creator": "Sơn Tùng M-TP",
|
||||
"tracks": []
|
||||
},
|
||||
"Mỹ Tâm": {
|
||||
"id": "artist-Mỹ-Tâm",
|
||||
"title": "Mỹ Tâm",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/W4P8gl4dnrg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAv8Vt9mNePtnFl-zTX6WxA1_x8Wg",
|
||||
"type": "Artist",
|
||||
"creator": "Mỹ Tâm",
|
||||
"tracks": []
|
||||
},
|
||||
"MCK": {
|
||||
"id": "artist-MCK",
|
||||
"title": "MCK",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/09Mh7GgUFFA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAP2EG6hS5XOlG9Tc1tP4MfbkAdxg",
|
||||
"type": "Artist",
|
||||
"creator": "MCK",
|
||||
"tracks": []
|
||||
},
|
||||
"Wren Evans": {
|
||||
"id": "artist-Wren-Evans",
|
||||
"title": "Wren Evans",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/gLhchzCMXbo/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA-Dpfi7iIW3fURu7kI_9dPM3BXiQ",
|
||||
"type": "Artist",
|
||||
"creator": "Wren Evans",
|
||||
"tracks": []
|
||||
},
|
||||
"Rhymastic": {
|
||||
"id": "artist-Rhymastic",
|
||||
"title": "Rhymastic",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/uFHyAxPhCKk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAC54CcJKkIiD1-WzD21wfXlt76Bw",
|
||||
"type": "Artist",
|
||||
"creator": "Rhymastic",
|
||||
"tracks": []
|
||||
},
|
||||
"Mono": {
|
||||
"id": "artist-Mono",
|
||||
"title": "Mono",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/okz5RIZRT0U/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDb0vsqgOg9POWvlOYv9jQE_TRkgA",
|
||||
"type": "Artist",
|
||||
"creator": "Mono",
|
||||
"tracks": []
|
||||
},
|
||||
"Vũ.": {
|
||||
"id": "artist-Vũ.",
|
||||
"title": "Vũ.",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/xlXmeiUj5OA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDqYliVsJivgyxtkp_4EGmXYYQCxg",
|
||||
"type": "Artist",
|
||||
"creator": "Vũ.",
|
||||
"tracks": []
|
||||
},
|
||||
"Suboi": {
|
||||
"id": "artist-Suboi",
|
||||
"title": "Suboi",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/gERJjsDhe0g/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBpX23iaiDfN5Ee8tsTy7iIr87zFA",
|
||||
"type": "Artist",
|
||||
"creator": "Suboi",
|
||||
"tracks": []
|
||||
},
|
||||
"HIEUTHUHAI": {
|
||||
"id": "artist-HIEUTHUHAI",
|
||||
"title": "HIEUTHUHAI",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/NpI4TSgBVTw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA64o5gXesfyZgW0qj0F1K-b41lVw",
|
||||
"type": "Artist",
|
||||
"creator": "HIEUTHUHAI",
|
||||
"tracks": []
|
||||
},
|
||||
"Andree Right Hand": {
|
||||
"id": "artist-Andree-Right-Hand",
|
||||
"title": "Andree Right Hand",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/A-tX5PI3V0o/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBy2sihK_BXt3hOlWE5Gg9eA_DF8A",
|
||||
"type": "Artist",
|
||||
"creator": "Andree Right Hand",
|
||||
"tracks": []
|
||||
},
|
||||
"Ngọt": {
|
||||
"id": "artist-Ngọt",
|
||||
"title": "Ngọt",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/9mA7h1jfxc8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBzRl1JSQc3tKsg9oadC6pSyKPm7Q",
|
||||
"type": "Artist",
|
||||
"creator": "Ngọt",
|
||||
"tracks": []
|
||||
},
|
||||
"Taylor Swift": {
|
||||
"id": "artist-Taylor-Swift",
|
||||
"title": "Taylor Swift",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/ko70cExuzZM/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC1PgPkzBs6QEv04boPTkfKQfOAFQ",
|
||||
"type": "Artist",
|
||||
"creator": "Taylor Swift",
|
||||
"tracks": []
|
||||
},
|
||||
"Tlinh": {
|
||||
"id": "artist-Tlinh",
|
||||
"title": "Tlinh",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCAC0eQiNHs3k10YiyAkNMafKFASQ",
|
||||
"type": "Artist",
|
||||
"creator": "Tlinh",
|
||||
"tracks": []
|
||||
},
|
||||
"Hoàng Thùy Linh": {
|
||||
"id": "artist-Hoàng-Thùy-Linh",
|
||||
"title": "Hoàng Thùy Linh",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Q6ZNsHvspEg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB2MB7H38KeXhcPFSi5P4CUe18K3A",
|
||||
"type": "Artist",
|
||||
"creator": "Hoàng Thùy Linh",
|
||||
"tracks": []
|
||||
},
|
||||
"Orange": {
|
||||
"id": "artist-Orange",
|
||||
"title": "Orange",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/AaiZ0L7-JWE/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBN0-rbSO24JSR7fG5Tr-Dbv9nvzQ",
|
||||
"type": "Artist",
|
||||
"creator": "Orange",
|
||||
"tracks": []
|
||||
},
|
||||
"Charlie Puth": {
|
||||
"id": "artist-Charlie-Puth",
|
||||
"title": "Charlie Puth",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Nq3x1AkwgpY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC2x9lYvVHH4Af10hVPhm0eoCEAgQ",
|
||||
"type": "Artist",
|
||||
"creator": "Charlie Puth",
|
||||
"tracks": []
|
||||
},
|
||||
"Wxrdie": {
|
||||
"id": "artist-Wxrdie",
|
||||
"title": "Wxrdie",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/bqocfrCfPw0/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDo-RKKB1XcyaF58AZrP5_fmYmqiw",
|
||||
"type": "Artist",
|
||||
"creator": "Wxrdie",
|
||||
"tracks": []
|
||||
},
|
||||
"Trúc Nhân": {
|
||||
"id": "artist-Trúc-Nhân",
|
||||
"title": "Trúc Nhân",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/hjYOanJelUs/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC5CQEmJKM1ddDLAM1j1rgle4Dm0w",
|
||||
"type": "Artist",
|
||||
"creator": "Trúc Nhân",
|
||||
"tracks": []
|
||||
},
|
||||
"B Ray": {
|
||||
"id": "artist-B-Ray",
|
||||
"title": "B Ray",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/iE52-XXnQqs/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCbMovU9YKvACHHdbN_Lu_gohwj3A",
|
||||
"type": "Artist",
|
||||
"creator": "B Ray",
|
||||
"tracks": []
|
||||
},
|
||||
"Bruno Mars": {
|
||||
"id": "artist-Bruno-Mars",
|
||||
"title": "Bruno Mars",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/mrV8kK5t0V8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDSivGovJHd4fAWIMSac0CWZDVJZQ",
|
||||
"type": "Artist",
|
||||
"creator": "Bruno Mars",
|
||||
"tracks": []
|
||||
},
|
||||
"Hà Anh Tuấn": {
|
||||
"id": "artist-Hà-Anh-Tuấn",
|
||||
"title": "Hà Anh Tuấn",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/NJuNJ8Xs_00/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBrKeyNEEu24Q1EDU494BRHW2Qa7w",
|
||||
"type": "Artist",
|
||||
"creator": "Hà Anh Tuấn",
|
||||
"tracks": []
|
||||
},
|
||||
"Erik": {
|
||||
"id": "artist-Erik",
|
||||
"title": "Erik",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/wEVM-JBHPJc/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAwKgKMp74UpwF4g_3WXQbjsH0PnQ",
|
||||
"type": "Artist",
|
||||
"creator": "Erik",
|
||||
"tracks": []
|
||||
},
|
||||
"Binz": {
|
||||
"id": "artist-Binz",
|
||||
"title": "Binz",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/3gNuUcPg1fk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA7XsoVc-VA8XHqN030JWzd18aXhw",
|
||||
"type": "Artist",
|
||||
"creator": "Binz",
|
||||
"tracks": []
|
||||
},
|
||||
"Ed Sheeran": {
|
||||
"id": "artist-Ed-Sheeran",
|
||||
"title": "Ed Sheeran",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/JgDNFQ2RaLQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD8wp2I-6D9BcE4tXCj6EjDxxmiHg",
|
||||
"type": "Artist",
|
||||
"creator": "Ed Sheeran",
|
||||
"tracks": []
|
||||
},
|
||||
"BTS": {
|
||||
"id": "artist-BTS",
|
||||
"title": "BTS",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/BRYAWqGjmPo/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCWt0zVu59F8uZBNbL-yWcjv47ZAw",
|
||||
"type": "Artist",
|
||||
"creator": "BTS",
|
||||
"tracks": []
|
||||
},
|
||||
"Phương Mỹ Chi": {
|
||||
"id": "artist-Phương-Mỹ-Chi",
|
||||
"title": "Phương Mỹ Chi",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/BmrdGQ0LRRo/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDp32bXCdfYXtd09dz_jATKYYJbpw",
|
||||
"type": "Artist",
|
||||
"creator": "Phương Mỹ Chi",
|
||||
"tracks": []
|
||||
},
|
||||
"Low G": {
|
||||
"id": "artist-Low-G",
|
||||
"title": "Low G",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/AEH3QZvrEHI/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCBsCBtcNjjAbAURjKvYDAX-yWmRw",
|
||||
"type": "Artist",
|
||||
"creator": "Low G",
|
||||
"tracks": []
|
||||
},
|
||||
"Amee": {
|
||||
"id": "artist-Amee",
|
||||
"title": "Amee",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/ofGcw-Z-OYA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBy5YTF2s74BSGkycc1Xn-ckQRtAw",
|
||||
"type": "Artist",
|
||||
"creator": "Amee",
|
||||
"tracks": []
|
||||
},
|
||||
"Da LAB": {
|
||||
"id": "artist-Da-LAB",
|
||||
"title": "Da LAB",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Z1D26z9l8y8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCU_zgnZxezUq0pQGMBJdx_P5-4_w",
|
||||
"type": "Artist",
|
||||
"creator": "Da LAB",
|
||||
"tracks": []
|
||||
},
|
||||
"Đức Phúc": {
|
||||
"id": "artist-Đức-Phúc",
|
||||
"title": "Đức Phúc",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/IOe0tNoUGv8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCt0GHTc2g6xE3qdm1aOAmR-GXHnA",
|
||||
"type": "Artist",
|
||||
"creator": "Đức Phúc",
|
||||
"tracks": []
|
||||
},
|
||||
"Cá Hồi Hoang": {
|
||||
"id": "artist-Cá-Hồi-Hoang",
|
||||
"title": "Cá Hồi Hoang",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/H8NTALzm0F4/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhlIGUoTTAP&rs=AOn4CLA6owTCPQWQpmo8AQpGd4pUILN2KQ",
|
||||
"type": "Artist",
|
||||
"creator": "Cá Hồi Hoang",
|
||||
"tracks": []
|
||||
},
|
||||
"Chillies": {
|
||||
"id": "artist-Chillies",
|
||||
"title": "Chillies",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Pk4FJYs0a58/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA_80IsN6-TR0DeQixnTubUJnOnlQ",
|
||||
"type": "Artist",
|
||||
"creator": "Chillies",
|
||||
"tracks": []
|
||||
},
|
||||
"Tăng Duy Tân": {
|
||||
"id": "artist-Tăng-Duy-Tân",
|
||||
"title": "Tăng Duy Tân",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/1So7VBehCQg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCtU8tfN3WpQftpgqAbnE6Milu9CA",
|
||||
"type": "Artist",
|
||||
"creator": "Tăng Duy Tân",
|
||||
"tracks": []
|
||||
},
|
||||
"Văn Mai Hương": {
|
||||
"id": "artist-Văn-Mai-Hương",
|
||||
"title": "Văn Mai Hương",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/UKoay9jU0X4/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBZX6UprWadmKaMDudQ0q3ZQiDcSQ",
|
||||
"type": "Artist",
|
||||
"creator": "Văn Mai Hương",
|
||||
"tracks": []
|
||||
},
|
||||
"Diệu Kỳ Việt Nam Album": {
|
||||
"id": "album-Diệu-Kỳ-Việt-Nam",
|
||||
"title": "Diệu Kỳ Việt Nam",
|
||||
"description": "Album • Various Artists",
|
||||
"cover_url": "https://i.ytimg.com/vi/BkUSfsfGmfM/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBpcLC06Qf9WKGbV_HGmAQpHfO3zw",
|
||||
"type": "Album",
|
||||
"creator": "Various Artists",
|
||||
"tracks": []
|
||||
},
|
||||
"LINK Album": {
|
||||
"id": "album-LINK",
|
||||
"title": "LINK",
|
||||
"description": "Album • Hoàng Thùy Linh",
|
||||
"cover_url": "https://i.ytimg.com/vi/MuHxxRufmMQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCv5LhWls8lF8t0-rn23tUlwdIk-g",
|
||||
"type": "Album",
|
||||
"creator": "Hoàng Thùy Linh",
|
||||
"tracks": []
|
||||
},
|
||||
"99% Album": {
|
||||
"id": "album-99%",
|
||||
"title": "99%",
|
||||
"description": "Album • MCK",
|
||||
"cover_url": "https://i.ytimg.com/vi/dz6xe0xXqYE/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARh_IDQoMzAP&rs=AOn4CLCCuKsBhf_XCEoKRr49cZ1hrRozZw",
|
||||
"type": "Album",
|
||||
"creator": "MCK",
|
||||
"tracks": []
|
||||
},
|
||||
"Cong Album": {
|
||||
"id": "album-Cong",
|
||||
"title": "Cong",
|
||||
"description": "Album • Tóc Tiên",
|
||||
"cover_url": "https://i.ytimg.com/vi/FC9R18OQfbU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDi_uIbcx2MF8VaAClukECBHBG2Sg",
|
||||
"type": "Album",
|
||||
"creator": "Tóc Tiên",
|
||||
"tracks": []
|
||||
},
|
||||
"Một Vạn Năm Album": {
|
||||
"id": "album-Một-Vạn-Năm",
|
||||
"title": "Một Vạn Năm",
|
||||
"description": "Album • Vũ.",
|
||||
"cover_url": "https://i.ytimg.com/vi/dJaxhdDzIDQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAR-7bYtZVIU4wYjsOlJcb0QH-8TQ",
|
||||
"type": "Album",
|
||||
"creator": "Vũ.",
|
||||
"tracks": []
|
||||
},
|
||||
"Rap Việt Season 3 Album": {
|
||||
"id": "album-Rap-Việt-Season-3",
|
||||
"title": "Rap Việt Season 3",
|
||||
"description": "Album • Various Artists",
|
||||
"cover_url": "https://i.ytimg.com/vi/MbqTBRqFofg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDnixgazwv5VVs4vB-pRy7pl9GrnQ",
|
||||
"type": "Album",
|
||||
"creator": "Various Artists",
|
||||
"tracks": []
|
||||
},
|
||||
"Midnights Album": {
|
||||
"id": "album-Midnights",
|
||||
"title": "Midnights",
|
||||
"description": "Album • Taylor Swift",
|
||||
"cover_url": "https://i.ytimg.com/vi/M2atIJWDPcY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCSUAi5jQSKLNM97GPwjcy_8pS1yA",
|
||||
"type": "Album",
|
||||
"creator": "Taylor Swift",
|
||||
"tracks": []
|
||||
},
|
||||
"Human Album": {
|
||||
"id": "album-Human",
|
||||
"title": "Human",
|
||||
"description": "Album • Tùng Dương",
|
||||
"cover_url": "https://i.ytimg.com/vi/B88ZCv7uWYA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBwTr5vuN1h-oUjP2naWnKxF9P33g",
|
||||
"type": "Album",
|
||||
"creator": "Tùng Dương",
|
||||
"tracks": []
|
||||
},
|
||||
"Yên Album": {
|
||||
"id": "album-Yên",
|
||||
"title": "Yên",
|
||||
"description": "Album • Hoàng Dũng",
|
||||
"cover_url": "https://i.ytimg.com/vi/4tSmJ6wUMk0/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBAiqSVOTLIBQwRxbYfdt-csiY-bA",
|
||||
"type": "Album",
|
||||
"creator": "Hoàng Dũng",
|
||||
"tracks": []
|
||||
},
|
||||
"WeChoice Awards 2023 Album": {
|
||||
"id": "album-WeChoice-Awards-2023",
|
||||
"title": "WeChoice Awards 2023",
|
||||
"description": "Album • Various Artists",
|
||||
"cover_url": "https://i.ytimg.com/vi/i_ec-92cdJI/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCu00iBqsbp3WOx2bQeP6tTMZZwQg",
|
||||
"type": "Album",
|
||||
"creator": "Various Artists",
|
||||
"tracks": []
|
||||
},
|
||||
"Citopia Album": {
|
||||
"id": "album-Citopia",
|
||||
"title": "Citopia",
|
||||
"description": "Album • Phùng Khánh Linh",
|
||||
"cover_url": "https://i.ytimg.com/vi/xl7AfxJ5tMk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBwv-4XPbN61rT5OFBcR5LXjvGfHg",
|
||||
"type": "Album",
|
||||
"creator": "Phùng Khánh Linh",
|
||||
"tracks": []
|
||||
},
|
||||
"DreAMEE Album": {
|
||||
"id": "album-DreAMEE",
|
||||
"title": "DreAMEE",
|
||||
"description": "Album • Amee",
|
||||
"cover_url": "https://i.ytimg.com/vi/BBytiT94y2A/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBtKLHlKAhm6DabIRrnG28WygLHRQ",
|
||||
"type": "Album",
|
||||
"creator": "Amee",
|
||||
"tracks": []
|
||||
},
|
||||
"Sky Tour Album": {
|
||||
"id": "album-Sky-Tour",
|
||||
"title": "Sky Tour",
|
||||
"description": "Album • Sơn Tùng M-TP",
|
||||
"cover_url": "https://i.ytimg.com/vi/Gml7neJG7MI/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC7J81KA-cSdpPxEtD_gaxLw4vuaQ",
|
||||
"type": "Album",
|
||||
"creator": "Sơn Tùng M-TP",
|
||||
"tracks": []
|
||||
},
|
||||
"Loi Choi Album": {
|
||||
"id": "album-Loi-Choi",
|
||||
"title": "Loi Choi",
|
||||
"description": "Album • Wren Evans",
|
||||
"cover_url": "https://i.ytimg.com/vi/QZrZbEEAOZ8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBzZNfyqJxPl3lO-2KLC2IHdsjdEw",
|
||||
"type": "Album",
|
||||
"creator": "Wren Evans",
|
||||
"tracks": []
|
||||
},
|
||||
"Vũ Trụ Cò Bay Album": {
|
||||
"id": "album-Vũ-Trụ-Cò-Bay",
|
||||
"title": "Vũ Trụ Cò Bay",
|
||||
"description": "Album • Phương Mỹ Chi",
|
||||
"cover_url": "https://i.ytimg.com/vi/XAFfGb8V4bs/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAz-t-1Jeu5J2zE4kVCv6AL1rwPGQ",
|
||||
"type": "Album",
|
||||
"creator": "Phương Mỹ Chi",
|
||||
"tracks": []
|
||||
},
|
||||
"Hidden Gem Album": {
|
||||
"id": "album-Hidden-Gem",
|
||||
"title": "Hidden Gem",
|
||||
"description": "Album • Various Artists",
|
||||
"cover_url": "https://i.ytimg.com/vi/I9DoRLyZbq8/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARh_IEUoITAP&rs=AOn4CLBVTnD_weqLwPq2QGGI4vib67m6JA",
|
||||
"type": "Album",
|
||||
"creator": "Various Artists",
|
||||
"tracks": []
|
||||
},
|
||||
"Stardom Album": {
|
||||
"id": "album-Stardom",
|
||||
"title": "Stardom",
|
||||
"description": "Album • Vũ Cát Tường",
|
||||
"cover_url": "https://i.ytimg.com/vi/G12V5_HLYIQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_mhx6NN5QbQlgi4ZAtp2Q0Kmvog",
|
||||
"type": "Album",
|
||||
"creator": "Vũ Cát Tường",
|
||||
"tracks": []
|
||||
},
|
||||
"Hương Album": {
|
||||
"id": "album-Hương",
|
||||
"title": "Hương",
|
||||
"description": "Album • Văn Mai Hương",
|
||||
"cover_url": "https://i.ytimg.com/vi/ZC0uQ9B7DFk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAA4nuVj9QbNL85IUCt_PJjthrzjQ",
|
||||
"type": "Album",
|
||||
"creator": "Văn Mai Hương",
|
||||
"tracks": []
|
||||
},
|
||||
"Ai Cũng Phải Bắt Đầu Từ Đâu Đó Album": {
|
||||
"id": "album-Ai-Cũng-Phải-Bắt-Đầu-Từ-Đâu-Đó",
|
||||
"title": "Ai Cũng Phải Bắt Đầu Từ Đâu Đó",
|
||||
"description": "Album • HIEUTHUHAI",
|
||||
"cover_url": "https://i.ytimg.com/vi/apGwEpS8tqg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAaswFDv9on-M72RkW1yCbSyhG19w",
|
||||
"type": "Album",
|
||||
"creator": "HIEUTHUHAI",
|
||||
"tracks": []
|
||||
},
|
||||
"After Hours Album": {
|
||||
"id": "album-After-Hours",
|
||||
"title": "After Hours",
|
||||
"description": "Album • The Weeknd",
|
||||
"cover_url": "https://i.ytimg.com/vi/ygTZZpVkmKg/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhlIGAoQTAP&rs=AOn4CLAJw5eqVSypwCDp_Lv1UHDYZKzdLw",
|
||||
"type": "Album",
|
||||
"creator": "The Weeknd",
|
||||
"tracks": []
|
||||
},
|
||||
"Bolero Tru Tinh": {
|
||||
"id": "playlist-Bolero-Tru-Tinh",
|
||||
"title": "Bolero Tru Tinh",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/muDs2wLpBtE/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLApKILStj6RV-kl6tdEPIPj45h0-A",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Viral Hits Vietnam": {
|
||||
"id": "playlist-Viral-Hits-Vietnam",
|
||||
"title": "Viral Hits Vietnam",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/UdyGlHhI8tQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCAY2u4FOdXEMC-6C95u8xBsdO6nQ",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Acoustic Thu Gian": {
|
||||
"id": "playlist-Acoustic-Thu-Gian",
|
||||
"title": "Acoustic Thu Gian",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/IoKQ905zrxU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBJPpS_yUSyDUv9PjMFPjK3H1kdmA",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Workout Energy": {
|
||||
"id": "playlist-Workout-Energy",
|
||||
"title": "Workout Energy",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/dZ1tiIgLvro/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAjtQ3hwKesNVywnI49oV2x4PV-IQ",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Lofi Chill Vietnam": {
|
||||
"id": "playlist-Lofi-Chill-Vietnam",
|
||||
"title": "Lofi Chill Vietnam",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/VQoju_Dfi94/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAmHCuyDdiQqJ345GRQgLLhhcXU2g",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Party Anthems": {
|
||||
"id": "playlist-Party-Anthems",
|
||||
"title": "Party Anthems",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/aNSiNYb4c9c/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDgx99jZRe8m7XqBpwutGiMIT8AJQ",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Piano Focus": {
|
||||
"id": "playlist-Piano-Focus",
|
||||
"title": "Piano Focus",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/AnlKTlJLkro/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCI00FI0HZWHQWuACTV5HQV7Cgcyg",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Nhac Trinh Cong Son": {
|
||||
"id": "playlist-Nhac-Trinh-Cong-Son",
|
||||
"title": "Nhac Trinh Cong Son",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/m9CBkq1wu54/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCiCeeUxKjO2ePbLrGEOSfLRyqK_g",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Indie Vietnam": {
|
||||
"id": "playlist-Indie-Vietnam",
|
||||
"title": "Indie Vietnam",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/p0ODaducyPo/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBUfHeOzENXCm2yVU_oZSeOiGQLOw",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Nhac Tre Moi Nhat": {
|
||||
"id": "playlist-Nhac-Tre-Moi-Nhat",
|
||||
"title": "Nhac Tre Moi Nhat",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/q2Ev43H-nmc/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD_Zlz1YrxVVEOHgUrsaYo13EytEA",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"K-Pop ON!": {
|
||||
"id": "playlist-K-Pop-ON!",
|
||||
"title": "K-Pop ON!",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/eCVJyexv1kg/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDRg6HvyBxQm2dzzkiQE8f6hSZSBw",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"K-Pop Daebak": {
|
||||
"id": "playlist-K-Pop-Daebak",
|
||||
"title": "K-Pop Daebak",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/yf0O4WZVJqQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDG99KV-WjOmqf0pKu0jb7CLa7H0A",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Top 50 Vietnam": {
|
||||
"id": "playlist-Top-50-Vietnam",
|
||||
"title": "Top 50 Vietnam",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/bEas0GWDm1k/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCufqFtBRF6_VlO_-EXC3qdx3VR-A",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Sleep Sounds": {
|
||||
"id": "playlist-Sleep-Sounds",
|
||||
"title": "Sleep Sounds",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/LFASWuckB1c/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCotlKXoysvRmnwvONiMwGRlTDYNg",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Gaming Music": {
|
||||
"id": "playlist-Gaming-Music",
|
||||
"title": "Gaming Music",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/PP2Uvesx4ls/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAl7lLrOqFoRf2b9F6pvcdEwKKPvA",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Anime Hits": {
|
||||
"id": "playlist-Anime-Hits",
|
||||
"title": "Anime Hits",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/4N9HmMNf7EU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBtT7xbe4H2NeuykSQ3UiJpGPanJw",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Beast Mode": {
|
||||
"id": "playlist-Beast-Mode",
|
||||
"title": "Beast Mode",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/lwsxS4LZCxY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCgUJE9tP7WenyhCvsWh1g-88TMSw",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Vietnam Top Hits 2024": {
|
||||
"id": "playlist-Vietnam-Top-Hits-2024",
|
||||
"title": "Vietnam Top Hits 2024",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/ZYDt8WGo00A/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhyIFcoQTAP&rs=AOn4CLDg-X3e_RvvsJ9IMHzkF-SvLZKebQ",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"V-Pop Rising": {
|
||||
"id": "playlist-V-Pop-Rising",
|
||||
"title": "V-Pop Rising",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/eymezvWJkAo/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhaIFooWjAP&rs=AOn4CLCTUwgdyKTtaKMrOi2f8MwRbmJt-w",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Rap Viet All Stars": {
|
||||
"id": "playlist-Rap-Viet-All-Stars",
|
||||
"title": "Rap Viet All Stars",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/okD3pw2t59Y/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAPTN96Wl14jH7STJdRyMoVjqWIZg",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
}
|
||||
};
|
||||
839
frontend-vite/src/data/seed_data_real.ts
Normal file
839
frontend-vite/src/data/seed_data_real.ts
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
import { StaticPlaylist } from "../types";
|
||||
|
||||
export const GENERATED_CONTENT: Record<string, StaticPlaylist> = {
|
||||
"Noo Phước Thịnh": {
|
||||
"id": "artist-Noo-Phước-Thịnh",
|
||||
"title": "Noo Phước Thịnh",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/_E-7A81Ac8U/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBnhD_3sy8Si9qk-sZumXoebde6OA",
|
||||
"type": "Artist",
|
||||
"creator": "Noo Phước Thịnh",
|
||||
"tracks": []
|
||||
},
|
||||
"Obito": {
|
||||
"id": "artist-Obito",
|
||||
"title": "Obito",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/lUrmyU1cnxU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLArOPSXlEyOow0yD7n6i0_4q9BnAQ",
|
||||
"type": "Artist",
|
||||
"creator": "Obito",
|
||||
"tracks": []
|
||||
},
|
||||
"BLACKPINK": {
|
||||
"id": "artist-BLACKPINK",
|
||||
"title": "BLACKPINK",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/ioNng23DkIM/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBnvs20jQWj1VeUiDa_HDmpI5Mzog",
|
||||
"type": "Artist",
|
||||
"creator": "BLACKPINK",
|
||||
"tracks": []
|
||||
},
|
||||
"Soobin Hoàng Sơn": {
|
||||
"id": "artist-Soobin-Hoàng-Sơn",
|
||||
"title": "Soobin Hoàng Sơn",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/uFHyAxPhCKk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAC54CcJKkIiD1-WzD21wfXlt76Bw",
|
||||
"type": "Artist",
|
||||
"creator": "Soobin Hoàng Sơn",
|
||||
"tracks": []
|
||||
},
|
||||
"Đông Nhi": {
|
||||
"id": "artist-Đông-Nhi",
|
||||
"title": "Đông Nhi",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/7i6icKvZuuQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDpllCsFsLto35dQJ0kSN_J9OZnlg",
|
||||
"type": "Artist",
|
||||
"creator": "Đông Nhi",
|
||||
"tracks": []
|
||||
},
|
||||
"Min": {
|
||||
"id": "artist-Min",
|
||||
"title": "Min",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/VH3mWd28Ndg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDL1IMnoWCgq4vg4OXk27kO31h3VA",
|
||||
"type": "Artist",
|
||||
"creator": "Min",
|
||||
"tracks": []
|
||||
},
|
||||
"Justin Bieber": {
|
||||
"id": "artist-Justin-Bieber",
|
||||
"title": "Justin Bieber",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/fXivMSJm_kA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCUtGsU7kyB52jWqGkqkXXXWTjQZw",
|
||||
"type": "Artist",
|
||||
"creator": "Justin Bieber",
|
||||
"tracks": []
|
||||
},
|
||||
"Billie Eilish": {
|
||||
"id": "artist-Billie-Eilish",
|
||||
"title": "Billie Eilish",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/V9PVRfjEBTI/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBFbDPFc1NFarkEge0ZOXKeg7gJrw",
|
||||
"type": "Artist",
|
||||
"creator": "Billie Eilish",
|
||||
"tracks": []
|
||||
},
|
||||
"Kendrick Lamar": {
|
||||
"id": "artist-Kendrick-Lamar",
|
||||
"title": "Kendrick Lamar",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/U8F5G5wR1mk/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhlIGUoZTAP&rs=AOn4CLCAriNIWkifAul8L6NTgFvEqOrXWQ",
|
||||
"type": "Artist",
|
||||
"creator": "Kendrick Lamar",
|
||||
"tracks": []
|
||||
},
|
||||
"Olivia Rodrigo": {
|
||||
"id": "artist-Olivia-Rodrigo",
|
||||
"title": "Olivia Rodrigo",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/cii6ruuycQA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAtzRzOkqh9tmiujehjmxXx8dbFRw",
|
||||
"type": "Artist",
|
||||
"creator": "Olivia Rodrigo",
|
||||
"tracks": []
|
||||
},
|
||||
"Phan Mạnh Quỳnh": {
|
||||
"id": "artist-Phan-Mạnh-Quỳnh",
|
||||
"title": "Phan Mạnh Quỳnh",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/JlQSAS_ccxw/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBNpPsLgPOV9OSjemdScHSsBLCbzg",
|
||||
"type": "Artist",
|
||||
"creator": "Phan Mạnh Quỳnh",
|
||||
"tracks": []
|
||||
},
|
||||
"Ariana Grande": {
|
||||
"id": "artist-Ariana-Grande",
|
||||
"title": "Ariana Grande",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/hKwizc5nsWY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBD5zUOR_8GLbxh6gvg7wjbL3veEA",
|
||||
"type": "Artist",
|
||||
"creator": "Ariana Grande",
|
||||
"tracks": []
|
||||
},
|
||||
"Post Malone": {
|
||||
"id": "artist-Post-Malone",
|
||||
"title": "Post Malone",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/7aekxC_monc/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB7edrWBLVQVm8m66dSpRPjAVMV9A",
|
||||
"type": "Artist",
|
||||
"creator": "Post Malone",
|
||||
"tracks": []
|
||||
},
|
||||
"BigDaddy": {
|
||||
"id": "artist-BigDaddy",
|
||||
"title": "BigDaddy",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/o3Ui6lbvRSo/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDgLlalmwcftOI_eCf9g5NypoRPig",
|
||||
"type": "Artist",
|
||||
"creator": "BigDaddy",
|
||||
"tracks": []
|
||||
},
|
||||
"Drake": {
|
||||
"id": "artist-Drake",
|
||||
"title": "Drake",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/xpVfcZ0ZcFM/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDtjCxJ3y8riQElZ7JCOJu058rftA",
|
||||
"type": "Artist",
|
||||
"creator": "Drake",
|
||||
"tracks": []
|
||||
},
|
||||
"JustaTee": {
|
||||
"id": "artist-JustaTee",
|
||||
"title": "JustaTee",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/FfUeTzwYUJk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC5YpThVpv3NoAwHQ8QiT0z9XFSvA",
|
||||
"type": "Artist",
|
||||
"creator": "JustaTee",
|
||||
"tracks": []
|
||||
},
|
||||
"Đen Vâu": {
|
||||
"id": "artist-Đen-Vâu",
|
||||
"title": "Đen Vâu",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Hqmbo0ROBQw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA9lSyi9M9k24HQhgobFPMEgQEKVA",
|
||||
"type": "Artist",
|
||||
"creator": "Đen Vâu",
|
||||
"tracks": []
|
||||
},
|
||||
"Emily": {
|
||||
"id": "artist-Emily",
|
||||
"title": "Emily",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/ixleWbPl4is/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARgTIEoofzAP&rs=AOn4CLAuTsNzWFh-HMrdg2tdoS-tMjDeOA",
|
||||
"type": "Artist",
|
||||
"creator": "Emily",
|
||||
"tracks": []
|
||||
},
|
||||
"LyLy": {
|
||||
"id": "artist-LyLy",
|
||||
"title": "LyLy",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/IpniN1Wq68Y/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDtzI23cjX9W-4LliFstESlIpYczw",
|
||||
"type": "Artist",
|
||||
"creator": "LyLy",
|
||||
"tracks": []
|
||||
},
|
||||
"The Weeknd": {
|
||||
"id": "artist-The-Weeknd",
|
||||
"title": "The Weeknd",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Mx92lTYxrJQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAL8NG5JoNndy3_nxIFDT3q4aD9Ow",
|
||||
"type": "Artist",
|
||||
"creator": "The Weeknd",
|
||||
"tracks": []
|
||||
},
|
||||
"Karik": {
|
||||
"id": "artist-Karik",
|
||||
"title": "Karik",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/9XoOCDoxyKw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDo_mVzAjDx0GIsdfDFFg9JrCjiJw",
|
||||
"type": "Artist",
|
||||
"creator": "Karik",
|
||||
"tracks": []
|
||||
},
|
||||
"Sơn Tùng M-TP": {
|
||||
"id": "artist-Sơn-Tùng-M-TP",
|
||||
"title": "Sơn Tùng M-TP",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAKKkxyzooXkasYWnLdJchzMqBICg",
|
||||
"type": "Artist",
|
||||
"creator": "Sơn Tùng M-TP",
|
||||
"tracks": []
|
||||
},
|
||||
"Mỹ Tâm": {
|
||||
"id": "artist-Mỹ-Tâm",
|
||||
"title": "Mỹ Tâm",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/W4P8gl4dnrg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAv8Vt9mNePtnFl-zTX6WxA1_x8Wg",
|
||||
"type": "Artist",
|
||||
"creator": "Mỹ Tâm",
|
||||
"tracks": []
|
||||
},
|
||||
"MCK": {
|
||||
"id": "artist-MCK",
|
||||
"title": "MCK",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/09Mh7GgUFFA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAP2EG6hS5XOlG9Tc1tP4MfbkAdxg",
|
||||
"type": "Artist",
|
||||
"creator": "MCK",
|
||||
"tracks": []
|
||||
},
|
||||
"Wren Evans": {
|
||||
"id": "artist-Wren-Evans",
|
||||
"title": "Wren Evans",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/gLhchzCMXbo/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA-Dpfi7iIW3fURu7kI_9dPM3BXiQ",
|
||||
"type": "Artist",
|
||||
"creator": "Wren Evans",
|
||||
"tracks": []
|
||||
},
|
||||
"Rhymastic": {
|
||||
"id": "artist-Rhymastic",
|
||||
"title": "Rhymastic",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/uFHyAxPhCKk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAC54CcJKkIiD1-WzD21wfXlt76Bw",
|
||||
"type": "Artist",
|
||||
"creator": "Rhymastic",
|
||||
"tracks": []
|
||||
},
|
||||
"Mono": {
|
||||
"id": "artist-Mono",
|
||||
"title": "Mono",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/okz5RIZRT0U/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDb0vsqgOg9POWvlOYv9jQE_TRkgA",
|
||||
"type": "Artist",
|
||||
"creator": "Mono",
|
||||
"tracks": []
|
||||
},
|
||||
"Vũ.": {
|
||||
"id": "artist-Vũ.",
|
||||
"title": "Vũ.",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/xlXmeiUj5OA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDqYliVsJivgyxtkp_4EGmXYYQCxg",
|
||||
"type": "Artist",
|
||||
"creator": "Vũ.",
|
||||
"tracks": []
|
||||
},
|
||||
"Suboi": {
|
||||
"id": "artist-Suboi",
|
||||
"title": "Suboi",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/gERJjsDhe0g/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBpX23iaiDfN5Ee8tsTy7iIr87zFA",
|
||||
"type": "Artist",
|
||||
"creator": "Suboi",
|
||||
"tracks": []
|
||||
},
|
||||
"HIEUTHUHAI": {
|
||||
"id": "artist-HIEUTHUHAI",
|
||||
"title": "HIEUTHUHAI",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/NpI4TSgBVTw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA64o5gXesfyZgW0qj0F1K-b41lVw",
|
||||
"type": "Artist",
|
||||
"creator": "HIEUTHUHAI",
|
||||
"tracks": []
|
||||
},
|
||||
"Andree Right Hand": {
|
||||
"id": "artist-Andree-Right-Hand",
|
||||
"title": "Andree Right Hand",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/A-tX5PI3V0o/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBy2sihK_BXt3hOlWE5Gg9eA_DF8A",
|
||||
"type": "Artist",
|
||||
"creator": "Andree Right Hand",
|
||||
"tracks": []
|
||||
},
|
||||
"Ngọt": {
|
||||
"id": "artist-Ngọt",
|
||||
"title": "Ngọt",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/9mA7h1jfxc8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBzRl1JSQc3tKsg9oadC6pSyKPm7Q",
|
||||
"type": "Artist",
|
||||
"creator": "Ngọt",
|
||||
"tracks": []
|
||||
},
|
||||
"Taylor Swift": {
|
||||
"id": "artist-Taylor-Swift",
|
||||
"title": "Taylor Swift",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/ko70cExuzZM/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC1PgPkzBs6QEv04boPTkfKQfOAFQ",
|
||||
"type": "Artist",
|
||||
"creator": "Taylor Swift",
|
||||
"tracks": []
|
||||
},
|
||||
"Tlinh": {
|
||||
"id": "artist-Tlinh",
|
||||
"title": "Tlinh",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCAC0eQiNHs3k10YiyAkNMafKFASQ",
|
||||
"type": "Artist",
|
||||
"creator": "Tlinh",
|
||||
"tracks": []
|
||||
},
|
||||
"Hoàng Thùy Linh": {
|
||||
"id": "artist-Hoàng-Thùy-Linh",
|
||||
"title": "Hoàng Thùy Linh",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Q6ZNsHvspEg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB2MB7H38KeXhcPFSi5P4CUe18K3A",
|
||||
"type": "Artist",
|
||||
"creator": "Hoàng Thùy Linh",
|
||||
"tracks": []
|
||||
},
|
||||
"Orange": {
|
||||
"id": "artist-Orange",
|
||||
"title": "Orange",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/AaiZ0L7-JWE/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBN0-rbSO24JSR7fG5Tr-Dbv9nvzQ",
|
||||
"type": "Artist",
|
||||
"creator": "Orange",
|
||||
"tracks": []
|
||||
},
|
||||
"Charlie Puth": {
|
||||
"id": "artist-Charlie-Puth",
|
||||
"title": "Charlie Puth",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Nq3x1AkwgpY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC2x9lYvVHH4Af10hVPhm0eoCEAgQ",
|
||||
"type": "Artist",
|
||||
"creator": "Charlie Puth",
|
||||
"tracks": []
|
||||
},
|
||||
"Wxrdie": {
|
||||
"id": "artist-Wxrdie",
|
||||
"title": "Wxrdie",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/bqocfrCfPw0/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDo-RKKB1XcyaF58AZrP5_fmYmqiw",
|
||||
"type": "Artist",
|
||||
"creator": "Wxrdie",
|
||||
"tracks": []
|
||||
},
|
||||
"Trúc Nhân": {
|
||||
"id": "artist-Trúc-Nhân",
|
||||
"title": "Trúc Nhân",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/hjYOanJelUs/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC5CQEmJKM1ddDLAM1j1rgle4Dm0w",
|
||||
"type": "Artist",
|
||||
"creator": "Trúc Nhân",
|
||||
"tracks": []
|
||||
},
|
||||
"B Ray": {
|
||||
"id": "artist-B-Ray",
|
||||
"title": "B Ray",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/iE52-XXnQqs/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCbMovU9YKvACHHdbN_Lu_gohwj3A",
|
||||
"type": "Artist",
|
||||
"creator": "B Ray",
|
||||
"tracks": []
|
||||
},
|
||||
"Bruno Mars": {
|
||||
"id": "artist-Bruno-Mars",
|
||||
"title": "Bruno Mars",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/mrV8kK5t0V8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDSivGovJHd4fAWIMSac0CWZDVJZQ",
|
||||
"type": "Artist",
|
||||
"creator": "Bruno Mars",
|
||||
"tracks": []
|
||||
},
|
||||
"Hà Anh Tuấn": {
|
||||
"id": "artist-Hà-Anh-Tuấn",
|
||||
"title": "Hà Anh Tuấn",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/NJuNJ8Xs_00/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBrKeyNEEu24Q1EDU494BRHW2Qa7w",
|
||||
"type": "Artist",
|
||||
"creator": "Hà Anh Tuấn",
|
||||
"tracks": []
|
||||
},
|
||||
"Erik": {
|
||||
"id": "artist-Erik",
|
||||
"title": "Erik",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/wEVM-JBHPJc/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAwKgKMp74UpwF4g_3WXQbjsH0PnQ",
|
||||
"type": "Artist",
|
||||
"creator": "Erik",
|
||||
"tracks": []
|
||||
},
|
||||
"Binz": {
|
||||
"id": "artist-Binz",
|
||||
"title": "Binz",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/3gNuUcPg1fk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA7XsoVc-VA8XHqN030JWzd18aXhw",
|
||||
"type": "Artist",
|
||||
"creator": "Binz",
|
||||
"tracks": []
|
||||
},
|
||||
"Ed Sheeran": {
|
||||
"id": "artist-Ed-Sheeran",
|
||||
"title": "Ed Sheeran",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/JgDNFQ2RaLQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD8wp2I-6D9BcE4tXCj6EjDxxmiHg",
|
||||
"type": "Artist",
|
||||
"creator": "Ed Sheeran",
|
||||
"tracks": []
|
||||
},
|
||||
"BTS": {
|
||||
"id": "artist-BTS",
|
||||
"title": "BTS",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/BRYAWqGjmPo/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCWt0zVu59F8uZBNbL-yWcjv47ZAw",
|
||||
"type": "Artist",
|
||||
"creator": "BTS",
|
||||
"tracks": []
|
||||
},
|
||||
"Phương Mỹ Chi": {
|
||||
"id": "artist-Phương-Mỹ-Chi",
|
||||
"title": "Phương Mỹ Chi",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/BmrdGQ0LRRo/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDp32bXCdfYXtd09dz_jATKYYJbpw",
|
||||
"type": "Artist",
|
||||
"creator": "Phương Mỹ Chi",
|
||||
"tracks": []
|
||||
},
|
||||
"Low G": {
|
||||
"id": "artist-Low-G",
|
||||
"title": "Low G",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/AEH3QZvrEHI/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCBsCBtcNjjAbAURjKvYDAX-yWmRw",
|
||||
"type": "Artist",
|
||||
"creator": "Low G",
|
||||
"tracks": []
|
||||
},
|
||||
"Amee": {
|
||||
"id": "artist-Amee",
|
||||
"title": "Amee",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/ofGcw-Z-OYA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBy5YTF2s74BSGkycc1Xn-ckQRtAw",
|
||||
"type": "Artist",
|
||||
"creator": "Amee",
|
||||
"tracks": []
|
||||
},
|
||||
"Da LAB": {
|
||||
"id": "artist-Da-LAB",
|
||||
"title": "Da LAB",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Z1D26z9l8y8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCU_zgnZxezUq0pQGMBJdx_P5-4_w",
|
||||
"type": "Artist",
|
||||
"creator": "Da LAB",
|
||||
"tracks": []
|
||||
},
|
||||
"Đức Phúc": {
|
||||
"id": "artist-Đức-Phúc",
|
||||
"title": "Đức Phúc",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/IOe0tNoUGv8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCt0GHTc2g6xE3qdm1aOAmR-GXHnA",
|
||||
"type": "Artist",
|
||||
"creator": "Đức Phúc",
|
||||
"tracks": []
|
||||
},
|
||||
"Cá Hồi Hoang": {
|
||||
"id": "artist-Cá-Hồi-Hoang",
|
||||
"title": "Cá Hồi Hoang",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/H8NTALzm0F4/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhlIGUoTTAP&rs=AOn4CLA6owTCPQWQpmo8AQpGd4pUILN2KQ",
|
||||
"type": "Artist",
|
||||
"creator": "Cá Hồi Hoang",
|
||||
"tracks": []
|
||||
},
|
||||
"Chillies": {
|
||||
"id": "artist-Chillies",
|
||||
"title": "Chillies",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/Pk4FJYs0a58/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA_80IsN6-TR0DeQixnTubUJnOnlQ",
|
||||
"type": "Artist",
|
||||
"creator": "Chillies",
|
||||
"tracks": []
|
||||
},
|
||||
"Tăng Duy Tân": {
|
||||
"id": "artist-Tăng-Duy-Tân",
|
||||
"title": "Tăng Duy Tân",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/1So7VBehCQg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCtU8tfN3WpQftpgqAbnE6Milu9CA",
|
||||
"type": "Artist",
|
||||
"creator": "Tăng Duy Tân",
|
||||
"tracks": []
|
||||
},
|
||||
"Văn Mai Hương": {
|
||||
"id": "artist-Văn-Mai-Hương",
|
||||
"title": "Văn Mai Hương",
|
||||
"description": "Artist",
|
||||
"cover_url": "https://i.ytimg.com/vi/UKoay9jU0X4/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBZX6UprWadmKaMDudQ0q3ZQiDcSQ",
|
||||
"type": "Artist",
|
||||
"creator": "Văn Mai Hương",
|
||||
"tracks": []
|
||||
},
|
||||
"Diệu Kỳ Việt Nam Album": {
|
||||
"id": "album-Diệu-Kỳ-Việt-Nam",
|
||||
"title": "Diệu Kỳ Việt Nam",
|
||||
"description": "Album • Various Artists",
|
||||
"cover_url": "https://i.ytimg.com/vi/BkUSfsfGmfM/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBpcLC06Qf9WKGbV_HGmAQpHfO3zw",
|
||||
"type": "Album",
|
||||
"creator": "Various Artists",
|
||||
"tracks": []
|
||||
},
|
||||
"LINK Album": {
|
||||
"id": "album-LINK",
|
||||
"title": "LINK",
|
||||
"description": "Album • Hoàng Thùy Linh",
|
||||
"cover_url": "https://i.ytimg.com/vi/MuHxxRufmMQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCv5LhWls8lF8t0-rn23tUlwdIk-g",
|
||||
"type": "Album",
|
||||
"creator": "Hoàng Thùy Linh",
|
||||
"tracks": []
|
||||
},
|
||||
"99% Album": {
|
||||
"id": "album-99%",
|
||||
"title": "99%",
|
||||
"description": "Album • MCK",
|
||||
"cover_url": "https://i.ytimg.com/vi/dz6xe0xXqYE/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARh_IDQoMzAP&rs=AOn4CLCCuKsBhf_XCEoKRr49cZ1hrRozZw",
|
||||
"type": "Album",
|
||||
"creator": "MCK",
|
||||
"tracks": []
|
||||
},
|
||||
"Cong Album": {
|
||||
"id": "album-Cong",
|
||||
"title": "Cong",
|
||||
"description": "Album • Tóc Tiên",
|
||||
"cover_url": "https://i.ytimg.com/vi/FC9R18OQfbU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDi_uIbcx2MF8VaAClukECBHBG2Sg",
|
||||
"type": "Album",
|
||||
"creator": "Tóc Tiên",
|
||||
"tracks": []
|
||||
},
|
||||
"Một Vạn Năm Album": {
|
||||
"id": "album-Một-Vạn-Năm",
|
||||
"title": "Một Vạn Năm",
|
||||
"description": "Album • Vũ.",
|
||||
"cover_url": "https://i.ytimg.com/vi/dJaxhdDzIDQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAR-7bYtZVIU4wYjsOlJcb0QH-8TQ",
|
||||
"type": "Album",
|
||||
"creator": "Vũ.",
|
||||
"tracks": []
|
||||
},
|
||||
"Rap Việt Season 3 Album": {
|
||||
"id": "album-Rap-Việt-Season-3",
|
||||
"title": "Rap Việt Season 3",
|
||||
"description": "Album • Various Artists",
|
||||
"cover_url": "https://i.ytimg.com/vi/MbqTBRqFofg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDnixgazwv5VVs4vB-pRy7pl9GrnQ",
|
||||
"type": "Album",
|
||||
"creator": "Various Artists",
|
||||
"tracks": []
|
||||
},
|
||||
"Midnights Album": {
|
||||
"id": "album-Midnights",
|
||||
"title": "Midnights",
|
||||
"description": "Album • Taylor Swift",
|
||||
"cover_url": "https://i.ytimg.com/vi/M2atIJWDPcY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCSUAi5jQSKLNM97GPwjcy_8pS1yA",
|
||||
"type": "Album",
|
||||
"creator": "Taylor Swift",
|
||||
"tracks": []
|
||||
},
|
||||
"Human Album": {
|
||||
"id": "album-Human",
|
||||
"title": "Human",
|
||||
"description": "Album • Tùng Dương",
|
||||
"cover_url": "https://i.ytimg.com/vi/B88ZCv7uWYA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBwTr5vuN1h-oUjP2naWnKxF9P33g",
|
||||
"type": "Album",
|
||||
"creator": "Tùng Dương",
|
||||
"tracks": []
|
||||
},
|
||||
"Yên Album": {
|
||||
"id": "album-Yên",
|
||||
"title": "Yên",
|
||||
"description": "Album • Hoàng Dũng",
|
||||
"cover_url": "https://i.ytimg.com/vi/4tSmJ6wUMk0/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBAiqSVOTLIBQwRxbYfdt-csiY-bA",
|
||||
"type": "Album",
|
||||
"creator": "Hoàng Dũng",
|
||||
"tracks": []
|
||||
},
|
||||
"WeChoice Awards 2023 Album": {
|
||||
"id": "album-WeChoice-Awards-2023",
|
||||
"title": "WeChoice Awards 2023",
|
||||
"description": "Album • Various Artists",
|
||||
"cover_url": "https://i.ytimg.com/vi/i_ec-92cdJI/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCu00iBqsbp3WOx2bQeP6tTMZZwQg",
|
||||
"type": "Album",
|
||||
"creator": "Various Artists",
|
||||
"tracks": []
|
||||
},
|
||||
"Citopia Album": {
|
||||
"id": "album-Citopia",
|
||||
"title": "Citopia",
|
||||
"description": "Album • Phùng Khánh Linh",
|
||||
"cover_url": "https://i.ytimg.com/vi/xl7AfxJ5tMk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBwv-4XPbN61rT5OFBcR5LXjvGfHg",
|
||||
"type": "Album",
|
||||
"creator": "Phùng Khánh Linh",
|
||||
"tracks": []
|
||||
},
|
||||
"DreAMEE Album": {
|
||||
"id": "album-DreAMEE",
|
||||
"title": "DreAMEE",
|
||||
"description": "Album • Amee",
|
||||
"cover_url": "https://i.ytimg.com/vi/BBytiT94y2A/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBtKLHlKAhm6DabIRrnG28WygLHRQ",
|
||||
"type": "Album",
|
||||
"creator": "Amee",
|
||||
"tracks": []
|
||||
},
|
||||
"Sky Tour Album": {
|
||||
"id": "album-Sky-Tour",
|
||||
"title": "Sky Tour",
|
||||
"description": "Album • Sơn Tùng M-TP",
|
||||
"cover_url": "https://i.ytimg.com/vi/Gml7neJG7MI/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC7J81KA-cSdpPxEtD_gaxLw4vuaQ",
|
||||
"type": "Album",
|
||||
"creator": "Sơn Tùng M-TP",
|
||||
"tracks": []
|
||||
},
|
||||
"Loi Choi Album": {
|
||||
"id": "album-Loi-Choi",
|
||||
"title": "Loi Choi",
|
||||
"description": "Album • Wren Evans",
|
||||
"cover_url": "https://i.ytimg.com/vi/QZrZbEEAOZ8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBzZNfyqJxPl3lO-2KLC2IHdsjdEw",
|
||||
"type": "Album",
|
||||
"creator": "Wren Evans",
|
||||
"tracks": []
|
||||
},
|
||||
"Vũ Trụ Cò Bay Album": {
|
||||
"id": "album-Vũ-Trụ-Cò-Bay",
|
||||
"title": "Vũ Trụ Cò Bay",
|
||||
"description": "Album • Phương Mỹ Chi",
|
||||
"cover_url": "https://i.ytimg.com/vi/XAFfGb8V4bs/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAz-t-1Jeu5J2zE4kVCv6AL1rwPGQ",
|
||||
"type": "Album",
|
||||
"creator": "Phương Mỹ Chi",
|
||||
"tracks": []
|
||||
},
|
||||
"Hidden Gem Album": {
|
||||
"id": "album-Hidden-Gem",
|
||||
"title": "Hidden Gem",
|
||||
"description": "Album • Various Artists",
|
||||
"cover_url": "https://i.ytimg.com/vi/I9DoRLyZbq8/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARh_IEUoITAP&rs=AOn4CLBVTnD_weqLwPq2QGGI4vib67m6JA",
|
||||
"type": "Album",
|
||||
"creator": "Various Artists",
|
||||
"tracks": []
|
||||
},
|
||||
"Stardom Album": {
|
||||
"id": "album-Stardom",
|
||||
"title": "Stardom",
|
||||
"description": "Album • Vũ Cát Tường",
|
||||
"cover_url": "https://i.ytimg.com/vi/G12V5_HLYIQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_mhx6NN5QbQlgi4ZAtp2Q0Kmvog",
|
||||
"type": "Album",
|
||||
"creator": "Vũ Cát Tường",
|
||||
"tracks": []
|
||||
},
|
||||
"Hương Album": {
|
||||
"id": "album-Hương",
|
||||
"title": "Hương",
|
||||
"description": "Album • Văn Mai Hương",
|
||||
"cover_url": "https://i.ytimg.com/vi/ZC0uQ9B7DFk/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAA4nuVj9QbNL85IUCt_PJjthrzjQ",
|
||||
"type": "Album",
|
||||
"creator": "Văn Mai Hương",
|
||||
"tracks": []
|
||||
},
|
||||
"Ai Cũng Phải Bắt Đầu Từ Đâu Đó Album": {
|
||||
"id": "album-Ai-Cũng-Phải-Bắt-Đầu-Từ-Đâu-Đó",
|
||||
"title": "Ai Cũng Phải Bắt Đầu Từ Đâu Đó",
|
||||
"description": "Album • HIEUTHUHAI",
|
||||
"cover_url": "https://i.ytimg.com/vi/apGwEpS8tqg/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAaswFDv9on-M72RkW1yCbSyhG19w",
|
||||
"type": "Album",
|
||||
"creator": "HIEUTHUHAI",
|
||||
"tracks": []
|
||||
},
|
||||
"After Hours Album": {
|
||||
"id": "album-After-Hours",
|
||||
"title": "After Hours",
|
||||
"description": "Album • The Weeknd",
|
||||
"cover_url": "https://i.ytimg.com/vi/ygTZZpVkmKg/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhlIGAoQTAP&rs=AOn4CLAJw5eqVSypwCDp_Lv1UHDYZKzdLw",
|
||||
"type": "Album",
|
||||
"creator": "The Weeknd",
|
||||
"tracks": []
|
||||
},
|
||||
"Bolero Tru Tinh": {
|
||||
"id": "playlist-Bolero-Tru-Tinh",
|
||||
"title": "Bolero Tru Tinh",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/muDs2wLpBtE/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLApKILStj6RV-kl6tdEPIPj45h0-A",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Viral Hits Vietnam": {
|
||||
"id": "playlist-Viral-Hits-Vietnam",
|
||||
"title": "Viral Hits Vietnam",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/UdyGlHhI8tQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCAY2u4FOdXEMC-6C95u8xBsdO6nQ",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Acoustic Thu Gian": {
|
||||
"id": "playlist-Acoustic-Thu-Gian",
|
||||
"title": "Acoustic Thu Gian",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/IoKQ905zrxU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBJPpS_yUSyDUv9PjMFPjK3H1kdmA",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Workout Energy": {
|
||||
"id": "playlist-Workout-Energy",
|
||||
"title": "Workout Energy",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/dZ1tiIgLvro/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAjtQ3hwKesNVywnI49oV2x4PV-IQ",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Lofi Chill Vietnam": {
|
||||
"id": "playlist-Lofi-Chill-Vietnam",
|
||||
"title": "Lofi Chill Vietnam",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/VQoju_Dfi94/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAmHCuyDdiQqJ345GRQgLLhhcXU2g",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Party Anthems": {
|
||||
"id": "playlist-Party-Anthems",
|
||||
"title": "Party Anthems",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/aNSiNYb4c9c/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDgx99jZRe8m7XqBpwutGiMIT8AJQ",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Piano Focus": {
|
||||
"id": "playlist-Piano-Focus",
|
||||
"title": "Piano Focus",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/AnlKTlJLkro/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCI00FI0HZWHQWuACTV5HQV7Cgcyg",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Nhac Trinh Cong Son": {
|
||||
"id": "playlist-Nhac-Trinh-Cong-Son",
|
||||
"title": "Nhac Trinh Cong Son",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/m9CBkq1wu54/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCiCeeUxKjO2ePbLrGEOSfLRyqK_g",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Indie Vietnam": {
|
||||
"id": "playlist-Indie-Vietnam",
|
||||
"title": "Indie Vietnam",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/p0ODaducyPo/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBUfHeOzENXCm2yVU_oZSeOiGQLOw",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Nhac Tre Moi Nhat": {
|
||||
"id": "playlist-Nhac-Tre-Moi-Nhat",
|
||||
"title": "Nhac Tre Moi Nhat",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/q2Ev43H-nmc/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD_Zlz1YrxVVEOHgUrsaYo13EytEA",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"K-Pop ON!": {
|
||||
"id": "playlist-K-Pop-ON!",
|
||||
"title": "K-Pop ON!",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/eCVJyexv1kg/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDRg6HvyBxQm2dzzkiQE8f6hSZSBw",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"K-Pop Daebak": {
|
||||
"id": "playlist-K-Pop-Daebak",
|
||||
"title": "K-Pop Daebak",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/yf0O4WZVJqQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDG99KV-WjOmqf0pKu0jb7CLa7H0A",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Top 50 Vietnam": {
|
||||
"id": "playlist-Top-50-Vietnam",
|
||||
"title": "Top 50 Vietnam",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/bEas0GWDm1k/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCufqFtBRF6_VlO_-EXC3qdx3VR-A",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Sleep Sounds": {
|
||||
"id": "playlist-Sleep-Sounds",
|
||||
"title": "Sleep Sounds",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/LFASWuckB1c/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCotlKXoysvRmnwvONiMwGRlTDYNg",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Gaming Music": {
|
||||
"id": "playlist-Gaming-Music",
|
||||
"title": "Gaming Music",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/PP2Uvesx4ls/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAl7lLrOqFoRf2b9F6pvcdEwKKPvA",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Anime Hits": {
|
||||
"id": "playlist-Anime-Hits",
|
||||
"title": "Anime Hits",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/4N9HmMNf7EU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBtT7xbe4H2NeuykSQ3UiJpGPanJw",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Beast Mode": {
|
||||
"id": "playlist-Beast-Mode",
|
||||
"title": "Beast Mode",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/lwsxS4LZCxY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCgUJE9tP7WenyhCvsWh1g-88TMSw",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Vietnam Top Hits 2024": {
|
||||
"id": "playlist-Vietnam-Top-Hits-2024",
|
||||
"title": "Vietnam Top Hits 2024",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/ZYDt8WGo00A/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhyIFcoQTAP&rs=AOn4CLDg-X3e_RvvsJ9IMHzkF-SvLZKebQ",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"V-Pop Rising": {
|
||||
"id": "playlist-V-Pop-Rising",
|
||||
"title": "V-Pop Rising",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/eymezvWJkAo/hq720.jpg?sqp=-oaymwE2CNAFEJQDSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gmAAtAFigIMCAAQARhaIFooWjAP&rs=AOn4CLCTUwgdyKTtaKMrOi2f8MwRbmJt-w",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
},
|
||||
"Rap Viet All Stars": {
|
||||
"id": "playlist-Rap-Viet-All-Stars",
|
||||
"title": "Rap Viet All Stars",
|
||||
"description": "Playlist • Trending",
|
||||
"cover_url": "https://i.ytimg.com/vi/okD3pw2t59Y/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAPTN96Wl14jH7STJdRyMoVjqWIZg",
|
||||
"type": "Playlist",
|
||||
"tracks": []
|
||||
}
|
||||
};
|
||||
35
frontend-vite/src/hooks/useDominantColor.ts
Normal file
35
frontend-vite/src/hooks/useDominantColor.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDominantColor(imageUrl?: string) {
|
||||
const [color, setColor] = useState<string>('#121212'); // Default to dark grey
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) return;
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "Anonymous";
|
||||
img.src = imageUrl;
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
|
||||
// Draw image to 1x1 canvas to get average color
|
||||
ctx.drawImage(img, 0, 0, 1, 1);
|
||||
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
setColor(`rgb(${r}, ${g}, ${b})`);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
// Fallback or keep previous
|
||||
};
|
||||
|
||||
}, [imageUrl]);
|
||||
|
||||
return color;
|
||||
}
|
||||
21
frontend-vite/src/hooks/useInfiniteScroll.ts
Normal file
21
frontend-vite/src/hooks/useInfiniteScroll.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export function useInfiniteScroll(callback: () => void, isFetching: boolean = false) {
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
const lastElementRef = useCallback((node: HTMLElement | null) => {
|
||||
if (isFetching) return;
|
||||
|
||||
if (observer.current) observer.current.disconnect();
|
||||
|
||||
observer.current = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
if (node) observer.current.observe(node);
|
||||
}, [isFetching, callback]);
|
||||
|
||||
return lastElementRef;
|
||||
}
|
||||
76
frontend-vite/src/hooks/useLyrics.ts
Normal file
76
frontend-vite/src/hooks/useLyrics.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { libraryService } from '../services/library';
|
||||
|
||||
export interface LyricLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function useLyrics(trackTitle: string, artistName: string, currentTime: number, enabled: boolean = true) {
|
||||
const [lyrics, setLyrics] = useState<string | null>(null);
|
||||
const [syncedLines, setSyncedLines] = useState<LyricLine[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackTitle && artistName && enabled) {
|
||||
setLoading(true);
|
||||
setLyrics(null);
|
||||
setSyncedLines([]);
|
||||
|
||||
libraryService.getLyrics(trackTitle, artistName)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
if (data.syncedLyrics) {
|
||||
setSyncedLines(parseSyncedLyrics(data.syncedLyrics));
|
||||
} else {
|
||||
setLyrics(data.plainLyrics || "No lyrics available.");
|
||||
}
|
||||
} else {
|
||||
setLyrics(null);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [trackTitle, artistName]);
|
||||
|
||||
const activeIndex = useMemo(() => {
|
||||
return syncedLines.findIndex((line, i) => {
|
||||
const nextLine = syncedLines[i + 1];
|
||||
return currentTime >= line.time && (!nextLine || currentTime < nextLine.time);
|
||||
});
|
||||
}, [syncedLines, currentTime]);
|
||||
|
||||
const currentLine = activeIndex !== -1 ? syncedLines[activeIndex] : null;
|
||||
const nextLine = activeIndex !== -1 && activeIndex + 1 < syncedLines.length ? syncedLines[activeIndex + 1] : null;
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
syncedLines,
|
||||
loading,
|
||||
activeIndex,
|
||||
currentLine,
|
||||
nextLine
|
||||
};
|
||||
}
|
||||
|
||||
function parseSyncedLyrics(lrc: string): LyricLine[] {
|
||||
const lines = lrc.split('\n');
|
||||
const result: LyricLine[] = [];
|
||||
const regex = /\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/;
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(regex);
|
||||
if (match) {
|
||||
const min = parseInt(match[1]);
|
||||
const sec = parseInt(match[2]);
|
||||
const ms = parseInt(match[3].length === 2 ? match[3] + '0' : match[3]); // Normalize ms
|
||||
const time = min * 60 + sec + ms / 1000;
|
||||
const text = match[4].trim();
|
||||
if (text) result.push({ time, text });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
346
frontend-vite/src/index.css
Normal file
346
frontend-vite/src/index.css
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #000000;
|
||||
--foreground: #FFFFFF;
|
||||
|
||||
/* Spotify Theme (Default) */
|
||||
--spotify-base: #000000;
|
||||
--spotify-sidebar: #000000;
|
||||
--spotify-player: #212121;
|
||||
--spotify-highlight: #FFFFFF;
|
||||
--spotify-hover: #ffffff1a;
|
||||
--spotify-text-main: #FFFFFF;
|
||||
--spotify-text-muted: #AAAAAA;
|
||||
--spotify-card: #000000;
|
||||
--spotify-card-hover: #181818;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Global Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
:root[data-theme='apple'] {
|
||||
/* Apple Music Dark Theme - 100% Accurate */
|
||||
--background: #1C1C1E;
|
||||
/* System Gray 6 (Dark) */
|
||||
--foreground: #FFFFFF;
|
||||
|
||||
--spotify-base: #1C1C1E;
|
||||
--spotify-sidebar: rgba(28, 28, 30, 0.75);
|
||||
/* Translucent sidebar */
|
||||
--spotify-player: rgba(28, 28, 30, 0.85);
|
||||
/* Glass player */
|
||||
--spotify-highlight: #FA243C;
|
||||
/* Apple Music Red */
|
||||
--spotify-hover: rgba(255, 255, 255, 0.08);
|
||||
--spotify-text-main: #FFFFFF;
|
||||
--spotify-text-muted: #8E8E93;
|
||||
/* System Gray */
|
||||
--spotify-card: rgba(255, 255, 255, 0.06);
|
||||
--spotify-card-hover: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--spotify-base);
|
||||
color: var(--spotify-text-main);
|
||||
font-family: var(--font-family);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Soundwave Logo Animations */
|
||||
@keyframes soundwave-1 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes soundwave-2 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes soundwave-3 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes soundwave-4 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-soundwave-1 {
|
||||
animation: soundwave-1 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-soundwave-2 {
|
||||
animation: soundwave-2 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-soundwave-3 {
|
||||
animation: soundwave-3 0.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-soundwave-4 {
|
||||
animation: soundwave-4 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Fade-in Animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Fade-in from bottom */
|
||||
@keyframes slideInBottom {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in-from-bottom {
|
||||
animation: slideInBottom 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Pulse animation for loading */
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Spin animation */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Custom Range Slider */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: #4d4d4d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
margin-top: -4px;
|
||||
opacity: 1;
|
||||
/* Always visible */
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
input[type="range"]:active::-webkit-slider-thumb {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: none;
|
||||
opacity: 1;
|
||||
/* Always visible */
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
input[type="range"]:active::-moz-range-thumb {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Accent colors on hover */
|
||||
input[type="range"].accent-green::-webkit-slider-thumb {
|
||||
background: var(--spotify-highlight);
|
||||
}
|
||||
|
||||
input[type="range"].accent-green::-moz-range-thumb {
|
||||
background: var(--spotify-highlight);
|
||||
}
|
||||
|
||||
/* Backdrop blur for modals */
|
||||
.backdrop-blur-sm {
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.text-spotify-text-muted {
|
||||
color: var(--spotify-text-muted);
|
||||
}
|
||||
|
||||
.text-spotify-text-subdued {
|
||||
color: var(--spotify-text-subdued);
|
||||
}
|
||||
|
||||
/* Background Colors */
|
||||
.bg-spotify-base {
|
||||
background-color: var(--spotify-base);
|
||||
}
|
||||
|
||||
.bg-spotify-card {
|
||||
background-color: var(--spotify-card);
|
||||
}
|
||||
|
||||
.bg-spotify-card-hover {
|
||||
background-color: var(--spotify-card-hover);
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
button,
|
||||
a,
|
||||
.transition {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Staggered animation utility */
|
||||
.fill-mode-backwards {
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--spotify-highlight);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
13
frontend-vite/src/main.tsx
Normal file
13
frontend-vite/src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
136
frontend-vite/src/pages/Album.tsx
Normal file
136
frontend-vite/src/pages/Album.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { libraryService } from '../services/library';
|
||||
import { usePlayer } from '../context/PlayerContext';
|
||||
import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react';
|
||||
import { Track } from '../types';
|
||||
|
||||
export default function Album() {
|
||||
const { id } = useParams();
|
||||
const { playTrack, toggleLike, likedTracks } = usePlayer();
|
||||
const [tracks, setTracks] = useState<Track[]>([]);
|
||||
const [albumInfo, setAlbumInfo] = useState<{ title: string, artist: string, cover?: string, year?: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
// If ID is from YTM, ideally we fetch album.
|
||||
// If logic is "Search Album", we do that.
|
||||
|
||||
const fetchAlbum = async () => {
|
||||
// For now, assume ID is search query or we query "Album"
|
||||
// In this reskin, we usually pass Name as ID due to router setup in Home.
|
||||
|
||||
const query = decodeURIComponent(id);
|
||||
try {
|
||||
const results = await libraryService.search(query);
|
||||
if (results.length > 0) {
|
||||
setTracks(results);
|
||||
setAlbumInfo({
|
||||
title: query.replace(/^search-|^album-/, '').replace(/-/g, ' '), // Clean up slug
|
||||
artist: results[0].artist,
|
||||
cover: results[0].cover_url,
|
||||
year: '2024' // Mock or fetch
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchAlbum();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-full"><div className="animate-spin rounded-full h-12 w-12 border-t-2 border-white"></div></div>;
|
||||
if (!albumInfo) return <div>Album not found</div>;
|
||||
|
||||
const totalDuration = tracks.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const formattedDuration = `${Math.floor(totalDuration / 60)} minutes`;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-gradient-to-b from-[#2e2e2e] to-black pb-32">
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end bg-gradient-to-b from-black/20 to-black/60 pt-16 md:pt-12">
|
||||
{/* Cover */}
|
||||
<div className="w-40 h-40 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-lg overflow-hidden shrink-0">
|
||||
<img src={albumInfo.cover} alt={albumInfo.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-4 flex-1">
|
||||
<span className="text-xs md:text-sm font-bold tracking-widest uppercase text-white/70">Album</span>
|
||||
<h1 className="text-2xl md:text-6xl font-black text-white leading-tight">{albumInfo.title}</h1>
|
||||
<div className="flex flex-wrap justify-center md:justify-start items-center gap-2 text-white/80 font-medium text-sm md:text-base">
|
||||
<img src={albumInfo.cover} className="w-6 h-6 rounded-full" />
|
||||
<span className="hover:underline cursor-pointer">{albumInfo.artist}</span>
|
||||
<span>•</span>
|
||||
<span>{albumInfo.year}</span>
|
||||
<span>•</span>
|
||||
<span>{tracks.length} songs, {formattedDuration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 py-3 flex items-center justify-center gap-6 bg-black/20 backdrop-blur-sm sticky top-0 z-10 md:px-8">
|
||||
<button
|
||||
onClick={() => tracks.length > 0 && playTrack(tracks[0])} // Should play all
|
||||
className="bg-white text-black px-8 py-2 rounded-full font-bold text-sm hover:scale-105 transition flex items-center gap-2 shadow-lg hover:shadow-xl hover:bg-neutral-200"
|
||||
>
|
||||
<Play fill="currentColor" size={18} />
|
||||
Play
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="p-2 text-neutral-400 hover:text-white transition border border-white/10 rounded-full hover:bg-white/10 hover:border-white">
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
<button className="p-2 text-neutral-400 hover:text-white transition border border-white/10 rounded-full hover:bg-white/10 hover:border-white">
|
||||
<ListPlus size={20} />
|
||||
</button>
|
||||
<button className="p-2 text-neutral-400 hover:text-white transition border border-white/10 rounded-full hover:bg-white/10 hover:border-white">
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tracklist */}
|
||||
<div className="p-4 md:p-8">
|
||||
{/* Header Row */}
|
||||
<div className="flex items-center text-sm text-neutral-400 border-b border-white/10 pb-2 mb-4 px-4 sticky top-20 bg-[#121212] z-10">
|
||||
<span className="w-10 text-center">#</span>
|
||||
<span className="flex-1">Title</span>
|
||||
<span className="hidden md:block w-12 text-right"><Clock size={16} /></span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{tracks.map((track, i) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group flex items-center p-3 rounded-md hover:bg-white/10 transition cursor-pointer"
|
||||
onClick={() => playTrack(track, tracks)}
|
||||
>
|
||||
<span className="w-10 text-center text-neutral-500 font-medium group-hover:hidden">{i + 1}</span>
|
||||
<Play size={16} className="w-10 hidden group-hover:block fill-white pl-2" />
|
||||
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<div className="font-medium text-white truncate text-base">{track.title}</div>
|
||||
<div className="text-sm text-neutral-400 truncate group-hover:text-white/70">{track.artist}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleLike(track); }}
|
||||
className={`mr-6 ${likedTracks.has(track.id) ? 'text-green-500 opacity-100' : 'text-neutral-400 opacity-0 group-hover:opacity-100'} hover:scale-110 transition`}
|
||||
>
|
||||
<Heart size={18} fill={likedTracks.has(track.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
|
||||
<span className="text-neutral-500 text-sm hidden md:block w-12 text-right font-mono">
|
||||
{Math.floor((track.duration || 0) / 60)}:{((track.duration || 0) % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
frontend-vite/src/pages/Artist.tsx
Normal file
236
frontend-vite/src/pages/Artist.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { libraryService } from '../services/library';
|
||||
import { usePlayer } from '../context/PlayerContext';
|
||||
import { Play, Shuffle, Heart, Disc, Music } from 'lucide-react';
|
||||
import { Track } from '../types';
|
||||
import { GENERATED_CONTENT } from '../data/seed_data';
|
||||
|
||||
interface ArtistData {
|
||||
name: string;
|
||||
photo?: string;
|
||||
topSongs: Track[];
|
||||
albums: any[]; // Extended type needed
|
||||
singles: any[];
|
||||
}
|
||||
|
||||
export default function Artist() {
|
||||
const { id } = useParams(); // Start with name or id
|
||||
const navigate = useNavigate();
|
||||
const { playTrack, toggleLike, likedTracks } = usePlayer();
|
||||
|
||||
const [artist, setArtist] = useState<ArtistData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// YouTube Music uses name-based IDs or channel IDs.
|
||||
// Our 'id' might be a name if clicked from Home.
|
||||
// If it's a UUID (from DB), we might need to look up.
|
||||
// For now, assume ID = Name or handle both.
|
||||
const artistName = decodeURIComponent(id || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!artistName) return;
|
||||
|
||||
// OPTIMISTIC LOADING START
|
||||
// 1. Try to find in Seed Data first for instant header
|
||||
// Seed data keys are names, but IDs are "artist-Name"
|
||||
const seedArtist = Object.values(GENERATED_CONTENT).find(
|
||||
item => item.id === id || item.title === artistName || item.id === `artist-${artistName.replace(/ /g, '-')}`
|
||||
);
|
||||
|
||||
if (seedArtist) {
|
||||
setArtist({
|
||||
name: seedArtist.title,
|
||||
photo: seedArtist.cover_url,
|
||||
topSongs: [], // Will load
|
||||
albums: [],
|
||||
singles: []
|
||||
});
|
||||
setLoading(false); // Show UI immediately!
|
||||
} else {
|
||||
setLoading(true); // Only blocking load if we have ZERO data
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
// Fetch info (Background)
|
||||
// If we already have photo from seed, maybe skip or update?
|
||||
// libraryService.getArtistInfo might find a better photo or same.
|
||||
|
||||
// Parallel Fetch for speed
|
||||
const [info, songs] = await Promise.allSettled([
|
||||
!seedArtist?.cover_url ? libraryService.getArtistInfo(artistName) : Promise.resolve({ photo: seedArtist.cover_url }),
|
||||
libraryService.search(artistName)
|
||||
]);
|
||||
|
||||
const finalPhoto = (info.status === 'fulfilled' && info.value?.photo) ? info.value.photo : seedArtist?.cover_url;
|
||||
let topSongs = (songs.status === 'fulfilled') ? songs.value : [];
|
||||
|
||||
if (topSongs.length > 5) topSongs = topSongs.slice(0, 5);
|
||||
|
||||
setArtist({
|
||||
name: artistName,
|
||||
photo: finalPhoto,
|
||||
topSongs,
|
||||
albums: [],
|
||||
singles: []
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [artistName, id]);
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-white"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!artist) return <div>Artist not found</div>;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-black pb-32">
|
||||
{/* Header / Banner */}
|
||||
<div className="relative h-[40vh] min-h-[300px] w-full group">
|
||||
{artist.photo && (
|
||||
<div className="absolute inset-0">
|
||||
<img src={artist.photo} alt={artist.name} className="w-full h-full object-cover opacity-60 mask-gradient-b" />
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-0 left-0 p-4 md:p-8 w-full">
|
||||
<h1 className="text-3xl md:text-7xl font-bold mb-4 md:mb-6 tracking-tight text-white drop-shadow-lg">{artist.name}</h1>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => artist.topSongs.length > 0 && playTrack(artist.topSongs[0])}
|
||||
className="bg-white text-black px-8 py-3 rounded-full font-bold text-lg hover:scale-105 transition flex items-center gap-2"
|
||||
>
|
||||
<Play fill="currentColor" size={20} />
|
||||
Play
|
||||
</button>
|
||||
<button className="bg-white/10 backdrop-blur-md text-white px-6 py-3 rounded-full font-bold text-lg hover:bg-white/20 transition border border-white/20 flex items-center gap-2">
|
||||
<Shuffle size={20} />
|
||||
Shuffle
|
||||
</button>
|
||||
<button className="bg-white/10 backdrop-blur-md text-white p-3 rounded-full hover:bg-white/20 transition border border-white/20">
|
||||
<Heart size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 md:p-8 space-y-8 md:space-y-12 max-w-7xl mx-auto">
|
||||
{/* Top Songs */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-6">Top Songs</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{artist.topSongs.length === 0 ? (
|
||||
// Skeleton Loading for Songs
|
||||
[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center p-3 gap-4 animate-pulse">
|
||||
<div className="w-8 h-4 bg-white/10 rounded" />
|
||||
<div className="w-12 h-12 bg-white/10 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="w-1/3 h-4 bg-white/10 rounded" />
|
||||
<div className="w-1/4 h-3 bg-white/10 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
artist.topSongs.map((track, i) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group flex items-center p-3 rounded-md hover:bg-white/10 transition cursor-pointer"
|
||||
onClick={() => playTrack(track, artist.topSongs)}
|
||||
>
|
||||
<span className="w-8 text-center text-neutral-500 font-medium group-hover:hidden">{i + 1}</span>
|
||||
<Play size={16} className="w-8 hidden group-hover:block fill-white" />
|
||||
|
||||
<img src={track.cover_url} alt="Cover" className="w-12 h-12 rounded mx-4 object-cover" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-white truncate">{track.title}</div>
|
||||
<div className="text-sm text-neutral-400 truncate">{track.artist} • {track.album || 'Single'}</div>
|
||||
</div>
|
||||
|
||||
<span className="text-neutral-500 text-sm hidden md:block mr-8">
|
||||
{Math.floor((track.duration || 0) / 60)}:{((track.duration || 0) % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleLike(track); }}
|
||||
className={`${likedTracks.has(track.id) ? 'text-green-500 opacity-100' : 'text-neutral-400 opacity-0 group-hover:opacity-100'} hover:scale-110 transition`}
|
||||
>
|
||||
<Heart size={18} fill={likedTracks.has(track.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
)))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Albums (Mock UI for now as strict album search is hard with yt-dlp only) */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">Albums</h2>
|
||||
<button className="text-sm font-bold text-neutral-400 hover:text-white uppercase tracking-wider">See All</button>
|
||||
</div>
|
||||
{/* Placeholder Logic: Show top song covers as "Albums" for visual parity if no real albums */}
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-6">
|
||||
{artist.topSongs.slice(0, 5).map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => playTrack(track, [track])}
|
||||
>
|
||||
<div className="aspect-square bg-neutral-900 rounded-lg overflow-hidden mb-3 relative">
|
||||
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
|
||||
<div className="bg-white text-black p-3 rounded-full hover:scale-110 transition">
|
||||
<Play fill="currentColor" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold truncate text-white">{track.album || track.title}</h3>
|
||||
<div className="flex items-center gap-1 text-sm text-neutral-400">
|
||||
<Disc size={14} />
|
||||
<span>Album</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Singles */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-6">Singles</h2>
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-6">
|
||||
{artist.topSongs.slice(0, 4).reverse().map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => playTrack(track, [track])}
|
||||
>
|
||||
<div className="aspect-square bg-neutral-900 rounded-xl overflow-hidden mb-3 relative border-2 border-neutral-800">
|
||||
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
|
||||
<div className="bg-white text-black p-3 rounded-full hover:scale-110 transition">
|
||||
<Play fill="currentColor" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold truncate text-center text-white">{track.title}</h3>
|
||||
<div className="flex items-center justify-center gap-1 text-sm text-neutral-400">
|
||||
<Music size={14} />
|
||||
<span>Single</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
frontend-vite/src/pages/Collection.tsx
Normal file
150
frontend-vite/src/pages/Collection.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { Play, Pause, Heart, Clock, Shuffle, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { usePlayer } from '../context/PlayerContext';
|
||||
import CoverImage from '../components/CoverImage';
|
||||
|
||||
export default function Collection() {
|
||||
const { likedTracksData, playTrack, currentTrack, isPlaying, togglePlay } = usePlayer();
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (likedTracksData.length > 0) {
|
||||
playTrack(likedTracksData[0], likedTracksData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShufflePlay = () => {
|
||||
if (likedTracksData.length > 0) {
|
||||
const shuffled = [...likedTracksData].sort(() => Math.random() - 0.5);
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '--:--';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto no-scrollbar pb-24">
|
||||
{/* Hero Header */}
|
||||
<div className="h-72 md:h-80 bg-gradient-to-b from-indigo-800 to-[#121212] p-6 md:p-8 flex flex-col md:flex-row items-center md:items-end relative">
|
||||
<Link to="/library" className="absolute top-4 left-4 md:hidden">
|
||||
<ArrowLeft className="w-6 h-6" />
|
||||
</Link>
|
||||
|
||||
<div className="w-40 h-40 md:w-56 md:h-56 bg-gradient-to-br from-indigo-700 via-purple-600 to-blue-400 rounded-md shadow-2xl flex items-center justify-center mb-4 md:mb-0 md:mr-8 flex-shrink-0">
|
||||
<Heart className="w-20 h-20 md:w-24 md:h-24 text-white fill-white" />
|
||||
</div>
|
||||
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-xs font-bold uppercase tracking-wider mb-1">Playlist</p>
|
||||
<h1 className="text-4xl md:text-6xl font-black mb-4">Liked Songs</h1>
|
||||
<p className="text-sm text-neutral-300">
|
||||
{likedTracksData.length} {likedTracksData.length === 1 ? 'song' : 'songs'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-6 py-4 flex items-center gap-4">
|
||||
<button
|
||||
onClick={handlePlayAll}
|
||||
disabled={likedTracksData.length === 0}
|
||||
className="w-14 h-14 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105 transition shadow-lg disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-6 h-6 text-black fill-black ml-1" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleShufflePlay}
|
||||
disabled={likedTracksData.length === 0}
|
||||
className="text-neutral-400 hover:text-white transition disabled:opacity-50"
|
||||
>
|
||||
<Shuffle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Track List */}
|
||||
<div className="px-6">
|
||||
{/* Header */}
|
||||
<div className="hidden md:grid grid-cols-[16px_4fr_2fr_1fr] gap-4 px-4 py-2 text-sm text-neutral-400 border-b border-white/10 mb-2">
|
||||
<span>#</span>
|
||||
<span>TITLE</span>
|
||||
<span>ALBUM</span>
|
||||
<span className="flex justify-end"><Clock className="w-4 h-4" /></span>
|
||||
</div>
|
||||
|
||||
{likedTracksData.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<Heart className="w-16 h-16 mx-auto text-neutral-600 mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">Songs you like will appear here</h2>
|
||||
<p className="text-neutral-400 mb-6">Save songs by tapping the heart icon.</p>
|
||||
<Link
|
||||
to="/search"
|
||||
className="inline-block px-6 py-3 bg-white text-black font-bold rounded-full hover:scale-105 transition"
|
||||
>
|
||||
Find something to listen to
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
likedTracksData.map((track, index) => {
|
||||
const isCurrentTrack = currentTrack?.id === track.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={() => playTrack(track, likedTracksData)}
|
||||
className={`grid grid-cols-[auto_1fr_auto] md:grid-cols-[16px_4fr_2fr_1fr] gap-4 px-4 py-2 rounded-md hover:bg-white/10 transition group cursor-pointer ${isCurrentTrack ? 'bg-white/10' : ''}`}
|
||||
>
|
||||
{/* Index / Play indicator */}
|
||||
<div className="flex items-center">
|
||||
<span className={`text-sm ${isCurrentTrack ? 'text-[#1DB954]' : 'text-neutral-400'} group-hover:hidden`}>
|
||||
{isCurrentTrack && isPlaying ? (
|
||||
<div className="flex items-end gap-[2px] h-4">
|
||||
<div className="w-[3px] bg-[#1DB954] rounded-full animate-soundwave-1" />
|
||||
<div className="w-[3px] bg-[#1DB954] rounded-full animate-soundwave-2" />
|
||||
<div className="w-[3px] bg-[#1DB954] rounded-full animate-soundwave-3" />
|
||||
</div>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); isCurrentTrack ? togglePlay() : playTrack(track, likedTracksData); }}
|
||||
className="hidden group-hover:block text-white"
|
||||
>
|
||||
{isCurrentTrack && isPlaying ? <Pause className="w-4 h-4 fill-current" /> : <Play className="w-4 h-4 fill-current" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cover + Info */}
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<CoverImage
|
||||
src={track.cover_url}
|
||||
alt={track.title}
|
||||
className="w-10 h-10 rounded flex-shrink-0"
|
||||
fallbackText="♪"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium truncate ${isCurrentTrack ? 'text-[#1DB954]' : 'text-white'}`}>{track.title}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Album */}
|
||||
<p className="hidden md:flex items-center text-sm text-neutral-400 truncate">{track.album}</p>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex items-center justify-end">
|
||||
<Heart className="w-4 h-4 text-[#1DB954] fill-current mr-4 opacity-0 group-hover:opacity-100" />
|
||||
<span className="text-sm text-neutral-400">{formatDuration(track.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
496
frontend-vite/src/pages/Home.tsx
Normal file
496
frontend-vite/src/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Play, ArrowUpDown, Clock, Music2, User } from "lucide-react";
|
||||
import { Link } from 'react-router-dom';
|
||||
import { usePlayer } from '../context/PlayerContext';
|
||||
import { libraryService } from '../services/library';
|
||||
import { Track, StaticPlaylist } from '../types';
|
||||
import CoverImage from '../components/CoverImage';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
|
||||
type SortOption = 'recent' | 'alpha-asc' | 'alpha-desc' | 'artist';
|
||||
|
||||
export default function Home() {
|
||||
const [timeOfDay, setTimeOfDay] = useState("Good evening");
|
||||
const [browseData, setBrowseData] = useState<Record<string, StaticPlaylist[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
const { playTrack, playHistory } = usePlayer();
|
||||
|
||||
useEffect(() => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) setTimeOfDay("Good morning");
|
||||
else if (hour < 18) setTimeOfDay("Good afternoon");
|
||||
else setTimeOfDay("Good evening");
|
||||
|
||||
// Cache First Strategy for "Super Fast" loading
|
||||
const cached = localStorage.getItem('ytm_browse_cache_v4');
|
||||
if (cached) {
|
||||
setBrowseData(JSON.parse(cached));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
libraryService.getBrowseContent()
|
||||
.then(data => {
|
||||
setBrowseData(data);
|
||||
setLoading(false);
|
||||
// Update Cache
|
||||
localStorage.setItem('ytm_browse_cache_v4', JSON.stringify(data));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error fetching browse:", err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sortPlaylists = (playlists: StaticPlaylist[]) => {
|
||||
const sorted = [...playlists];
|
||||
switch (sortBy) {
|
||||
case 'alpha-asc':
|
||||
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
case 'alpha-desc':
|
||||
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
||||
case 'artist':
|
||||
return sorted.sort((a, b) => (a.creator || '').localeCompare(b.creator || ''));
|
||||
case 'recent':
|
||||
default:
|
||||
return sorted;
|
||||
}
|
||||
};
|
||||
|
||||
const firstCategory = Object.keys(browseData)[0];
|
||||
const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null;
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'recent', label: 'Recently Added', icon: Clock },
|
||||
{ value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown },
|
||||
{ value: 'alpha-desc', label: 'Alphabetical (Z-A)', icon: ArrowUpDown },
|
||||
{ value: 'artist', label: 'Artist Name', icon: User },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 no-scrollbar pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">{timeOfDay}</h1>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-spotify-card hover:bg-spotify-card-hover rounded-full text-sm font-medium transition"
|
||||
>
|
||||
<ArrowUpDown className="w-4 h-4" />
|
||||
Sort
|
||||
</button>
|
||||
|
||||
{showSortMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-[#282828] rounded-lg shadow-xl z-50 py-1 border border-[#383838]">
|
||||
{sortOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
setSortBy(option.value as SortOption);
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 hover:bg-[#3a3a3a] transition ${sortBy === option.value ? 'text-[#1DB954]' : 'text-white'}`}
|
||||
>
|
||||
<option.icon className="w-4 h-4" />
|
||||
{option.label}
|
||||
{sortBy === option.value && (
|
||||
<span className="ml-auto text-[#1DB954]">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
{loading ? (
|
||||
<div className="mb-8 w-full h-80 bg-spotify-card rounded-xl flex items-center p-8 animate-pulse">
|
||||
<Skeleton className="w-56 h-56 rounded-md shadow-2xl mr-8" />
|
||||
<div className="flex-1 space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-12 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
) : heroPlaylist && (
|
||||
<Link to={`/playlist/${heroPlaylist.id}`}>
|
||||
<div className="mb-8 w-full h-auto md:h-80 bg-gradient-to-r from-[#2a2a2a] to-[#181818] rounded-xl flex flex-col md:flex-row items-center p-6 md:p-8 hover:bg-[#2a2a2a] transition duration-300 group cursor-pointer shadow-2xl">
|
||||
<div className="relative mb-4 md:mb-0 md:mr-8 flex-shrink-0">
|
||||
<CoverImage
|
||||
src={heroPlaylist.cover_url}
|
||||
alt={heroPlaylist.title}
|
||||
className="w-48 h-48 md:w-56 md:h-56 rounded-2xl shadow-2xl group-hover:scale-105 transition duration-500"
|
||||
fallbackText="VB"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col text-center md:text-left">
|
||||
<span className="text-xs font-bold tracking-wider uppercase mb-2">Featured Playlist</span>
|
||||
<h2 className="text-3xl md:text-5xl font-black mb-4 leading-tight">{heroPlaylist.title}</h2>
|
||||
<p className="text-[#a7a7a7] text-sm md:text-base line-clamp-2 md:line-clamp-3 max-w-2xl mb-6">
|
||||
{heroPlaylist.description}
|
||||
</p>
|
||||
<div className="mt-auto inline-flex items-center gap-2 bg-[#1DB954] text-black px-8 py-3 rounded-full font-bold uppercase tracking-widest hover:scale-105 transition self-center md:self-start">
|
||||
<Play className="fill-black" />
|
||||
Play Now
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Recently Listened */}
|
||||
<RecentlyListenedSection playHistory={playHistory} playTrack={playTrack} />
|
||||
|
||||
{/* Made For You */}
|
||||
<MadeForYouSection />
|
||||
|
||||
{/* Artist Vietnam */}
|
||||
<ArtistVietnamSection />
|
||||
|
||||
{/* Top Albums Section (NEW) */}
|
||||
{loading ? (
|
||||
<div className="mb-8">
|
||||
<Skeleton className="h-8 w-48 mb-4" />
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{[1, 2, 3, 4, 5].map(j => (
|
||||
<div key={j} className="space-y-3">
|
||||
<Skeleton className="w-full aspect-square rounded-md" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : browseData["Top Albums"] && browseData["Top Albums"].length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-4">
|
||||
{browseData["Top Albums"].slice(0, 15).map((album) => (
|
||||
<Link to={`/album/${album.id}`} key={album.id}>
|
||||
<div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-2 md:mb-3">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-full aspect-square rounded-xl shadow-lg"
|
||||
fallbackText={album.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
|
||||
<div className="w-8 h-8 md:w-10 md:h-10 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-5 md:h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold mb-0.5 truncate text-[11px] md:text-base">{album.title}</h3>
|
||||
<p className="text-[10px] md:text-xs text-[#a7a7a7] line-clamp-1">{album.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Browse Lists */}
|
||||
{loading ? (
|
||||
<div className="space-y-8">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i}>
|
||||
<Skeleton className="h-8 w-48 mb-4" />
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{[1, 2, 3, 4, 5].map(j => (
|
||||
<div key={j} className="space-y-3">
|
||||
<Skeleton className="w-full aspect-square rounded-md" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : Object.keys(browseData).length > 0 ? (
|
||||
Object.entries(browseData)
|
||||
.filter(([category]) => category !== "Top Albums") // Filter out albums since we showed them above
|
||||
.map(([category, playlists]) => (
|
||||
<div key={category} className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">{category}</h2>
|
||||
<Link to={`/section?category=${encodeURIComponent(category)}`}>
|
||||
<span className="text-xs font-bold text-[#b3b3b3] uppercase tracking-wider hover:text-white cursor-pointer">Show all</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */}
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
|
||||
{sortPlaylists(playlists).slice(0, 15).map((playlist) => (
|
||||
<Link to={`/playlist/${playlist.id}`} key={playlist.id}>
|
||||
<div className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-2 md:mb-4">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full aspect-square rounded-xl shadow-lg"
|
||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
|
||||
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold mb-0.5 truncate text-[11px] md:text-base">{playlist.title}</h3>
|
||||
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<h2 className="text-xl font-bold mb-4">Ready to explore?</h2>
|
||||
<p className="text-[#a7a7a7]">Browse content is loading or empty. Try searching for music.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Recently Listened Section
|
||||
function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Track[], playTrack: (track: Track, queue?: Track[]) => void }) {
|
||||
if (playHistory.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-8 animate-in">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock className="w-5 h-5 text-[#1DB954]" />
|
||||
<h2 className="text-2xl font-bold">Recently Listened</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
|
||||
{playHistory.slice(0, 10).map((track, i) => (
|
||||
<div
|
||||
key={`${track.id}-${i}`}
|
||||
onClick={() => playTrack(track, playHistory)}
|
||||
className="flex-shrink-0 w-40 bg-spotify-card rounded-xl overflow-hidden hover:bg-spotify-card-hover transition duration-300 group cursor-pointer"
|
||||
>
|
||||
<div className="relative">
|
||||
<CoverImage
|
||||
src={track.cover_url}
|
||||
alt={track.title}
|
||||
className="w-40 h-40"
|
||||
fallbackText={track.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
|
||||
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
|
||||
<Play className="fill-black text-black ml-1 w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-sm truncate">{track.title}</h3>
|
||||
<p className="text-xs text-[#a7a7a7] truncate">{track.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Made For You Section
|
||||
function MadeForYouSection() {
|
||||
const { playHistory, playTrack } = usePlayer();
|
||||
const [recommendations, setRecommendations] = useState<Track[]>([]);
|
||||
const [seedTrack, setSeedTrack] = useState<Track | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (playHistory.length > 0) {
|
||||
const seed = playHistory[0];
|
||||
setSeedTrack(seed);
|
||||
setLoading(true);
|
||||
|
||||
libraryService.getRecommendations(seed.artist)
|
||||
.then(tracks => {
|
||||
setRecommendations(tracks);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}
|
||||
}, [playHistory.length > 0 ? playHistory[0]?.id : null]);
|
||||
|
||||
if (playHistory.length === 0) return null;
|
||||
if (!loading && recommendations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-8 animate-in">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Music2 className="w-5 h-5 text-[#1DB954]" />
|
||||
<h2 className="text-2xl font-bold">Made For You</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[#a7a7a7] mb-4">
|
||||
{seedTrack ? <>Because you listened to <span className="text-white font-medium">{seedTrack.artist}</span></> : "Recommended for you"}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="space-y-3">
|
||||
<Skeleton className="w-full aspect-square rounded-md" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
|
||||
{recommendations.slice(0, 10).map((track, i) => (
|
||||
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-2 md:mb-4">
|
||||
<CoverImage
|
||||
src={track.cover_url}
|
||||
alt={track.title}
|
||||
className="w-full aspect-square rounded-xl shadow-lg"
|
||||
fallbackText={track.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
|
||||
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold mb-0.5 truncate text-[11px] md:text-base">{track.title}</h3>
|
||||
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Artist Vietnam Section
|
||||
// Artist Vietnam Section (Optimized & Personalized)
|
||||
function ArtistVietnamSection() {
|
||||
const { playHistory } = usePlayer();
|
||||
|
||||
// Expanded pool of popular artists for discovery/fallback
|
||||
const POPULAR_ARTISTS = [
|
||||
"Sơn Tùng M-TP", "HIEUTHUHAI", "Đen Vâu", "Hoàng Dũng",
|
||||
"Vũ.", "MONO", "Tlinh", "Erik", "Binz", "JustaTee",
|
||||
"Rhymastic", "Low G", "MCK", "Min", "Amee", "Karik",
|
||||
"Suboi", "Bích Phương", "Trúc Nhân", "Đức Phúc"
|
||||
];
|
||||
|
||||
const [artists, setArtists] = useState<string[]>([]);
|
||||
const [artistPhotos, setArtistPhotos] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Determine Artist List (Personalized + Discovery)
|
||||
const deriveArtists = () => {
|
||||
const historyArtists = new Set<string>();
|
||||
playHistory.forEach(track => {
|
||||
if (track.artist) historyArtists.add(track.artist);
|
||||
});
|
||||
|
||||
const recent = Array.from(historyArtists).slice(0, 5); // Take top 5 recent
|
||||
|
||||
// Fill rest with random popular artists that aren't in recent
|
||||
const needed = 20 - recent.length;
|
||||
const available = POPULAR_ARTISTS.filter(a => !historyArtists.has(a));
|
||||
const shuffled = available.sort(() => 0.5 - Math.random()).slice(0, needed);
|
||||
|
||||
return [...recent, ...shuffled];
|
||||
};
|
||||
|
||||
const targetArtists = deriveArtists();
|
||||
setArtists(targetArtists);
|
||||
|
||||
// 2. Load Photos (Cache First Strategy)
|
||||
const loadPhotos = async () => {
|
||||
// v3: Progressive Loading + Smaller Thumbnails
|
||||
const cacheKey = 'artist_photos_cache_v3';
|
||||
const cached = JSON.parse(localStorage.getItem(cacheKey) || '{}');
|
||||
|
||||
// Initialize with cache immediately
|
||||
setArtistPhotos(cached);
|
||||
setLoading(false); // Show names immediately
|
||||
|
||||
// Identify missing photos
|
||||
const missing = targetArtists.filter(name => !cached[name]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
// Fetch missing incrementally
|
||||
for (const name of missing) {
|
||||
try {
|
||||
// Fetch one by one and update state immediately
|
||||
// This prevents "batch waiting" feeling
|
||||
libraryService.getArtistInfo(name).then(data => {
|
||||
if (data.photo) {
|
||||
setArtistPhotos(prev => {
|
||||
const next: Record<string, string> = { ...prev, [name]: data.photo || "" };
|
||||
localStorage.setItem(cacheKey, JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPhotos();
|
||||
}, [playHistory.length]); // Re-run when history changes significantly
|
||||
|
||||
return (
|
||||
<div className="mb-8 animate-in">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<User className="w-5 h-5 text-[#1DB954]" />
|
||||
<h2 className="text-2xl font-bold">Suggested Artists</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[#a7a7a7] mb-4">Based on your recent listening</p>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
|
||||
{artists.length === 0 && loading ? (
|
||||
[1, 2, 3, 4, 5, 6].map(i => (
|
||||
<div key={i} className="flex-shrink-0 w-36 text-center space-y-3">
|
||||
<Skeleton className="w-36 h-36 rounded-xl" />
|
||||
<Skeleton className="h-4 w-3/4 mx-auto" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
artists.map((name, i) => (
|
||||
<Link to={`/artist/${encodeURIComponent(name)}`} key={i}>
|
||||
<div className="flex-shrink-0 w-36 text-center group cursor-pointer">
|
||||
<div className="relative mb-3">
|
||||
<CoverImage
|
||||
src={artistPhotos[name]}
|
||||
alt={name}
|
||||
className="w-36 h-36 rounded-xl shadow-lg group-hover:shadow-xl transition object-cover"
|
||||
fallbackText={name.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition rounded-xl flex items-center justify-center">
|
||||
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
|
||||
<Play className="fill-black text-black ml-1 w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold text-sm truncate px-2">{name}</h3>
|
||||
<p className="text-xs text-[#a7a7a7]">Artist</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
frontend-vite/src/pages/Library.tsx
Normal file
252
frontend-vite/src/pages/Library.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { Play, Plus } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useLibrary } from '../context/LibraryContext';
|
||||
import { usePlayer } from '../context/PlayerContext';
|
||||
import CoverImage from '../components/CoverImage';
|
||||
import CreatePlaylistModal from '../components/CreatePlaylistModal';
|
||||
import { dbService } from '../services/db';
|
||||
import { libraryService } from '../services/library';
|
||||
import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
|
||||
export default function Library() {
|
||||
const { userPlaylists, libraryItems, refreshLibrary, activeFilter, setActiveFilter } = useLibrary();
|
||||
const { likedTracks } = usePlayer();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
// Discovery State
|
||||
const [discoveryItems, setDiscoveryItems] = useState<any[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const handleCreatePlaylist = async (name: string) => {
|
||||
await dbService.createPlaylist(name);
|
||||
refreshLibrary();
|
||||
};
|
||||
|
||||
const filters = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'playlists', label: 'Playlists' },
|
||||
{ key: 'artists', label: 'Artists' },
|
||||
{ key: 'albums', label: 'Albums' },
|
||||
] as const;
|
||||
|
||||
// Filter Logic: Local Items
|
||||
const filteredLocalItems = useMemo(() => {
|
||||
return libraryItems.filter(item => {
|
||||
if (activeFilter === 'all') return true;
|
||||
if (activeFilter === 'playlists') return item.type === 'Playlist';
|
||||
if (activeFilter === 'artists') return item.type === 'Artist';
|
||||
if (activeFilter === 'albums') return item.type === 'Album';
|
||||
return true;
|
||||
});
|
||||
}, [libraryItems, activeFilter]);
|
||||
|
||||
// Infinite Information
|
||||
const filteredDiscoveryItems = useMemo(() => {
|
||||
return discoveryItems.filter(item => {
|
||||
if (activeFilter === 'all') return true;
|
||||
if (activeFilter === 'playlists') return item.type === 'Playlist';
|
||||
if (activeFilter === 'artists') return item.type === 'Artist';
|
||||
if (activeFilter === 'albums') return item.type === 'Album';
|
||||
return true;
|
||||
});
|
||||
}, [discoveryItems, activeFilter]);
|
||||
|
||||
const displayItems = [...filteredLocalItems, ...filteredDiscoveryItems];
|
||||
|
||||
// Load More (Discovery)
|
||||
const loadMore = async () => {
|
||||
if (isFetching) return;
|
||||
setIsFetching(true);
|
||||
|
||||
// Simulate network delay for UX
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
|
||||
const moreContent = await libraryService.discoverContent(activeFilter);
|
||||
setDiscoveryItems(prev => [...prev, ...moreContent]);
|
||||
setIsFetching(false);
|
||||
};
|
||||
|
||||
const lastElementRef = useInfiniteScroll(loadMore, isFetching);
|
||||
|
||||
// Reset discovery on filter change? Optional, but maybe good to keep it fresh
|
||||
useEffect(() => {
|
||||
// We can keep discovery items but maybe filter them?
|
||||
// Or clear them to finding new specific ones?
|
||||
// Let's clear to find specific ones if switching tabs.
|
||||
// setDiscoveryItems([]);
|
||||
// Actually, let's just append. If I switch to Artists, I want artists.
|
||||
// But if I switch back to All, I want see what I had.
|
||||
// Simple approach: Keep them, but `discoverContent` takes type.
|
||||
}, [activeFilter]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 md:p-6 no-scrollbar pb-24">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-4">Your Library</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
onClick={() => setActiveFilter(filter.key)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition ${activeFilter === filter.key ? 'bg-white text-black' : 'bg-spotify-card text-white hover:bg-spotify-card-hover'}`}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Playlist Button - Compact */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-spotify-card hover:bg-spotify-card-hover rounded-full transition text-sm font-medium border border-white/10"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Playlist</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Liked Songs Card */}
|
||||
{(activeFilter === 'all' || activeFilter === 'playlists') && (
|
||||
<Link to="/collection/tracks">
|
||||
<div className="mb-4 flex items-center gap-4 p-4 bg-gradient-to-r from-indigo-800/30 to-blue-600/30 rounded-lg hover:from-indigo-800/50 hover:to-blue-600/50 transition group cursor-pointer">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-indigo-700 to-blue-300 rounded flex items-center justify-center shadow-lg">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-lg">Liked Songs</h3>
|
||||
<p className="text-sm text-neutral-400">{likedTracks.size} songs</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition shadow-lg">
|
||||
<Play className="w-5 h-5 text-black fill-black ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Playlists */}
|
||||
{(activeFilter === 'all' || activeFilter === 'playlists') && userPlaylists.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-bold mb-3">Your Playlists</h2>
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-4">
|
||||
{userPlaylists.map((playlist) => (
|
||||
<Link to={`/playlist/${playlist.id}`} key={playlist.id}>
|
||||
<div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition group cursor-pointer">
|
||||
<div className="relative mb-2 md:mb-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full aspect-square rounded-xl shadow-lg"
|
||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition">
|
||||
<div className="w-8 h-8 md:w-10 md:h-10 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg">
|
||||
<Play className="w-4 h-4 md:w-5 md:h-5 text-black fill-black ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold truncate text-[11px] md:text-base">{playlist.title}</h3>
|
||||
<p className="text-[10px] md:text-xs text-neutral-400 line-clamp-1">{playlist.tracks.length} songs</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Library Grid (Local + Discovery) */}
|
||||
{displayItems.length > 0 && (
|
||||
<div>
|
||||
{(activeFilter === 'all' || activeFilter !== 'playlists') && <h2 className="text-lg font-bold mb-3">Saved & Discovered</h2>}
|
||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-4">
|
||||
{displayItems.map((item, index) => (
|
||||
<Link
|
||||
to={
|
||||
item.type === 'Playlist' ? `/playlist/${item.id}` :
|
||||
item.type === 'Artist' ? `/artist/${encodeURIComponent(item.title)}` :
|
||||
`/search?q=${encodeURIComponent(item.title)}` // Albums link to search for now as we don't have dedicated album page yet, wait we do have Album.tsx but routing might need check.
|
||||
// Actually Album.tsx exists.
|
||||
// item.type === 'Album' ? `/album/${item.id}` : ...
|
||||
// But item.id for discovery is random.
|
||||
// Let's stick to Search for generic album discovery navigation or update Album page to fetch by title.
|
||||
// Search is safest for discovered items.
|
||||
}
|
||||
key={`${item.id}-${index}`}
|
||||
ref={index === displayItems.length - 1 ? lastElementRef : null}
|
||||
>
|
||||
<div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition group cursor-pointer h-full">
|
||||
<div className="relative mb-3">
|
||||
<CoverImage
|
||||
src={item.cover_url}
|
||||
alt={item.title}
|
||||
className={`w-full aspect-square shadow-lg rounded-xl`}
|
||||
fallbackText={item.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition">
|
||||
<div className="w-10 h-10 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg">
|
||||
<Play className="w-5 h-5 text-black fill-black ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold truncate text-[11px] md:text-base">{item.title}</h3>
|
||||
<p className="text-[10px] md:text-xs text-neutral-400 capitalize line-clamp-1">{item.type}{item.creator ? ` • ${item.creator}` : ''}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Loading Skeletons */}
|
||||
{isFetching && Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`skel-${i}`} className="p-3">
|
||||
<Skeleton className="w-full aspect-square rounded-md mb-3" />
|
||||
<Skeleton className="h-4 w-3/4 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State (Only if absolutely nothing) */}
|
||||
{displayItems.length === 0 && userPlaylists.length === 0 && !isFetching && (
|
||||
<div className="text-center py-20">
|
||||
<div className="w-20 h-20 mx-auto mb-4 bg-[#282828] rounded-full flex items-center justify-center">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-neutral-500">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2">Your library is empty</h2>
|
||||
<p className="text-neutral-400 mb-6">Create a playlist or listen to music to build your library.</p>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="px-6 py-3 bg-white text-black font-bold rounded-full hover:scale-105 transition"
|
||||
>
|
||||
Create Playlist
|
||||
</button>
|
||||
{/* Trigger discovery manually if empty */}
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="block mx-auto mt-4 text-sm text-neutral-400 hover:text-white underline"
|
||||
>
|
||||
Browse Recommended
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreatePlaylistModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreatePlaylist}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue