First commit

This commit is contained in:
Jorge Vargas 2025-03-10 18:11:13 -06:00
commit 5daf2dc6b9
17 changed files with 5649 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

4
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "yarn dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

48
README.md Normal file
View file

@ -0,0 +1,48 @@
# Astro Starter Kit: Basics
```sh
yarn create astro@latest -- --template basics
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554)
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
│ └── favicon.svg
├── src/
│ ├── layouts/
│ │ └── Layout.astro
│ └── pages/
│ └── index.astro
└── package.json
```
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `yarn install` | Installs dependencies |
| `yarn dev` | Starts local dev server at `localhost:4321` |
| `yarn build` | Build your production site to `./dist/` |
| `yarn preview` | Preview your build locally, before deploying |
| `yarn astro ...` | Run CLI commands like `astro add`, `astro check` |
| `yarn astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

10
astro.config.mjs Normal file
View file

@ -0,0 +1,10 @@
// @ts-check
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
output: 'static',
integrations: [react()]
});

21
eslint.config.js Normal file
View file

@ -0,0 +1,21 @@
import neostandard, { resolveIgnoresFromGitignore } from 'neostandard'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginAstro from 'eslint-plugin-astro'
const neoConfig = neostandard({ ignores: resolveIgnoresFromGitignore(), noStyle: true, ts: true })
/** @type {import("eslint").Linter.Config} */
export default [
...neoConfig,
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
'no-undef': 'error'
}
},
{ env: { browser: true } },
...eslintPluginAstro.configs.recommended,
eslintConfigPrettier
]

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "client",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/react": "^4.2.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"astro": "^5.4.2",
"cheerio": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"devDependencies": {
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"neostandard": "^0.12.1",
"prettier": "^3.5.3",
"prettier-config-standard": "^7.0.0"
}
}

6
prettier.config.cjs Normal file
View file

@ -0,0 +1,6 @@
const prettierConfigStandard = require('prettier-config-standard')
/** @type {import("prettier").Config} */
const config = {...prettierConfigStandard, printWidth: 120}
module.exports = config

53
src/components/Album.tsx Normal file
View file

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import { getTracks } from 'utils/search'
import type { AlbumRow, TrackRow } from 'utils/types'
export default function Album(props: { album: AlbumRow; lastReqRef: React.RefObject<number> }) {
const { album, lastReqRef } = props
const [open, setOpen] = useState(false)
return (
<div className='album' onClick={() => setOpen((o) => !o)}>
<h3>{album.name}</h3>
<TrackList url={album.url} open={open} lastReqRef={lastReqRef} />
</div>
)
}
function TrackList(props: { url: string; open: boolean; lastReqRef: React.RefObject<number> }) {
const { url, open, lastReqRef } = props
const [tracks, setTracks] = useState<TrackRow[] | null>(null)
async function fetchTracks() {
const tracks = await getTracks(url, lastReqRef)
setTracks(tracks)
}
useEffect(() => {
if (!tracks && open) fetchTracks()
}, [open])
if (!open) return null
return (
<div className='trackList'>
{tracks
? tracks.map((t, i) => (
<div className='track'>
<span className='track-name'>
<span className='track-number'>{(i + 1).toString().padStart(2, '0').slice(-2)}.</span>
<span>{t.name}</span>
</span>
<span className='format-tag'>FLAC/MP3</span>
<TrackDownload url={url} lastReqRef={lastReqRef} />
</div>
))
: null}
</div>
)
}
function TrackDownload(props: { url: string; lastReqRef: React.RefObject<number> }) {
return <button className='download-btn'>Download</button>
}

View file

@ -0,0 +1,62 @@
import { useRef, useState } from 'react'
import * as cheerio from 'cheerio'
import Album from './Album'
import type { AlbumRow } from 'utils/types'
import { politeFetch, searchAlbums } from 'utils/search'
import Loader from './Loader'
export default function Downloader() {
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<AlbumRow[]>([])
const [lastQuery, setLastQuery] = useState<string | null>(null)
const lastReqRef = useRef(Date.now())
async function handleSearch(ev: React.FormEvent<HTMLFormElement>) {
ev.preventDefault()
setLoading(true)
const formData = new FormData(ev.target as HTMLFormElement)
const query = formData.get('query') as string
try {
const uniqueAlbums = await searchAlbums(query, lastReqRef)
setResults(uniqueAlbums)
} catch (error) {
// handle error
} finally {
setLoading(false)
setLastQuery(query)
}
}
return (
<>
<form className='search-container' onSubmit={handleSearch}>
<input
type='text'
name='query'
className='search-box'
placeholder='Search for soundtracks...'
min={3}
required
/>
<input type='submit' className='search-btn' value='Search' />
</form>
<Loader show={loading} />
{!loading && lastQuery ? (
<div className='search-meta'>
{results.length > 0 ? (
<span className='result-count'>{`Found ${results.length} ${results.length === 1 ? 'album' : 'albums'}`}</span>
) : (
<div className='no-results'>No albums found for "{lastQuery}"</div>
)}
</div>
) : null}
<div className='results'>
{results.map((r) => (
<Album album={r} lastReqRef={lastReqRef} />
))}
</div>
</>
)
}

View file

@ -0,0 +1,4 @@
export default function Loader(props: { show: boolean }) {
const { show } = props
return show ? <div className='loader' /> : null
}

176
src/pages/index.astro Normal file
View file

@ -0,0 +1,176 @@
---
import Downloader from '../components/Downloader'
---
<head>
<title>Khinsider Downloader</title>
</head>
<style is:global>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #1a1a1a;
color: #e0e0e0;
}
.search-container {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.search-box {
flex: 1;
padding: 12px;
font-size: 16px;
border: 1px solid #333;
border-radius: 4px;
background-color: #2d2d2d;
color: #e0e0e0;
}
.search-btn {
padding: 12px 24px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.search-btn:hover {
background-color: #0056b3;
}
.search-meta {
margin-bottom: 15px;
color: #858585;
font-size: 0.9em;
}
.result-count {
font-weight: 500;
color: #e0e0e0;
}
.loader {
border: 4px solid #333;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error {
color: #ff6b6b;
padding: 10px;
margin: 10px 0;
border: 1px solid #ff6b6b;
border-radius: 4px;
background-color: #2d0000;
display: none;
}
.results {
background-color: #2d2d2d;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.album {
padding: 15px;
border-bottom: 1px solid #333;
cursor: pointer;
transition: background 0.2s;
}
.album:hover {
background-color: #333;
}
.track {
display: flex;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid #333;
}
.track-name {
flex: 1;
margin-right: 15px;
color: #e0e0e0;
}
.format-tag {
background-color: #4a4a4a;
color: white;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.85em;
margin-right: 10px;
}
.download-btn {
background-color: #28a745;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.download-btn:hover {
background-color: #218838;
}
.back-btn {
background: none;
border: none;
color: #007bff;
cursor: pointer;
padding: 10px 0;
#margin-bottom: 15px;
margin: 0 15px 15px 15px;
display: block;
}
.no-results {
padding: 20px;
text-align: center;
color: #858585;
}
.album-title {
font-weight: 500;
margin-bottom: 4px;
color: #e0e0e0;
}
.album-meta {
display: flex;
gap: 12px;
font-size: 0.9em;
color: #858585;
}
.meta-item {
padding: 2px 8px;
background: #333;
border-radius: 4px;
}
.track-formats {
display: flex;
gap: 8px;
}
.download-btn.flac {
background-color: #6f42c1;
}
.download-btn.flac:hover {
background-color: #563d7c;
}
.results h3 {
padding: 0 15px;
margin: 0 0 15px 0;
font-size: 1.1em;
}
</style>
<body>
<h1>Khinsider Downloader</h1>
<Downloader client:only='react' />
<div class='error'></div>
</body>

13
src/utils/consts.ts Normal file
View file

@ -0,0 +1,13 @@
export const REQUEST_DELAY_MS = 3 * 1000
export const USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0',
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 11; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Mobile Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'
]

77
src/utils/search.ts Normal file
View file

@ -0,0 +1,77 @@
import * as cheerio from 'cheerio'
import { USER_AGENTS } from './consts'
import type { AlbumRow } from './types'
function getRandom(list: any[]) {
return list[Math.floor(Math.random() * list.length)]
}
export function politeFetch(url: string, lastReqRef: React.RefObject<number>) {
const elapsed = Date.now() - lastReqRef.current
const corsUrl = `https://cors.chitowarlock.com/${url}`
const headers = {
'User-Agent': getRandom(USER_AGENTS),
Referer: 'https://downloads.khinsider.com/',
'Accept-Language': 'en-US,en;q=0.5',
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*//**;q=0.8',
'Accept-Encoding': 'gzip, deflate, br'
}
return fetch(corsUrl, { headers, signal: AbortSignal.timeout(10 * 1000) }).finally(() => {
lastReqRef.current = Date.now()
})
}
export async function searchAlbums(query: string, lastReqRef: React.RefObject<number>) {
const searchUrl = `https://downloads.khinsider.com/search?search=${query}`
const searchHtml = await (await politeFetch(searchUrl, lastReqRef)).text()
const $ = cheerio.load(searchHtml)
const albumRows = $('table.albumList tr:not(:first-child)')
const albums: AlbumRow[] = []
albumRows.each((index, row) => {
const albumLink = $(row).find('td:nth-of-type(2) a')
if (albumLink.length === 0) return
const albumName = albumLink.text().trim()
const albumUrl = `https://downloads.khinsider.com${albumLink.attr('href')}`
albums.push({ name: albumName, url: albumUrl })
})
const seen = new Set()
const uniqueAlbums = albums.filter((album) => {
if (seen.has(album.url)) return false
seen.add(album.url)
return true
})
return uniqueAlbums
}
export async function getTracks(url: string, lastReqRef: React.RefObject<number>) {
const albumHtml = await (await politeFetch(url, lastReqRef)).text()
const $ = cheerio.load(albumHtml)
const tracks: { name: string; url: string; format: string }[] = []
$('table#songlist tr:has(a[href$=".mp3"]), table#songlist tr:has(a[href$=".flac"])').each((index, row) => {
const link = $(row).find('a[href$=".mp3"], a[href$=".flac"]')
if (link.length === 0) return
const trackUrl = link.attr('href')!.startsWith('http')
? link.attr('href')!
: `https://downloads.khinsider.com${link.attr('href')}`
tracks.push({
name: link.text().trim(),
url: trackUrl,
format: link.attr('href')!.toLowerCase().endsWith('.flac') ? 'flac' : 'mp3'
})
})
return tracks
}

10
src/utils/types.ts Normal file
View file

@ -0,0 +1,10 @@
export interface AlbumRow {
name: string
url: string
}
export interface TrackRow {
name: string
url: string
format: string
}

15
tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"baseUrl": "./src",
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

5085
yarn.lock Normal file

File diff suppressed because it is too large Load diff