This commit is contained in:
afkarxyz
2025-12-08 19:33:43 +07:00
parent 2fb544d1f8
commit 8f10094e40
24 changed files with 1506 additions and 781 deletions
+14 -14
View File
@@ -55,7 +55,7 @@ function App() {
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.6";
const CURRENT_VERSION = "6.7";
const download = useDownload();
const metadata = useMetadata();
@@ -325,16 +325,16 @@ function App() {
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position)
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, false, position, trackId)
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
download.handleDownloadSelected(selectedTracks, track_list, undefined, true)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
@@ -391,10 +391,10 @@ function App() {
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position)
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, false, position, trackId)
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
@@ -462,17 +462,17 @@ function App() {
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position)
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, isArtistDiscography, position, trackId)
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name, true)}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
+82 -62
View File
@@ -16,12 +16,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
@@ -325,81 +324,102 @@ export function Settings() {
{/* Right Column */}
<div className="space-y-4">
{/* Filename Format */}
{/* Folder Structure */}
<div className="space-y-2">
<Label className="text-sm">Filename Format</Label>
<RadioGroup
value={tempSettings.filenameFormat}
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
}));
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="title-artist" id="title-artist" />
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="artist-title" id="artist-title" />
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="title" id="title" />
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
</div>
</RadioGroup>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.folderPreset === "custom" && (
<InputWithContext
value={tempSettings.folderTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
placeholder="{artist}/{album}"
className="h-9 text-sm"
/>
)}
{tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
</p>
)}
</div>
<div className="border-t" />
{/* Folder Settings */}
{/* Filename Format */}
<div className="space-y-2">
<h3 className="font-medium text-sm">Folder Settings</h3>
<div className="flex items-center gap-2">
<Checkbox
id="track-number"
checked={tempSettings.trackNumber}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
/>
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Adds track numbers to filenames. Uses album track numbers when Album Subfolder is enabled, otherwise uses playlist position</p>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="artist-subfolder"
checked={tempSettings.artistSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
<Select
value={tempSettings.filenamePreset}
onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (
<InputWithContext
value={tempSettings.filenameTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
placeholder="{track}. {title}"
className="h-9 text-sm"
/>
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist only</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="album-subfolder"
checked={tempSettings.albumSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
/>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist & Discography</p>
</TooltipContent>
</Tooltip>
</div>
)}
{tempSettings.filenameTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
</p>
)}
</div>
<div className="border-t" />
+82 -62
View File
@@ -9,12 +9,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, type Settings as SettingsType, type FontFamily } from "@/lib/settings";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -237,81 +236,102 @@ export function SettingsPage() {
{/* Right Column */}
<div className="space-y-4">
{/* Filename Format */}
{/* Folder Structure */}
<div className="space-y-2">
<Label className="text-sm">Filename Format</Label>
<RadioGroup
value={tempSettings.filenameFormat}
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
}));
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="title-artist" id="title-artist" />
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="artist-title" id="artist-title" />
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="title" id="title" />
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
</div>
</RadioGroup>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.folderPreset === "custom" && (
<InputWithContext
value={tempSettings.folderTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
placeholder="{artist}/{album}"
className="h-9 text-sm"
/>
)}
{tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
</p>
)}
</div>
<div className="border-t pt-4" />
{/* Folder Settings */}
{/* Filename Format */}
<div className="space-y-2">
<h3 className="font-medium text-sm">Folder Settings</h3>
<div className="flex items-center gap-2">
<Checkbox
id="track-number"
checked={tempSettings.trackNumber}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
/>
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Adds track numbers to filenames</p>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="artist-subfolder"
checked={tempSettings.artistSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
<Select
value={tempSettings.filenamePreset}
onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (
<InputWithContext
value={tempSettings.filenameTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
placeholder="{track}. {title}"
className="h-9 text-sm"
/>
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist only</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="album-subfolder"
checked={tempSettings.albumSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
/>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist & Discography</p>
</TooltipContent>
</Tooltip>
</div>
)}
{tempSettings.filenameTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
</p>
)}
</div>
<div className="border-t pt-4" />
+2 -2
View File
@@ -49,7 +49,7 @@ interface TrackListProps {
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
@@ -301,7 +301,7 @@ export function TrackList({
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1)
}
size="sm"
disabled={isDownloading || downloadingTrack === track.isrc}
+40 -27
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { downloadCover } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
@@ -22,7 +22,6 @@ export function useCover() {
artistName: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
position?: number,
trackId?: string
) => {
@@ -41,20 +40,27 @@ export function useCover() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Build output path similar to audio download
// Build output path using template system
const templateData: TemplateData = {
artist: artistName,
album: albumName,
title: trackName,
track: position,
playlist: playlistName,
};
// For playlist/discography, prepend the folder name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
}
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
}
}
}
@@ -64,7 +70,7 @@ export function useCover() {
track_name: trackName,
artist_name: artistName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
});
@@ -97,8 +103,7 @@ export function useCover() {
const handleDownloadAllCovers = async (
tracks: TrackMetadata[],
playlistName?: string,
isArtistDiscography?: boolean
playlistName?: string
) => {
if (tracks.length === 0) {
toast.error("No tracks to download covers");
@@ -135,19 +140,27 @@ export function useCover() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists,
album: track.album_name,
title: track.name,
track: i + 1,
playlist: playlistName,
};
// For playlist/discography, prepend the folder name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
}
if (isArtistDiscography) {
if (settings.albumSubfolder && track.album_name) {
outputDir = joinPath(os, outputDir, sanitizePath(track.album_name, os));
}
} else {
if (settings.artistSubfolder && track.artists) {
outputDir = joinPath(os, outputDir, sanitizePath(track.artists, os));
}
if (settings.albumSubfolder && track.album_name) {
outputDir = joinPath(os, outputDir, sanitizePath(track.album_name, os));
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
}
}
}
@@ -157,7 +170,7 @@ export function useCover() {
track_name: track.name,
artist_name: track.artists,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: i + 1,
});
+91 -57
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { downloadTrack } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
@@ -27,10 +27,10 @@ export function useDownload() {
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
position?: number,
spotifyId?: string,
durationMs?: number
durationMs?: number,
releaseYear?: string
) => {
let service = settings.downloader;
@@ -40,24 +40,36 @@ export function useDownload() {
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
// Build template data for folder path
const templateData: TemplateData = {
artist: artistName,
album: albumName,
title: trackName,
track: position,
year: releaseYear,
playlist: playlistName,
isrc: isrc,
};
// For playlist/discography downloads, always create a folder with the playlist/artist name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
}
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true; // Use album track number for discography with album subfolder
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true; // Use album track number when both artist and album subfolders are used
// Apply folder template if available
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
}
}
// Use album track number if template contains {album}
if (settings.folderTemplate.includes("{album}")) {
useAlbumTrackNumber = true;
}
}
// Always add item to queue before downloading
@@ -92,7 +104,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -124,7 +136,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -155,7 +167,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -184,7 +196,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -214,7 +226,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -239,11 +251,12 @@ export function useDownload() {
trackName?: string,
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
folderName?: string,
position?: number,
spotifyId?: string,
durationMs?: number
durationMs?: number,
isAlbum?: boolean,
releaseYear?: string
) => {
let service = settings.downloader;
@@ -253,24 +266,38 @@ export function useDownload() {
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
// Build template data for folder path
const templateData: TemplateData = {
artist: artistName,
album: albumName,
title: trackName,
track: position,
year: releaseYear,
playlist: folderName,
isrc: isrc,
};
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true;
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
// For playlist/discography downloads, always create a folder with the playlist/artist name
if (folderName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true;
// Apply folder template if available
if (settings.folderTemplate) {
// Parse and apply folder template
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
// Split by / and sanitize each part
const parts = folderPath.split("/").filter(p => p.trim());
for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
}
}
// Use album track number if template contains {album}
if (settings.folderTemplate.includes("{album}")) {
useAlbumTrackNumber = true;
}
}
if (service === "auto") {
@@ -299,7 +326,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -328,7 +355,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -356,7 +383,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -382,7 +409,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -411,7 +438,7 @@ export function useDownload() {
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
@@ -436,8 +463,8 @@ export function useDownload() {
albumName?: string,
spotifyId?: string,
playlistName?: string,
isArtistDiscography?: boolean,
durationMs?: number
durationMs?: number,
position?: number
) => {
if (!isrc) {
toast.error("No ISRC found for this track");
@@ -457,8 +484,7 @@ export function useDownload() {
artistName,
albumName,
playlistName,
isArtistDiscography,
undefined, // Don't pass position for single track
position, // Pass position for track numbering
spotifyId,
durationMs
);
@@ -491,8 +517,8 @@ export function useDownload() {
const handleDownloadSelected = async (
selectedTracks: string[],
allTracks: TrackMetadata[],
playlistName?: string,
isArtistDiscography?: boolean
folderName?: string,
isAlbum?: boolean
) => {
if (selectedTracks.length === 0) {
toast.error("No tracks selected");
@@ -543,6 +569,9 @@ export function useDownload() {
}
try {
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track?.release_date?.substring(0, 4);
// Download with pre-created itemID
const response = await downloadWithItemID(
isrc,
@@ -551,11 +580,12 @@ export function useDownload() {
track?.name,
track?.artists,
track?.album_name,
playlistName,
isArtistDiscography,
folderName,
i + 1, // Sequential position based on selection order
track?.spotify_id,
track?.duration_ms
track?.duration_ms,
isAlbum,
releaseYear
);
if (response.success) {
@@ -622,8 +652,8 @@ export function useDownload() {
const handleDownloadAll = async (
tracks: TrackMetadata[],
playlistName?: string,
isArtistDiscography?: boolean
folderName?: string,
isAlbum?: boolean
) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc);
@@ -671,6 +701,9 @@ export function useDownload() {
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try {
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(
track.isrc,
settings,
@@ -678,11 +711,12 @@ export function useDownload() {
track.name,
track.artists,
track.album_name,
playlistName,
isArtistDiscography,
folderName,
i + 1,
track.spotify_id,
track.duration_ms
track.duration_ms,
isAlbum,
releaseYear
);
if (response.success) {
+23 -15
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { downloadLyrics } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
@@ -17,7 +17,6 @@ export function useLyrics() {
artistName: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
position?: number
) => {
if (!spotifyId) {
@@ -33,33 +32,42 @@ export function useLyrics() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Build output path similar to audio download
// Build output path using template system
const templateData: TemplateData = {
artist: artistName,
album: albumName,
title: trackName,
track: position,
playlist: playlistName,
};
// For playlist/discography, prepend the folder name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
}
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
}
}
}
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const response = await downloadLyrics({
spotify_id: spotifyId,
track_name: trackName,
artist_name: artistName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
use_album_track_number: settings.albumSubfolder,
use_album_track_number: useAlbumTrackNumber,
});
if (response.success) {
+110 -6
View File
@@ -2,20 +2,64 @@ import { GetDefaults } from "../../wailsjs/go/main/App";
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk";
// Folder structure presets
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom";
// Filename format presets
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "custom";
export interface Settings {
downloadPath: string;
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
theme: string;
themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily;
filenameFormat: "title-artist" | "artist-title" | "title";
artistSubfolder: boolean;
albumSubfolder: boolean;
// New template system
folderPreset: FolderPreset;
folderTemplate: string;
filenamePreset: FilenamePreset;
filenameTemplate: string;
// Legacy settings (kept for migration)
filenameFormat?: "title-artist" | "artist-title" | "title";
artistSubfolder?: boolean;
albumSubfolder?: boolean;
trackNumber: boolean;
sfxEnabled: boolean;
operatingSystem: "Windows" | "linux/MacOS"
}
// Folder preset templates
export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: string }> = {
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"custom": { label: "Custom...", template: "" },
};
// Filename preset templates
export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template: string }> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"custom": { label: "Custom...", template: "" },
};
// Available template variables
export const TEMPLATE_VARIABLES = [
{ key: "{artist}", description: "Artist name", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{year}", description: "Release year", example: "2014" },
{ key: "{isrc}", description: "ISRC code", example: "USCJY1431309" },
{ key: "{playlist}", description: "Playlist name", example: "My Playlist" },
];
// Auto-detect operating system
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
@@ -31,9 +75,10 @@ export const DEFAULT_SETTINGS: Settings = {
theme: "yellow",
themeMode: "auto",
fontFamily: "google-sans",
filenameFormat: "title-artist",
artistSubfolder: false,
albumSubfolder: false,
folderPreset: "none",
folderTemplate: "",
filenamePreset: "title-artist",
filenameTemplate: "{title} - {artist}",
trackNumber: false,
sfxEnabled: true,
operatingSystem: detectOS()
@@ -80,6 +125,37 @@ export function getSettings(): Settings {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
// Migrate old folder/filename settings to new template system
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
const hasArtist = parsed.artistSubfolder;
const hasAlbum = parsed.albumSubfolder;
if (hasArtist && hasAlbum) {
parsed.folderPreset = "artist-album";
parsed.folderTemplate = "{artist}/{album}";
} else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
} else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
} else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
}
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
const format = parsed.filenameFormat;
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
} else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
} else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
// Always use detected OS (don't persist it)
parsed.operatingSystem = detectOS();
return { ...DEFAULT_SETTINGS, ...parsed };
@@ -90,6 +166,34 @@ export function getSettings(): Settings {
return DEFAULT_SETTINGS;
}
// Parse template and replace variables with actual values
export interface TemplateData {
artist?: string;
album?: string;
title?: string;
track?: number;
year?: string;
isrc?: string;
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
if (!template) return "";
let result = template;
// Replace each variable
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{playlist\}/g, data.playlist || "");
return result;
}
export async function getSettingsWithDefaults(): Promise<Settings> {
const settings = getSettings();