diff --git a/backend/spotfetch.go b/backend/spotfetch.go index a8922b5..055c277 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -714,6 +714,17 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter } } + if albumArtistsString == "" { + albumArtists := extractArtists(getMap(albumData, "artists")) + if len(albumArtists) > 0 { + albumArtistNames := []string{} + for _, artist := range albumArtists { + albumArtistNames = append(albumArtistNames, getString(artist, "name")) + } + albumArtistsString = strings.Join(albumArtistNames, ", ") + } + } + albumInfo = map[string]interface{}{ "id": albumID, "name": getString(albumData, "name"), @@ -1228,6 +1239,11 @@ func extractDiscographyItems(itemsData map[string]interface{}) []map[string]inte return items } +func stripHTMLTags(s string) string { + re := regexp.MustCompile(`<[^>]*>`) + return re.ReplaceAllString(s, "") +} + func FilterArtist(data map[string]interface{}) map[string]interface{} { dataMap := getMap(data, "data") artistData := getMap(dataMap, "artistUnion") @@ -1243,7 +1259,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} { if ok { biographyText := getString(biographyMap, "text") if biographyText != "" { - profile["biography"] = html.UnescapeString(biographyText) + profile["biography"] = html.UnescapeString(stripHTMLTags(biographyText)) } } } diff --git a/frontend/src/assets/github-lang-colors.ts b/frontend/src/assets/github-lang-colors.ts new file mode 100644 index 0000000..e390cab --- /dev/null +++ b/frontend/src/assets/github-lang-colors.ts @@ -0,0 +1,18 @@ +export const langColors: Record = { + "TypeScript": "#2b7489", + "Go": "#375eab", + "Python": "#3572A5", + "CSS": "#563d7c", + "HTML": "#e44b23", + "JavaScript": "#f1e05a", + "Java": "#b07219", + "C": "#555555", + "C Sharp": "#178600", + "cpp": "#f34b7d", + "Ruby": "#701516", + "PHP": "#4F5D95", + "Swift": "#ffac45", + "Kotlin": "#F18E33", + "Rust": "#dea584", + "Shell": "#89e051" +}; diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index 397597a..ccb933a 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -8,11 +8,12 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { Bug, Lightbulb, ExternalLink } from "lucide-react"; +import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download } from "lucide-react"; import ExyezedIcon from "@/assets/icons/exyezed.svg"; import SpotubeDLIcon from "@/assets/icons/spotubedl.svg"; import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg"; import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; +import { langColors } from "@/assets/github-lang-colors"; interface AboutPageProps { version: string; } @@ -27,6 +28,7 @@ export function AboutPage({ version }: AboutPageProps) { const [featureDesc, setFeatureDesc] = useState(""); const [useCase, setUseCase] = useState(""); const [featureContext, setFeatureContext] = useState(""); + const [repoStats, setRepoStats] = useState>({}); useEffect(() => { const fetchOS = async () => { try { @@ -66,6 +68,80 @@ export function AboutPage({ version }: AboutPageProps) { } }; fetchLocation(); + const fetchRepoStats = async () => { + const CACHE_KEY = 'github_repo_stats'; + const CACHE_DURATION = 1000 * 60 * 60; + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + try { + const { data, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < CACHE_DURATION) { + setRepoStats(data); + return; + } + } + catch (err) { + console.error('Failed to parse cache:', err); + } + } + const repos = [ + { name: 'SpotiDownloader', owner: 'afkarxyz' }, + { name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' } + ]; + const stats: Record = {}; + for (const repo of repos) { + try { + const [repoRes, releasesRes, langsRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`), + fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`), + fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`) + ]); + if (repoRes.status === 403) { + if (cached) { + const { data } = JSON.parse(cached); + setRepoStats(data); + } + return; + } + if (repoRes.ok && releasesRes.ok && langsRes.ok) { + const repoData = await repoRes.json(); + const releases = await releasesRes.json(); + const languages = await langsRes.json(); + let totalDownloads = 0; + let latestDownloads = 0; + if (releases.length > 0) { + latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0; + totalDownloads = releases.reduce((sum: number, release: any) => { + return sum + (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0); + }, 0); + } + const topLangs = Object.entries(languages) + .sort(([, a]: any, [, b]: any) => b - a) + .slice(0, 4) + .map(([lang]) => lang); + stats[repo.name] = { + stars: repoData.stargazers_count, + forks: repoData.forks_count, + createdAt: repoData.created_at, + totalDownloads, + latestDownloads, + languages: topLangs + }; + } + } + catch (err) { + console.error(`Failed to fetch stats for ${repo.name}:`, err); + if (cached) { + const { data } = JSON.parse(cached); + setRepoStats(data); + return; + } + } + } + setRepoStats(stats); + localStorage.setItem(CACHE_KEY, JSON.stringify({ data: stats, timestamp: Date.now() })); + }; + fetchRepoStats(); }, []); const faqs = [ { @@ -92,6 +168,34 @@ export function AboutPage({ version }: AboutPageProps) { const sanitizeForURL = (text: string): string => { return text.replace(/[()]/g, "").replace(/,/g, " -"); }; + const formatTimeAgo = (dateString: string): string => { + const now = new Date(); + const updated = new Date(dateString); + const diffMs = now.getTime() - updated.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffMonths = Math.floor(diffDays / 30); + if (diffDays === 0) + return 'today'; + if (diffDays === 1) + return '1d'; + if (diffDays < 30) + return `${diffDays}d`; + if (diffMonths === 1) + return '1mo'; + if (diffMonths < 12) + return `${diffMonths}mo`; + const diffYears = Math.floor(diffMonths / 12); + return `${diffYears}y`; + }; + const formatNumber = (num: number): string => { + if (num >= 1000) { + return num.toLocaleString(); + } + return num.toString(); + }; + const getLangColor = (lang: string): string => { + return langColors[lang] || '#858585'; + }; const handleSubmit = () => { let title = ""; let body = ""; @@ -256,12 +360,40 @@ ${location || "Unknown"} SpotiDownloader SpotiDownloader Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API. + {repoStats['SpotiDownloader'] && ( +
+ {repoStats['SpotiDownloader'].languages?.map((lang: string) => ({lang}))} +
+
+ {formatNumber(repoStats['SpotiDownloader'].stars)} + {repoStats['SpotiDownloader'].forks} + {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)} +
+
+ TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)} + LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)} +
+
)} openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}> - Twitter/X Media Batch Downloader Twitter/X Batch Downloader + Twitter/X Media Batch Downloader Twitter/X Media Batch Downloader A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf + {repoStats['Twitter-X-Media-Batch-Downloader'] && ( +
+ {repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => ({lang}))} +
+
+ {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)} + {repoStats['Twitter-X-Media-Batch-Downloader'].forks} + {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)} +
+
+ TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)} + LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)} +
+
)}
diff --git a/frontend/src/components/HistoryPage.tsx b/frontend/src/components/HistoryPage.tsx index eea2a9d..1433847 100644 --- a/frontend/src/components/HistoryPage.tsx +++ b/frontend/src/components/HistoryPage.tsx @@ -131,151 +131,155 @@ export function HistoryPage() { setShowClearConfirm(false); }; return (
-
-
-
-

Download History

- {history.length > 0 && ( - {history.length.toLocaleString('en-US')} - )} -
- -
- +
+
-
- - setSearchQuery(e.target.value)} className="pl-8 h-9"/> -
- +

