Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 123 additions & 156 deletions plugins/multipleplaylists/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LunaUnload, Tracer } from "@luna/core";
import { MediaItem, redux, ContextMenu, Playlist } from "@luna/lib";
import { MediaItem, redux, ContextMenu, Playlist, TidalApi } from "@luna/lib";

export const { trace, errSignal } = Tracer("[MultiplePlaylists]");

Expand All @@ -9,6 +9,9 @@ export { Settings } from "./Settings";
// Functions in unloads are called when plugin is unloaded.
export const unloads = new Set<LunaUnload>();

// Local tracking of songs added to playlists (server data can be stale)
const addedSongs = new Map<string, Set<string>>(); // playlistId -> Set<songId>

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addedSongs is a long-lived global Map that never gets cleared. If the plugin is used heavily across many playlists/songs, this can grow unbounded over the app lifetime. Consider clearing it on plugin unload (e.g., add an unload callback) and/or pruning per-playlist sets after some reasonable size/age.

Suggested change
// Ensure local tracking map is cleared when the plugin is unloaded to avoid unbounded growth.
unloads.add(() => {
addedSongs.clear();
});

Copilot uses AI. Check for mistakes.
// Function to show playlist selector modal
async function showPlaylistSelector(song: MediaItem) {

Expand Down Expand Up @@ -122,19 +125,56 @@ async function showPlaylistSelector(song: MediaItem) {
});
}

