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

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
}