Download History

+ {history.length > 0 && ( + {history.length.toLocaleString('en-US')} + )}
+
-
- {paginatedHistory.length === 0 ? (
-
- -
-
-

No download history

-

Your downloaded tracks will appear here.

-
-
) : ( - - - - - - - - - - - - - {paginatedHistory.map((item, index) => ( - - - - - - - - ))} - -
#TitleAlbumFormatDurDownloaded AtLink
- {startIndex + index + 1} - -
- {item.album} { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/> -
- {item.title} - {item.artists} -
-
-
-
{item.album}
-
-
- {item.format} - {item.quality && {item.quality}} -
-
- {item.duration_str} - - {formatDate(item.timestamp)} - - -
)} +
+
+ + setSearchQuery(e.target.value)} className="pl-8 h-9" /> +
+
+
- {totalPages > 1 && ( - - - { - e.preventDefault(); - if (currentPage > 1) - setCurrentPage(currentPage - 1); - }} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - +
+ {paginatedHistory.length === 0 ? (
+
+ +
+
+

No download history

+

Your downloaded tracks will appear here.

+
+
) : ( + + + + + + + + + + + + + {paginatedHistory.map((item, index) => ( + + + + + + + + ))} + +
#TitleAlbumFormatDurDownloaded AtLink
+ {startIndex + index + 1} + +
+ {item.album} { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }} /> +
+ {item.title} + {item.artists} +
+
+
+
{item.album}
+
+
+ + {['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format} + + {item.quality && {item.quality}} +
+
+ {item.duration_str} + + {formatDate(item.timestamp)} + + +
)} +
- {getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - setCurrentPage(page); - }} isActive={currentPage === page} className="cursor-pointer"> - {page} - - )))} + { + totalPages > 1 && ( + + + { + e.preventDefault(); + if (currentPage > 1) + setCurrentPage(currentPage - 1); + }} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} /> + - - { - e.preventDefault(); - if (currentPage < totalPages) - setCurrentPage(currentPage + 1); - }} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - - - )} + {getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? ( + + ) : ( + { + e.preventDefault(); + setCurrentPage(page); + }} isActive={currentPage === page} className="cursor-pointer"> + {page} + + )))} - - - - Clear Download History? - - This will remove all entries from your download history. This action cannot be undone. - Note: The actual downloaded files will NOT be deleted. - - - - - - - - -
); + + { + e.preventDefault(); + if (currentPage < totalPages) + setCurrentPage(currentPage + 1); + }} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} /> + + + ) + } + + + + + Clear Download History? + + This will remove all entries from your download history. This action cannot be undone. + Note: The actual downloaded files will NOT be deleted. + + + + + + + + +
); } diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index ef90df8..1ee3dec 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -266,40 +266,40 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting - + - + - + - + - + - + - + - + - + - + - + - +