export default function SkippedTracksPage() {
const [skippedTracks, setSkippedTracks] = useState<SkippedTrack[]>([]);
const [loading, setLoading] = useState(false);
const [timeframeInDays, setTimeframeInDays] = useState(30);
const [skipThreshold, setSkipThreshold] = useState(3);
const [autoUnlike, setAutoUnlike] = useState(true);
// Confirmation dialog states
const [showClearDataDialog, setShowClearDataDialog] = useState(false);
const [showRemoveHighlightedDialog, setShowRemoveHighlightedDialog] =
useState(false);
/**
* Initializes component with skip data and user preferences
*
* Performs multi-stage initialization by:
* 1. Loading skipped track history from persistent storage
* 2. Retrieving user configuration settings for thresholds and timeframes
* 3. Synchronizing component state with application preferences
* 4. Ensuring proper error handling with user feedback
*
* Sets the foundation for all skip analysis functionality by establishing
* data context and user preferences for analysis parameters.
*/
const loadSkippedData = async () => {
setLoading(true);
try {
const tracks = await window.spotify.getSkippedTracks();
const settings = (await window.spotify.getSettings()) as SettingsSchema;
setTimeframeInDays(settings.timeframeInDays || 30);
setSkipThreshold(settings.skipThreshold || 3);
setAutoUnlike(settings.autoUnlike !== false);
setSkippedTracks(tracks);
} catch (error) {
console.error("Failed to load skipped tracks:", error);
toast.error("Failed to load data", {
description: "Could not load skipped tracks data.",
});
window.spotify.saveLog(`Error loading skipped tracks: ${error}`, "ERROR");
} finally {
setLoading(false);
}
};
/**
* Refreshes skipped tracks data using the dedicated refresh endpoint
*/
const refreshSkippedData = async () => {
setLoading(true);
try {
const tracks = await window.spotify.refreshSkippedTracks();
setSkippedTracks(tracks);
toast.success("Data refreshed", {
description: "Skipped tracks data has been refreshed.",
});
window.spotify.saveLog("Refreshed skipped tracks data", "INFO");
} catch (error) {
console.error("Failed to refresh skipped tracks:", error);
toast.error("Failed to refresh data", {
description: "Could not refresh skipped tracks data.",
});
window.spotify.saveLog(
`Error refreshing skipped tracks: ${error}`,
"ERROR",
);
} finally {
setLoading(false);
}
};
/**
* Evaluates tracks for automatic removal based on user preferences
*
* Implements intelligent library management by:
* 1. Filtering tracks that meet removal criteria based on skip threshold
* 2. Processing eligible tracks individually while respecting rate limits
* 3. Removing tracks from both Spotify library and local tracking data
* 4. Providing user feedback with grouped notifications to prevent overwhelming
* 5. Logging detailed removal information for audit and troubleshooting
*
* Only executes when autoUnlike setting is enabled, respecting user preference
* for automated library management.
*/
const checkForAutoUnlike = async () => {
if (!autoUnlike) return;
try {
const tracksToUnlike = skippedTracks.filter((track) =>
shouldSuggestRemoval(track, skipThreshold, timeframeInDays),
);
if (tracksToUnlike.length === 0) return;
const removedTrackIds: string[] = [];
for (const track of tracksToUnlike) {
try {
if (track.autoProcessed) continue;
const unlikeSuccess = await window.spotify.unlikeTrack(track.id);
if (unlikeSuccess) {
window.spotify.saveLog(
`Auto-removed track ${track.id} "${track.name}" from library (${shouldSuggestRemoval(track, skipThreshold, timeframeInDays) ? "eligible for removal" : "not eligible for removal"})`,
"INFO",
);
const removeSuccess = await removeFromSkippedData(track.id);
if (removeSuccess) {
removedTrackIds.push(track.id);
window.spotify.saveLog(
`Removed track ${track.id} from skipped data`,
"INFO",
);
}
track.autoProcessed = true;
// Limit notifications to avoid flooding
if (tracksToUnlike.indexOf(track) < 3) {
toast.success("Track auto-removed", {
description: `"${track.name}" by ${track.artist} was automatically removed from your library${removeSuccess ? " and skipped tracks" : ""}.`,
});
} else if (tracksToUnlike.indexOf(track) === 3) {
toast.info(
`${tracksToUnlike.length - 3} more tracks were automatically removed`,
{
description: "Check logs for details.",
},
);
}
}
} catch (error) {
console.error(`Error auto-unliking track ${track.id}:`, error);
}
}
if (removedTrackIds.length > 0) {
setSkippedTracks((prev) =>
prev.filter((track) => !removedTrackIds.includes(track.id)),
);
} else {
await loadSkippedData();
}
} catch (error) {
console.error("Error in auto-unlike process:", error);
}
};
/**
* Opens the data directory containing skip tracking files
*/
const handleOpenSkipsDirectory = async () => {
try {
await window.spotify.openSkipsDirectory();
} catch (error) {
console.error("Failed to open skip data folder:", error);
toast.error("Failed to open skip data folder", {
description: "Could not open the folder where skip data is saved.",
});
}
};
// Load data on component mount
useEffect(() => {
loadSkippedData();
}, []);
// Run auto-unlike when relevant state changes
useEffect(() => {
if (skippedTracks.length > 0) {
checkForAutoUnlike();
}
}, [skippedTracks, autoUnlike, skipThreshold]);
/**
* Removes track from skip tracking database only
*
* @param trackId - Spotify track ID to remove from tracking
* @returns Promise resolving to success status
*/
const removeFromSkippedData = async (trackId: string): Promise<boolean> => {
try {
const success = await window.spotify.removeFromSkippedData(trackId);
return success;
} catch (error) {
console.error("Error removing track from skipped data:", error);
window.spotify.saveLog(
`Error removing track ${trackId} from skipped data: ${error}`,
"ERROR",
);
return false;
}
};
/**
* Removes a track from Spotify library with optimistic UI updates
*
* Handles the complete unlike workflow:
* 1. Initiates Spotify API call to remove track from user's library
* 2. Updates local skip tracking data to reflect removal
* 3. Optimistically updates UI before API completion for responsive feel
* 4. Provides appropriate user feedback based on operation result
* 5. Handles errors gracefully with clear error messaging
*
* @param track - Track object containing ID and metadata for removal
*/
const handleUnlikeTrack = async (track: SkippedTrack) => {
try {
const unlikeSuccess = await window.spotify.unlikeTrack(track.id);
if (unlikeSuccess) {
window.spotify.saveLog(
`Manually removed track ${track.id} "${track.name}" from library`,
"INFO",
);
const removeSuccess = await removeFromSkippedData(track.id);
if (removeSuccess) {
window.spotify.saveLog(
`Removed track ${track.id} from skipped data`,
"INFO",
);
toast.success("Track removed", {
description: `"${track.name}" by ${track.artist} has been removed from your library and skipped tracks.`,
});
setSkippedTracks((prev) => prev.filter((t) => t.id !== track.id));
} else {
toast.success("Track partially removed", {
description: `"${track.name}" was removed from your library but couldn't be removed from skipped tracks data.`,
});
await loadSkippedData();
}
} else {
toast.error("Failed to remove track", {
description:
"Could not remove the track from your library. Please try again.",
});
}
} catch (error) {
console.error("Error unliking track:", error);
toast.error("Error", {
description: "An error occurred while removing the track.",
});
window.spotify.saveLog(
`Error removing track ${track.id}: ${error}`,
"ERROR",
);
}
};
/**
* Removes track from skip tracking without affecting library
*
* @param track - Track to remove from skip tracking
*/
const handleRemoveTrackData = async (track: SkippedTrack) => {
try {
const removeSuccess = await removeFromSkippedData(track.id);
if (removeSuccess) {
window.spotify.saveLog(
`Removed track ${track.id} "${track.name}" from skipped data only`,
"INFO",
);
toast.success("Track data removed", {
description: `"${track.name}" has been removed from skipped tracks data but remains in your library.`,
});
setSkippedTracks((prev) => prev.filter((t) => t.id !== track.id));
} else {
toast.error("Failed to remove track data", {
description:
"Could not remove the track from skipped data. Please try again.",
});
}
} catch (error) {
console.error("Error removing track data:", error);
toast.error("Error", {
description: "An error occurred while removing the track data.",
});
window.spotify.saveLog(
`Error removing track ${track.id} data: ${error}`,
"ERROR",
);
}
};
/**
* Batch removes all tracks exceeding skip threshold
* from both Spotify library and tracking database
*/
const handleRemoveAllHighlighted = async () => {
const tracksToRemove = skippedTracks.filter((track) =>
shouldSuggestRemoval(track, skipThreshold, timeframeInDays),
);
if (tracksToRemove.length === 0) {
toast.info("No tracks to remove", {
description: "There are no tracks that exceed the skip threshold.",
});
return;
}
// Close the dialog since we're proceeding with the action
setShowRemoveHighlightedDialog(false);
setLoading(true);
try {
let successCount = 0;
let failCount = 0;
for (const track of tracksToRemove) {
try {
const unlikeSuccess = await window.spotify.unlikeTrack(track.id);
if (unlikeSuccess) {
const removeSuccess = await removeFromSkippedData(track.id);
if (removeSuccess) {
successCount++;
window.spotify.saveLog(
`Batch removed track ${track.id} "${track.name}" from library and skipped data`,
"INFO",
);
} else {
window.spotify.saveLog(
`Batch removed track ${track.id} "${track.name}" from library only`,
"INFO",
);
failCount++;
}
} else {
failCount++;
window.spotify.saveLog(
`Failed to remove track ${track.id} from library during batch operation`,
"WARNING",
);
}
} catch (error) {
failCount++;
console.error(`Error processing track ${track.id}:`, error);
window.spotify.saveLog(
`Error removing track ${track.id} during batch operation: ${error}`,
"ERROR",
);
}
}
if (successCount > 0) {
toast.success(`Removed ${successCount} tracks`, {
description:
failCount > 0
? `Successfully removed ${successCount} tracks, but failed to remove ${failCount} tracks.`
: `Successfully removed ${successCount} tracks from your library.`,
});
} else if (failCount > 0) {
toast.error("Operation failed", {
description: `Failed to remove any of the ${failCount} tracks.`,
});
}
await loadSkippedData();
} catch (error) {
console.error("Error in batch remove operation:", error);
toast.error("Operation failed", {
description:
"An error occurred while removing tracks. See logs for details.",
});
window.spotify.saveLog(
`Error in batch remove operation: ${error}`,
"ERROR",
);
} finally {
setLoading(false);
}
};
/**
* Purges all skip tracking data while preserving Spotify library
*/
const handleClearSkippedData = async () => {
// Close the dialog since we're proceeding with the action
setShowClearDataDialog(false);
setLoading(true);
try {
const success = await window.spotify.saveSkippedTracks([]);
if (success) {
setSkippedTracks([]);
toast.success("Data cleared", {
description: "All skipped tracks data has been cleared.",
});
window.spotify.saveLog("Cleared all skipped tracks data", "INFO");
} else {
toast.error("Failed to clear data", {
description:
"An error occurred while clearing the skipped tracks data.",
});
}
} catch (error) {
console.error("Error clearing skipped data:", error);
toast.error("Error", {
description: "An error occurred while clearing the data.",
});
window.spotify.saveLog(`Error clearing skipped data: ${error}`, "ERROR");
} finally {
setLoading(false);
}
};
return (
<SkippedTracksLayout
isLoading={loading}
header={
<Suspense
fallback={<LoadingSpinner size="md" text="Loading header..." />}
>
<SkippedTracksHeader
timeframeInDays={timeframeInDays}
skipThreshold={skipThreshold}
loading={loading}
onRefresh={refreshSkippedData}
onOpenSkipsDirectory={handleOpenSkipsDirectory}
/>
</Suspense>
}
bulkActions={
<Suspense
fallback={<LoadingSpinner size="md" text="Loading actions..." />}
>
<SkippedTracksBulkActions
loading={loading}
tracks={skippedTracks}
skipThreshold={skipThreshold}
timeframeInDays={timeframeInDays}
showClearDataDialog={showClearDataDialog}
setShowClearDataDialog={setShowClearDataDialog}
showRemoveHighlightedDialog={showRemoveHighlightedDialog}
setShowRemoveHighlightedDialog={setShowRemoveHighlightedDialog}
onClearSkippedData={handleClearSkippedData}
onRemoveAllHighlighted={handleRemoveAllHighlighted}
/>
</Suspense>
}
tracksTable={
<Suspense
fallback={<LoadingSpinner size="lg" text="Loading track data..." />}
>
<SkippedTracksTable
tracks={skippedTracks}
loading={loading}
skipThreshold={skipThreshold}
timeframeInDays={timeframeInDays}
onUnlikeTrack={handleUnlikeTrack}
onRemoveTrackData={handleRemoveTrackData}
/>
</Suspense>
}
/>
);
}
SkippedTracksPage component