Merge pull request #8 from JulienMaille/playlists

Playlists search
This commit is contained in:
Samidy 2025-12-23 20:43:57 -08:00 committed by GitHub
commit 4307b5bf35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 107 additions and 24 deletions

View file

@ -110,6 +110,10 @@
<h2 class="section-title">Recent Albums</h2>
<div class="card-grid" id="home-recent-albums"></div>
</section>
<section class="content-section">
<h2 class="section-title">Recent Playlists</h2>
<div class="card-grid" id="home-recent-playlists"></div>
</section>
<section class="content-section">
<h2 class="section-title">Recent Artists</h2>
<div class="card-grid" id="home-recent-artists"></div>
@ -122,6 +126,7 @@
<button class="search-tab active" data-tab="tracks">Tracks</button>
<button class="search-tab" data-tab="albums">Albums</button>
<button class="search-tab" data-tab="artists">Artists</button>
<button class="search-tab" data-tab="playlists">Playlists</button>
</div>
<div class="search-tab-content active" id="search-tab-tracks">
<div class="track-list" id="search-tracks-container"></div>
@ -132,6 +137,9 @@
<div class="search-tab-content" id="search-tab-artists">
<div class="card-grid" id="search-artists-container"></div>
</div>
<div class="search-tab-content" id="search-tab-playlists">
<div class="card-grid" id="search-playlists-container"></div>
</div>
</div>
<div id="page-album" class="page">

View file

@ -157,6 +157,10 @@ export class LosslessAPI {
return album;
}
preparePlaylist(playlist) {
return playlist;
}
prepareArtist(artist) {
if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) {
return { ...artist, type: artist.artistTypes[0] };
@ -278,6 +282,27 @@ export class LosslessAPI {
}
}
async searchPlaylists(query) {
const cached = await this.cache.get('search_playlists', query);
if (cached) return cached;
try {
const response = await this.fetchWithRetry(`/search/?p=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'playlists');
const result = {
...normalized,
items: normalized.items.map(p => this.preparePlaylist(p))
};
await this.cache.set('search_playlists', query, result);
return result;
} catch (error) {
console.error('Playlist search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
}
}
async getAlbum(id) {
const cached = await this.cache.get('album', id);
if (cached) return cached;
@ -354,26 +379,40 @@ export class LosslessAPI {
// Unwrap the data property if it exists
const data = jsonData.data || jsonData;
const entries = Array.isArray(data) ? data : [data];
let playlist = null;
let tracksSection = null;
let playlist, tracksSection;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!playlist && ('uuid' in entry || 'numberOfTracks' in entry || 'title' in entry && 'id' in entry)) {
playlist = entry;
}
if (!tracksSection && 'items' in entry) {
tracksSection = entry;
}
// Check for direct playlist property (common in v2 responses)
if (data.playlist) {
playlist = data.playlist;
}
// If still no playlist found, try using the first valid entry
if (!playlist && entries.length > 0) {
// Check for direct items property
if (data.items) {
tracksSection = { items: data.items };
}
// Fallback: iterate if we still missed something or if structure is flat array
if (!playlist || !tracksSection) {
const entries = Array.isArray(data) ? data : [data];
for (const entry of entries) {
if (entry && typeof entry === 'object' && ('id' in entry || 'uuid' in entry)) {
if (!entry || typeof entry !== 'object') continue;
if (!playlist && ('uuid' in entry || 'numberOfTracks' in entry || ('title' in entry && 'id' in entry))) {
playlist = entry;
}
if (!tracksSection && 'items' in entry) {
tracksSection = entry;
}
}
}
// Fallback 2: If we have a list of entries but no explicit playlist object, try to find one that looks like a playlist
if (!playlist && Array.isArray(data)) {
for (const entry of data) {
if (entry && typeof entry === 'object' && ('uuid' in entry || 'numberOfTracks' in entry)) {
playlist = entry;
break;
}

View file

@ -193,9 +193,11 @@ export const recentActivityManager = {
_get() {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : { artists: [], albums: [] };
const parsed = data ? JSON.parse(data) : { artists: [], albums: [], playlists: [] };
if (!parsed.playlists) parsed.playlists = [];
return parsed;
} catch (e) {
return { artists: [], albums: [] };
return { artists: [], albums: [], playlists: [] };
}
},
@ -221,6 +223,10 @@ export const recentActivityManager = {
addAlbum(album) {
this._add('albums', album);
},
addPlaylist(playlist) {
this._add('playlists', playlist);
}
};

View file

@ -103,6 +103,19 @@ export class UIRenderer {
`;
}
createPlaylistCardHTML(playlist) {
const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed
return `
<a href="#playlist/${playlist.uuid}" class="card">
<div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(imageId, '320')}" alt="${playlist.title}" class="card-image" loading="lazy">
</div>
<h3 class="card-title">${playlist.title}</h3>
<p class="card-subtitle">${playlist.numberOfTracks || 0} tracks</p>
</a>
`;
}
createArtistCardHTML(artist) {
return `
<a href="#artist/${artist.id}" class="card artist">
@ -193,6 +206,7 @@ export class UIRenderer {
const albumsContainer = document.getElementById('home-recent-albums');
const artistsContainer = document.getElementById('home-recent-artists');
const playlistsContainer = document.getElementById('home-recent-playlists');
albumsContainer.innerHTML = recents.albums.length
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
@ -201,6 +215,12 @@ export class UIRenderer {
artistsContainer.innerHTML = recents.artists.length
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
: createPlaceholder("You haven't viewed any artists yet. Search for music to get started!");
if (playlistsContainer) {
playlistsContainer.innerHTML = recents.playlists && recents.playlists.length
? recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
: createPlaceholder("You haven't viewed any playlists yet. Search for music to get started!");
}
}
async renderSearchPage(query) {
@ -210,21 +230,25 @@ export class UIRenderer {
const tracksContainer = document.getElementById('search-tracks-container');
const artistsContainer = document.getElementById('search-artists-container');
const albumsContainer = document.getElementById('search-albums-container');
const playlistsContainer = document.getElementById('search-playlists-container');
tracksContainer.innerHTML = this.createSkeletonTracks(8, true);
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
playlistsContainer.innerHTML = this.createSkeletonCards(6, false);
try {
const [tracksResult, artistsResult, albumsResult] = await Promise.all([
const [tracksResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
this.api.searchTracks(query),
this.api.searchArtists(query),
this.api.searchAlbums(query)
this.api.searchAlbums(query),
this.api.searchPlaylists(query)
]);
let finalTracks = tracksResult.items;
let finalArtists = artistsResult.items;
let finalAlbums = albumsResult.items;
let finalPlaylists = playlistsResult.items;
if (finalArtists.length === 0 && finalTracks.length > 0) {
const artistMap = new Map();
@ -267,12 +291,17 @@ export class UIRenderer {
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder('No albums found.');
playlistsContainer.innerHTML = finalPlaylists.length
? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
: createPlaceholder('No playlists found.');
} catch (error) {
console.error("Search failed:", error);
const errorMsg = createPlaceholder(`Error during search. ${error.message}`);
tracksContainer.innerHTML = errorMsg;
artistsContainer.innerHTML = errorMsg;
albumsContainer.innerHTML = errorMsg;
playlistsContainer.innerHTML = errorMsg;
}
}
@ -405,10 +434,11 @@ async renderPlaylistPage(playlistId) {
</div>
`;
this.renderListWithTracks(tracklistContainer, tracks, true);
document.title = `${playlist.title} - Monochrome`;
} catch (error) {
this.renderListWithTracks(tracklistContainer, tracks, true);
recentActivityManager.addPlaylist(playlist);
document.title = `${playlist.title || 'Artist Mix'} - Monochrome`; } catch (error) {
console.error("Failed to load playlist:", error);
tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`);
}