First commit
This commit is contained in:
commit
5daf2dc6b9
17 changed files with 5649 additions and 0 deletions
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue