diff --git a/src/backend/src/playlist/playlist.entity.ts b/src/backend/src/playlist/playlist.entity.ts index 26841a3..2a4c525 100644 --- a/src/backend/src/playlist/playlist.entity.ts +++ b/src/backend/src/playlist/playlist.entity.ts @@ -18,6 +18,9 @@ export class PlaylistEntity { @Column({ default: false }) active?: boolean; + @Column({ default: false }) + isTrack?: boolean; // True for individual tracks, false for actual playlists + @Column({ default: () => Date.now() }) createdAt?: number; diff --git a/src/backend/src/playlist/playlist.service.ts b/src/backend/src/playlist/playlist.service.ts index 8b96a6b..5a612b7 100644 --- a/src/backend/src/playlist/playlist.service.ts +++ b/src/backend/src/playlist/playlist.service.ts @@ -48,6 +48,59 @@ export class PlaylistService { } async create(playlist: PlaylistEntity): Promise { + // Detect if URL is for a single track or a playlist and route accordingly + const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl); + + if (isTrack) { + await this.createSingleTrack(playlist); + } else { + await this.createPlaylist(playlist); + } + } + + private async createSingleTrack(playlist: PlaylistEntity): Promise { + let trackDetail: { name: string; artist: string; image: string }; + let playlist2Save: PlaylistEntity; + try { + trackDetail = await this.spotifyService.getTrackDetail( + playlist.spotifyUrl, + ); + this.logger.debug(`Track detail retrieved: ${trackDetail.name}`); + + playlist2Save = { + ...playlist, + name: trackDetail.name, + coverUrl: trackDetail.image, + isTrack: true, + active: false, // Single tracks cannot be subscribed + }; + // Don't create folder structure for individual tracks - they go in root + } catch (err) { + this.logger.error(`Error getting track details: ${err}`); + playlist2Save = { ...playlist, error: String(err), isTrack: true }; + } + const savedPlaylist = await this.save(playlist2Save); + + if (trackDetail) { + try { + await this.trackService.create( + { + artist: trackDetail.artist, + name: trackDetail.name, + spotifyUrl: playlist.spotifyUrl, + coverUrl: trackDetail.image, + }, + savedPlaylist, + ); + } catch (error) { + this.logger.error( + `Error creating track "${trackDetail.artist} - ${trackDetail.name}": ${error.message}`, + ); + } + } + } + + private async createPlaylist(playlist: PlaylistEntity): Promise { let detail: { tracks: any; name: any; image: any }; let playlist2Save: PlaylistEntity; try { @@ -100,6 +153,7 @@ export class PlaylistService { artist: track.artist, name: track.name, spotifyUrl: track.previewUrl || null, + coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover }, savedPlaylist, ); @@ -158,7 +212,10 @@ export class PlaylistService { @Interval(3_600_000) async checkActivePlaylists(): Promise { - const activePlaylists = await this.findAll({}, { active: true }); + // Only check actual playlists (not individual tracks) that are subscribed + const activePlaylists = await this.findAll( + { active: true, isTrack: false }, + ); for (const playlist of activePlaylists) { let tracks = []; try { diff --git a/src/backend/src/shared/spotify-api.service.ts b/src/backend/src/shared/spotify-api.service.ts index 08047d1..4f069f3 100644 --- a/src/backend/src/shared/spotify-api.service.ts +++ b/src/backend/src/shared/spotify-api.service.ts @@ -27,6 +27,64 @@ export class SpotifyApiService { } } + isTrackUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.pathname.includes('/track/'); + } catch { + return false; + } + } + + private getTrackId(url: string): string { + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/'); + const trackIndex = pathParts.findIndex((part) => part === 'track'); + if (trackIndex >= 0 && pathParts.length > trackIndex + 1) { + return pathParts[trackIndex + 1].split('?')[0]; + } + throw new Error('Invalid Spotify track URL'); + } catch (error) { + this.logger.error(`Failed to extract track ID: ${error.message}`); + throw error; + } + } + + async getTrackMetadata( + spotifyUrl: string, + ): Promise<{ name: string; artist: string; image: string }> { + try { + this.logger.debug(`Getting track metadata for ${spotifyUrl}`); + const trackId = this.getTrackId(spotifyUrl); + const accessToken = await this.getAccessToken(); + + const response = await fetch( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch track: ${response.status}`); + } + + const data = await response.json(); + + return { + name: data.name, + artist: data.artists.map((a) => a.name).join(', '), + image: data.album.images[0]?.url || '', + }; + } catch (error) { + this.logger.error(`Failed to get track metadata: ${error.message}`); + throw error; + } + } + async getPlaylistMetadata( spotifyUrl: string, ): Promise<{ name: string; image: string }> { @@ -95,38 +153,22 @@ export class SpotifyApiService { try { this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`); - const detail = await getDetails(spotifyUrl); - this.logger.debug( - `Initial tracks count from spotify-url-info: ${detail.tracks?.length || 0}`, - ); - - if (!detail.tracks || detail.tracks.length < 100) { - return detail.tracks || []; - } - - this.logger.debug( - 'Playlist has 100 or more tracks, using official Spotify API for pagination', - ); - const playlistId = this.getPlaylistId(spotifyUrl); this.logger.debug(`Extracted playlist ID: ${playlistId}`); + + const accessToken = await this.getAccessToken(); - try { - const accessToken = await this.getAccessToken(); + const allTracks = []; + let offset = 0; + let hasMoreTracks = true; - const allTracks = [...detail.tracks]; - let offset = 0; - let hasMoreTracks = true; - - allTracks.length = 0; - - while (hasMoreTracks) { + while (hasMoreTracks) { this.logger.debug( `Fetching tracks from Spotify API with offset ${offset}`, ); const response = await fetch( - `https://api.spotify.com/v1/playlists/${playlistId}/tracks?offset=${offset}&limit=100&fields=items(track(name,artists,preview_url)),next`, + `https://api.spotify.com/v1/playlists/${playlistId}/tracks?offset=${offset}&limit=100&fields=items(track(id,name,artists,preview_url,album(images))),next`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -153,14 +195,22 @@ export class SpotifyApiService { const pageTracks = data.items .map( (item: { - track: { name: any; artists: any[]; preview_url: any }; + track: { + id: string; + name: any; + artists: any[]; + preview_url: any; + album: { images: any[] }; + }; }) => { if (!item.track) return null; return { + id: item.track.id, name: item.track.name, artist: item.track.artists.map((a) => a.name).join(', '), previewUrl: item.track.preview_url, + coverUrl: item.track.album?.images?.[0]?.url || null, }; }, ) @@ -185,13 +235,6 @@ export class SpotifyApiService { `Total tracks retrieved from Spotify API: ${allTracks.length}`, ); return allTracks; - } catch (apiError) { - this.logger.error( - `Failed to get tracks from Spotify API: ${apiError.message}`, - ); - this.logger.debug('Falling back to initial tracks only'); - return detail.tracks || []; - } } catch (error) { this.logger.error(`Failed to get all playlist tracks: ${error.message}`); throw error; diff --git a/src/backend/src/shared/spotify.service.ts b/src/backend/src/shared/spotify.service.ts index 65b4c1f..c029f0e 100644 --- a/src/backend/src/shared/spotify.service.ts +++ b/src/backend/src/shared/spotify.service.ts @@ -12,6 +12,27 @@ export class SpotifyService { constructor(private readonly spotifyApiService: SpotifyApiService) {} + isTrackUrl(url: string): boolean { + return this.spotifyApiService.isTrackUrl(url); + } + + async getTrackDetail( + spotifyUrl: string, + ): Promise<{ name: string; artist: string; image: string }> { + this.logger.debug(`Get track ${spotifyUrl} on Spotify`); + try { + return await this.spotifyApiService.getTrackMetadata(spotifyUrl); + } catch (error) { + this.logger.error(`Error getting track details: ${error.message}`); + const detail = await getDetails(spotifyUrl); + return { + name: detail.preview.title, + artist: detail.preview.artist || 'Unknown Artist', + image: detail.preview.image, + }; + } + } + async getPlaylistDetail( spotifyUrl: string, ): Promise<{ name: string; tracks: any[]; image: string }> { diff --git a/src/backend/src/shared/utils.service.ts b/src/backend/src/shared/utils.service.ts index e7d1421..e4993fb 100644 --- a/src/backend/src/shared/utils.service.ts +++ b/src/backend/src/shared/utils.service.ts @@ -7,11 +7,17 @@ import { EnvironmentEnum } from '../environmentEnum'; export class UtilsService { constructor(private readonly configService: ConfigService) {} - getPlaylistFolderPath(name: string): string { + getRootDownloadsPath(): string { return resolve( __dirname, '..', this.configService.get(EnvironmentEnum.DOWNLOADS_PATH), + ); + } + + getPlaylistFolderPath(name: string): string { + return resolve( + this.getRootDownloadsPath(), this.stripFileIllegalChars(name), ); } diff --git a/src/backend/src/track/track.entity.ts b/src/backend/src/track/track.entity.ts index 39e4dea..4c327e3 100644 --- a/src/backend/src/track/track.entity.ts +++ b/src/backend/src/track/track.entity.ts @@ -33,6 +33,9 @@ export class TrackEntity { @Column({ nullable: true }) error?: string; + @Column({ nullable: true }) + coverUrl?: string; // Track-specific album art (overrides playlist coverUrl) + @Column({ default: Date.now() }) createdAt?: number; diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index 0834257..d5141a2 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -113,14 +113,20 @@ export class TrackService { if ( !track.name || !track.artist || - !track.playlist || - !track.playlist.coverUrl + !track.playlist ) { this.logger.error( - `Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}, coverUrl=${track.playlist?.coverUrl}`, + `Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`, ); return; } + // Use track's own coverUrl if available, otherwise fall back to playlist coverUrl + const coverUrl = track.coverUrl || track.playlist.coverUrl; + if (!coverUrl) { + this.logger.warn( + `No cover art available for track: ${track.artist} - ${track.name}`, + ); + } await this.update(track.id, { ...track, status: TrackStatusEnum.Downloading, @@ -129,12 +135,14 @@ export class TrackService { try { const folderName = this.getFolderName(track, track.playlist); await this.youtubeService.downloadAndFormat(track, folderName); - await this.youtubeService.addImage( - folderName, - track.playlist.coverUrl, - track.name, - track.artist, - ); + if (coverUrl) { + await this.youtubeService.addImage( + folderName, + coverUrl, + track.name, + track.artist, + ); + } } catch (err) { this.logger.error(err); error = String(err); @@ -155,6 +163,14 @@ export class TrackService { } getFolderName(track: TrackEntity, playlist: PlaylistEntity): string { + // Individual tracks (isTrack=true) go in root downloads folder, playlists in subfolders + if (playlist?.isTrack) { + return resolve( + this.utilsService.getRootDownloadsPath(), + this.getTrackFileName(track), + ); + } + const safePlaylistName = playlist?.name || 'unknown_playlist'; return resolve( this.utilsService.getPlaylistFolderPath(safePlaylistName), diff --git a/src/frontend/src/app/app.component.html b/src/frontend/src/app/app.component.html index 35b6deb..0bf2ba7 100644 --- a/src/frontend/src/app/app.component.html +++ b/src/frontend/src/app/app.component.html @@ -37,7 +37,28 @@ - + + +

No playlists

+
+ +
+
+
+

Songs

+
+ + +
+
+ + +

No songs

+
diff --git a/src/frontend/src/app/app.component.ts b/src/frontend/src/app/app.component.ts index 353676f..2370615 100644 --- a/src/frontend/src/app/app.component.ts +++ b/src/frontend/src/app/app.component.ts @@ -4,6 +4,7 @@ import {CommonModule, NgFor} from "@angular/common"; import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service"; import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component"; import {VersionService} from "./services/version.service"; +import {map} from "rxjs"; @Component({ selector: 'app-root', @@ -16,7 +17,8 @@ export class AppComponent { url = '' createLoading$ = this.playlistService.createLoading$; - playlists$ = this.playlistService.all$; + playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack))); + songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack))); version = this.versionService.getVersion(); constructor( diff --git a/src/frontend/src/app/components/playlist-box/playlist-box.component.html b/src/frontend/src/app/components/playlist-box/playlist-box.component.html index b11683c..6b8e2b8 100644 --- a/src/frontend/src/app/components/playlist-box/playlist-box.component.html +++ b/src/frontend/src/app/components/playlist-box/playlist-box.component.html @@ -16,11 +16,14 @@ -   + + +   +       diff --git a/src/frontend/src/app/models/playlist.ts b/src/frontend/src/app/models/playlist.ts index 380f3da..d10ee31 100644 --- a/src/frontend/src/app/models/playlist.ts +++ b/src/frontend/src/app/models/playlist.ts @@ -4,5 +4,6 @@ export interface Playlist { spotifyUrl: string; error?: string; active: boolean; + isTrack?: boolean; createdAt: number; } diff --git a/src/frontend/src/app/models/track.ts b/src/frontend/src/app/models/track.ts index a46f8fd..0ae4384 100644 --- a/src/frontend/src/app/models/track.ts +++ b/src/frontend/src/app/models/track.ts @@ -7,6 +7,7 @@ export interface Track { status: TrackStatusEnum; playlistId?: number; error?: string; + coverUrl?: string; } export enum TrackStatusEnum {