Compare commits

...

2 commits

Author SHA1 Message Date
Khoa Vo
fa3fcc1b16 Deploy: Linux cross-compile & Action workflow
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
2026-02-07 22:37:08 +07:00
Khoa Vo
d0d26171f0 Deploy: Add Dockerfile and static file serving 2026-02-07 22:27:15 +07:00
188 changed files with 22872 additions and 101600 deletions

View file

@ -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

37
.github/workflows/docker-publish.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Build and Publish Docker Image
on:
push:
branches: [ "main" ]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: git.khoavo.myds.me
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
git.khoavo.myds.me/vndangkhoa/spotify-clone:latest
platforms: linux/amd64

BIN
.tools/node.zip Normal file

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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

View 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

View 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" %*
)

View 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

Binary file not shown.

View 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%"

View 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" "$@"

View 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%" %*

View 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" "$@"

View 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%" %*

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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

View 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

View 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" %*
)

View 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

Binary file not shown.

View 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%"

View 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" "$@"

View 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%" %*

View 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" "$@"

View 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%" %*

View file

@ -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
View 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
View 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"]

View 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
View 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
View 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=

View 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)
}
}

View 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})
}

View 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)
}

View 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)
})
}

View 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"`
}

View 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)
}

View file

@ -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

View file

@ -1 +0,0 @@
[]

View file

@ -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}")

View file

@ -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

View file

@ -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"}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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}")

View file

@ -1 +0,0 @@
# Services Package

View file

@ -1,4 +0,0 @@
# Cache Service - Re-export CacheManager from backend.cache_manager
from backend.cache_manager import CacheManager
__all__ = ['CacheManager']

View file

@ -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

View file

@ -1 +0,0 @@
[]

View file

@ -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())

View file

@ -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
View 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>

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

@ -1,6 +1,6 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View 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();

View 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
View 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;

View 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>
);
}

View 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
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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"
/>
);
}

View 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>
);
}

View 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;
}

View file

@ -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;
}

View 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;
}

View 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": []
}
};

View 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": []
}
};

View 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;
}

View 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;
}

View 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
View 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;
}

View 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>,
)

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

Some files were not shown because too many files have changed in this diff Show more