First commit
This commit is contained in:
commit
5daf2dc6b9
17 changed files with 5649 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
4
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal 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
48
README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Astro Starter Kit: Basics
|
||||
|
||||
```sh
|
||||
yarn create astro@latest -- --template basics
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||

|
||||
|
||||
## 🚀 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
10
astro.config.mjs
Normal 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
21
eslint.config.js
Normal 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
30
package.json
Normal 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
6
prettier.config.cjs
Normal 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
53
src/components/Album.tsx
Normal 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>
|
||||
}
|
||||
62
src/components/Downloader.tsx
Normal file
62
src/components/Downloader.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
4
src/components/Loader.tsx
Normal file
4
src/components/Loader.tsx
Normal 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
176
src/pages/index.astro
Normal 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
13
src/utils/consts.ts
Normal 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
77
src/utils/search.ts
Normal 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
10
src/utils/types.ts
Normal 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
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue