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:
Snowoball
2025-12-29 13:28:56 +00:00
committed by GitHub
parent a2ac579854
commit 80d93977ab
12 changed files with 226 additions and 49 deletions
@@ -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;
+58 -1
View File
@@ -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 {
+73 -30
View File
@@ -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;
+21
View File
@@ -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 -1
View File
@@ -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),
);
}
+3
View File
@@ -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;
+25 -9
View File
@@ -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),
+22 -1
View File
@@ -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>
+3 -1
View File
@@ -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>&nbsp;
<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>&nbsp;</i>
</ng-container>
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download failed tracks" (click)="retryFailed(playlist.id)"></i>&nbsp;
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>&nbsp;
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>&nbsp;
+1
View File
@@ -4,5 +4,6 @@ export interface Playlist {
spotifyUrl: string;
error?: string;
active: boolean;
isTrack?: boolean;
createdAt: number;
}
+1
View File
@@ -7,6 +7,7 @@ export interface Track {
status: TrackStatusEnum;
playlistId?: number;
error?: string;
coverUrl?: string;
}
export enum TrackStatusEnum {