feat(track): allow download of individual track and fix album art
* allow download of individual track * fix album art close #29
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -48,6 +48,59 @@ export class PlaylistService {
|
||||
}
|
||||
|
||||
async create(playlist: PlaylistEntity): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
try {
|
||||
const accessToken = await this.getAccessToken();
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
const allTracks = [...detail.tracks];
|
||||
let offset = 0;
|
||||
let hasMoreTracks = true;
|
||||
const allTracks = [];
|
||||
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;
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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<string>(EnvironmentEnum.DOWNLOADS_PATH),
|
||||
);
|
||||
}
|
||||
|
||||
getPlaylistFolderPath(name: string): string {
|
||||
return resolve(
|
||||
this.getRootDownloadsPath(),
|
||||
this.stripFileIllegalChars(name),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -37,7 +37,28 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<app-playlist-box *ngFor="let playlist of playlists$ | async" [playlist]="playlist"></app-playlist-box>
|
||||
<ng-container *ngIf="playlists$ | async as playlists">
|
||||
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
||||
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="box">
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<p class="subtitle">Songs</p>
|
||||
<div class="buttons has-addons">
|
||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="songs$ | async as songs">
|
||||
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
||||
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
</a>
|
||||
</span>
|
||||
<span>
|
||||
<i class="fa-solid is-clickable hover-icon"
|
||||
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
||||
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
||||
(click)="toggleActive(playlist.id, playlist.active)"
|
||||
></i>
|
||||
<ng-container *ngIf="!playlist.isTrack">
|
||||
<i class="fa-solid is-clickable hover-icon"
|
||||
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
||||
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
||||
(click)="toggleActive(playlist.id, playlist.active)"
|
||||
></i>
|
||||
<i> </i>
|
||||
</ng-container>
|
||||
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download failed tracks" (click)="retryFailed(playlist.id)"></i>
|
||||
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>
|
||||
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>
|
||||
|
||||
@@ -4,5 +4,6 @@ export interface Playlist {
|
||||
spotifyUrl: string;
|
||||
error?: string;
|
||||
active: boolean;
|
||||
isTrack?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Track {
|
||||
status: TrackStatusEnum;
|
||||
playlistId?: number;
|
||||
error?: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
export enum TrackStatusEnum {
|
||||
|
||||
Reference in New Issue
Block a user