// Type definition for playlist object structure
interface PlaylistObject {
uuid: string;
title?: string;
numberOfTracks?: number;
type: string;
isEditable?: boolean;
isAutoGenerated?: boolean;
isSuggested?: boolean;
isRecommended?: boolean;
creator?: {
id?: string;
};
// Fetch user's playlists via Tidal API (proven approach from squadgazzz/PlaylistTools)
async function fetchUserPlaylists(): Promise<Array<{uuid: string, title: string, numberOfTracks: number}>> {
const state = redux.store.getState();
const userId = state.session?.userId;
if (!userId) throw new Error("Not logged in");

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchUserPlaylists() throws when state.session?.userId is missing, but the caller treats any failure the same and may end up showing "No playlists found". Consider detecting the "not logged in" case early in populatePlaylistList and showing a more accurate UI message (or skipping the favorites fallback if adding to playlists isn't possible).

Suggested change
if (!userId) throw new Error("Not logged in");
if (!userId) {
trace("fetchUserPlaylists: no userId in session; returning empty playlist list");
return [];
}

Copilot uses AI. Check for mistakes.

const headers = await TidalApi.getAuthHeaders();
const queryArgs = TidalApi.queryArgs();
const res = await fetch(
`https://api.tidal.com/v1/users/${userId}/playlists?${queryArgs}&limit=999`,
{ headers }
);
if (!res.ok) throw new Error(`Failed to fetch playlists: ${res.status}`);

const data = await res.json();
// Only show playlists the user owns (can add songs to)
return (data.items || [])
.filter((p: any) => p.creator?.id === userId)
.map((p: any) => ({
uuid: p.uuid,
title: p.title,
numberOfTracks: p.numberOfTracks
}));
}

// Fallback: load playlists from favorites store + Playlist.fromId()
async function fetchPlaylistsFromFavorites(): Promise<Array<{uuid: string, title: string, numberOfTracks: number}>> {
const state = redux.store.getState();
const userId = state.session?.userId;
const favoriteUUIDs: string[] = state.favorites?.playlists || [];
if (favoriteUUIDs.length === 0) return [];

const results: Array<{uuid: string, title: string, numberOfTracks: number}> = [];
for (const uuid of favoriteUUIDs) {
try {
const playlist = await Playlist.fromId(uuid);
if (playlist) {
// Only show playlists the user owns (can add songs to)
if (userId && playlist.tidalPlaylist?.creator?.id !== userId) continue;
results.push({
uuid,
title: await playlist.title() || 'Untitled Playlist',
numberOfTracks: await playlist.count() || 0
});
}
} catch (error) {
console.warn(`[MultiplePlaylists] Failed to load playlist ${uuid} from favorites:`, error);
}
}
return results;
}

// Function to populate the playlist list
Expand All @@ -143,67 +183,48 @@ async function populatePlaylistList(song?: MediaItem) {
if (!playlistContainer) return;

try {
// Get playlists from redux store
const state = redux.store.getState();
const playlists = state.content?.playlists || {};
const currentUser = state.auth?.user || {};
// Show loading state
playlistContainer.innerHTML = '<div style="padding: 20px; text-align: center; opacity: 0.7;">Loading playlists...</div>';

if (Object.keys(playlists).length === 0) {
// Try fetching playlists: API first, then favorites fallback
let playlistsArray: Array<{uuid: string, title: string, numberOfTracks: number}> = [];
try {
playlistsArray = await fetchUserPlaylists();
console.log(`[MultiplePlaylists] Loaded ${playlistsArray.length} playlists via Tidal API`);
} catch (apiError) {
console.warn('[MultiplePlaylists] API fetch failed, trying favorites fallback:', apiError);
try {
playlistsArray = await fetchPlaylistsFromFavorites();
console.log(`[MultiplePlaylists] Loaded ${playlistsArray.length} playlists from favorites`);
} catch (favError) {
console.warn('[MultiplePlaylists] Favorites fallback also failed:', favError);
}
}

if (playlistsArray.length === 0) {
playlistContainer.innerHTML = '<p style="opacity: 0.7;">No playlists found. Create some playlists first!</p>';
return;
}

// Filter for user's own playlists more specifically
const playlistsArray = Object.values(playlists).filter((playlist: any): playlist is PlaylistObject => {
if (!playlist || playlist.type !== 'USER') return false;

// Additional filtering to exclude recommended/suggested playlists
// Check for properties that indicate ownership
const playlistData = playlist as any;

// Exclude playlists that are marked as suggested/recommended
if (playlistData.isSuggested || playlistData.isRecommended) return false;

// Only include playlists that are editable (user owns them)
if (playlistData.isEditable === false) return false;

// Exclude auto-generated playlists
if (playlistData.isAutoGenerated) return false;

// Additional check: if creator info is available, ensure it's the current user
if (playlistData.creator?.id && currentUser.id && playlistData.creator.id !== currentUser.id) {
return false;
}

return true;
});

console.log(`[MultiplePlaylists] Processing ${playlistsArray.length} user playlists for duplicate detection`);

// Show loading state initially
playlistContainer.innerHTML = '<div style="padding: 20px; text-align: center; opacity: 0.7;">Loading playlists...</div>';

// Process playlists in batches to avoid blocking the UI
// Process playlists in batches for duplicate detection
const BATCH_SIZE = 5;
const playlistBatches = [];
for (let i = 0; i < playlistsArray.length; i += BATCH_SIZE) {
playlistBatches.push(playlistsArray.slice(i, i + BATCH_SIZE));
}

// Clear container and start building the list
playlistContainer.innerHTML = '';

for (const [batchIndex, batch] of playlistBatches.entries()) {
console.log(`[MultiplePlaylists] Processing batch ${batchIndex + 1}/${playlistBatches.length} (${batch.length} playlists)`);

// Process batch with Promise.allSettled for fault tolerance
const batchResults = await Promise.allSettled(
batch.map(async (playlist: PlaylistObject) => {
batch.map(async (playlist) => {
try {
const isAlreadyInPlaylist = song ? await isSongInPlaylist(song, playlist.uuid) : false;
const statusText = isAlreadyInPlaylist ? ' (Already added)' : '';
const opacity = isAlreadyInPlaylist ? '0.6' : '1';

return `
<label style="
display: flex;
Expand All @@ -215,8 +236,8 @@ async function populatePlaylistList(song?: MediaItem) {
transition: background 0.2s;
opacity: ${opacity};
" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<input type="checkbox"
data-playlist-id="${playlist.uuid}"
<input type="checkbox"
data-playlist-id="${playlist.uuid}"
style="margin-right: 12px; cursor: pointer;"
${isAlreadyInPlaylist ? 'disabled' : ''}>
<div>
Expand All @@ -227,7 +248,6 @@ async function populatePlaylistList(song?: MediaItem) {
`;
} catch (error) {
console.warn(`[MultiplePlaylists] Failed to process playlist ${playlist.uuid}:`, error);
// Return playlist without duplicate detection if check fails
return `
<label style="
display: flex;
Expand All @@ -238,133 +258,74 @@ async function populatePlaylistList(song?: MediaItem) {
border-radius: 4px;
transition: background 0.2s;
" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<input type="checkbox"
data-playlist-id="${playlist.uuid}"
<input type="checkbox"
data-playlist-id="${playlist.uuid}"
style="margin-right: 12px; cursor: pointer;">
<div>
<div style="font-weight: 500;">${playlist.title || 'Untitled Playlist'} (Check failed)</div>
<div style="font-weight: 500;">${playlist.title || 'Untitled Playlist'}</div>
<div style="font-size: 12px; opacity: 0.7;">${playlist.numberOfTracks || 0} tracks</div>
</div>
</label>
`;
}
})
);

// Add results to UI immediately after each batch
const batchHtml = batchResults
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
.join('');

playlistContainer.innerHTML += batchHtml;

// Small delay between batches to keep UI responsive
if (batchIndex < playlistBatches.length - 1) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}

console.log('[MultiplePlaylists] Finished processing all playlists');

} catch (error) {
// Log playlist load errors for debugging
trace.warn("Error loading playlists:", error);
playlistContainer.innerHTML = '<p style="color: #ff6b6b;">Error loading playlists</p>';
}
}

// Cache for playlist contents to avoid re-loading
const playlistCache = new Map<string, Set<string>>();
const cacheTimestamps = new Map<string, number>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

// Clear cache periodically to prevent memory buildup
setInterval(() => {
const now = Date.now();
for (const [playlistId, timestamp] of cacheTimestamps.entries()) {
if (now - timestamp > CACHE_DURATION) {
playlistCache.delete(playlistId);
cacheTimestamps.delete(playlistId);
// Fetch all track IDs in a playlist using Luna's Playlist class
async function getPlaylistTrackIds(playlistId: string): Promise<Set<string>> {
const trackIds = new Set<string>();
try {
const playlist = await Playlist.fromId(playlistId);
if (!playlist) return trackIds;

const items = await playlist.mediaItems();
for await (const item of items) {
trackIds.add(String(item.id));
}
console.log(`[MultiplePlaylists] Playlist ${playlistId} has ${trackIds.size} tracks`);
} catch (error) {
console.error(`[MultiplePlaylists] Error fetching tracks for ${playlistId}:`, error);
}
}, 60000); // Clean every minute
return trackIds;
}

// Function to check if a song is already in a playlist using Luna's Playlist API with optimizations
// Check if a song is in a playlist — checks local tracking first, then server
async function isSongInPlaylist(mediaItem: MediaItem, playlistId: string): Promise<boolean> {
try {
console.log(`[MultiplePlaylists] Checking if song ${mediaItem.id} is in playlist ${playlistId}`);

const songIdStr = String(mediaItem.id);
const now = Date.now();

// Check cache first
if (playlistCache.has(playlistId)) {
const cacheTime = cacheTimestamps.get(playlistId) || 0;
if (now - cacheTime < CACHE_DURATION) {
const cachedTracks = playlistCache.get(playlistId)!;
const isInPlaylist = cachedTracks.has(songIdStr);
console.log(`[MultiplePlaylists] Cache hit for playlist ${playlistId}: ${isInPlaylist ? 'IS' : 'NOT'} in playlist`);
return isInPlaylist;
} else {
// Cache expired, clear it
playlistCache.delete(playlistId);
cacheTimestamps.delete(playlistId);
}
}

// Add timeout protection for very large playlists (30 seconds max)
const timeoutPromise = new Promise<boolean>((_, reject) => {
setTimeout(() => reject(new Error('Playlist check timeout')), 30000);
});

const checkPromise = async (): Promise<boolean> => {
// Load the playlist using Luna's Playlist API
const playlist = await Playlist.fromId(playlistId);
if (!playlist) {
console.log(`[MultiplePlaylists] Could not load playlist ${playlistId}`);
return false;
}

console.log(`[MultiplePlaylists] Successfully loaded playlist: ${await playlist.title()}`);

// Get all media items in the playlist and cache them
const playlistMediaItems = await playlist.mediaItems();
const trackIds = new Set<string>();
let processedCount = 0;

// Convert async generator to Set for fast lookups with progress logging
for await (const playlistMediaItem of playlistMediaItems) {
trackIds.add(String(playlistMediaItem.id));
processedCount++;

// Log progress for very large playlists
if (processedCount % 1000 === 0) {
console.log(`[MultiplePlaylists] Processed ${processedCount} tracks from playlist ${playlistId}...`);
}
}

// Cache the results
playlistCache.set(playlistId, trackIds);
cacheTimestamps.set(playlistId, now);

const isInPlaylist = trackIds.has(songIdStr);
console.log(`[MultiplePlaylists] Song ${songIdStr} ${isInPlaylist ? 'IS' : 'NOT'} in playlist (${trackIds.size} tracks total)`);

return isInPlaylist;
};

// Race between the actual check and timeout
return await Promise.race([checkPromise(), timeoutPromise]);

} catch (error) {
if (error instanceof Error && error.message === 'Playlist check timeout') {
console.warn(`[MultiplePlaylists] Timeout checking playlist ${playlistId} - playlist may be very large, skipping duplicate check`);
} else {
console.error(`[MultiplePlaylists] Error checking playlist membership:`, error);
}
return false; // If we can't check, don't block the addition
const songIdStr = String(mediaItem.id);

// Fast check: did we add this song in the current session?
if (addedSongs.get(playlistId)?.has(songIdStr)) {
console.log(`[MultiplePlaylists] Song ${songIdStr} IS in playlist ${playlistId} (local tracking)`);
return true;
}

// Server check
const trackIds = await getPlaylistTrackIds(playlistId);
const found = trackIds.has(songIdStr);
console.log(`[MultiplePlaylists] Song ${songIdStr} ${found ? 'IS' : 'NOT'} in playlist ${playlistId} (server)`);
return found;
Comment on lines +324 to +328

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSongInPlaylist() always calls getPlaylistTrackIds() which iterates the entire playlist. This is invoked during UI rendering (per playlist) and again in addToSelectedPlaylists, so the same playlist can be fully scanned twice per modal open, and again on subsequent opens. Consider caching trackIds per playlist for the lifetime of the modal/session (or reusing the earlier duplicate-check result) to avoid repeated full playlist loads and reduce API load/latency.

Copilot uses AI. Check for mistakes.
}

// Function to add song to selected playlists from the modal, and persist selection
Expand All @@ -388,7 +349,7 @@ async function addToSelectedPlaylists(song: MediaItem) {
try {
console.log(`[MultiplePlaylists] Processing playlist ${playlistId} for song ${song.id}`);

// Check if song is already in the playlist using the MediaItem
// Check if song is already in the playlist
const alreadyInPlaylist = await isSongInPlaylist(song, playlistId);

if (alreadyInPlaylist) {
Expand All @@ -404,9 +365,15 @@ async function addToSelectedPlaylists(song: MediaItem) {
payload: {
playlistUUID: playlistId,
mediaItemIdsToAdd: [song.id],
addToIndex: -1 // Add to end
addToIndex: -1, // Add to end
onDupes: "SKIP" // Server-side duplicate prevention
}
});

// Track locally so we detect it immediately next time
if (!addedSongs.has(playlistId)) addedSongs.set(playlistId, new Set());
addedSongs.get(playlistId)!.add(String(song.id));
Comment on lines +373 to +375

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local duplicate tracking is updated immediately after dispatch(...) without any confirmation that the add actually succeeded. If the underlying async add fails, the song will be treated as "already added" for the rest of the session and the user may be unable to retry. Consider only updating addedSongs after a confirmed success signal (e.g., observing the relevant store update / awaited API result), or make the tracking explicitly optimistic and roll it back if failure can be detected.

Copilot uses AI. Check for mistakes.

successCount++;
} catch (error) {
// Log per-playlist errors but continue with others
Expand Down
Loading