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