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 })
|
@Column({ default: false })
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
isTrack?: boolean; // True for individual tracks, false for actual playlists
|
||||||
|
|
||||||
@Column({ default: () => Date.now() })
|
@Column({ default: () => Date.now() })
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,59 @@ export class PlaylistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(playlist: PlaylistEntity): Promise<void> {
|
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 detail: { tracks: any; name: any; image: any };
|
||||||
let playlist2Save: PlaylistEntity;
|
let playlist2Save: PlaylistEntity;
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +153,7 @@ export class PlaylistService {
|
|||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
spotifyUrl: track.previewUrl || null,
|
spotifyUrl: track.previewUrl || null,
|
||||||
|
coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover
|
||||||
},
|
},
|
||||||
savedPlaylist,
|
savedPlaylist,
|
||||||
);
|
);
|
||||||
@@ -158,7 +212,10 @@ export class PlaylistService {
|
|||||||
|
|
||||||
@Interval(3_600_000)
|
@Interval(3_600_000)
|
||||||
async checkActivePlaylists(): Promise<void> {
|
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) {
|
for (const playlist of activePlaylists) {
|
||||||
let tracks = [];
|
let tracks = [];
|
||||||
try {
|
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(
|
async getPlaylistMetadata(
|
||||||
spotifyUrl: string,
|
spotifyUrl: string,
|
||||||
): Promise<{ name: string; image: string }> {
|
): Promise<{ name: string; image: string }> {
|
||||||
@@ -95,38 +153,22 @@ export class SpotifyApiService {
|
|||||||
try {
|
try {
|
||||||
this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`);
|
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);
|
const playlistId = this.getPlaylistId(spotifyUrl);
|
||||||
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
||||||
|
|
||||||
|
const accessToken = await this.getAccessToken();
|
||||||
|
|
||||||
try {
|
const allTracks = [];
|
||||||
const accessToken = await this.getAccessToken();
|
let offset = 0;
|
||||||
|
let hasMoreTracks = true;
|
||||||
|
|
||||||
const allTracks = [...detail.tracks];
|
while (hasMoreTracks) {
|
||||||
let offset = 0;
|
|
||||||
let hasMoreTracks = true;
|
|
||||||
|
|
||||||
allTracks.length = 0;
|
|
||||||
|
|
||||||
while (hasMoreTracks) {
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Fetching tracks from Spotify API with offset ${offset}`,
|
`Fetching tracks from Spotify API with offset ${offset}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await fetch(
|
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: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
@@ -153,14 +195,22 @@ export class SpotifyApiService {
|
|||||||
const pageTracks = data.items
|
const pageTracks = data.items
|
||||||
.map(
|
.map(
|
||||||
(item: {
|
(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;
|
if (!item.track) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: item.track.id,
|
||||||
name: item.track.name,
|
name: item.track.name,
|
||||||
artist: item.track.artists.map((a) => a.name).join(', '),
|
artist: item.track.artists.map((a) => a.name).join(', '),
|
||||||
previewUrl: item.track.preview_url,
|
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}`,
|
`Total tracks retrieved from Spotify API: ${allTracks.length}`,
|
||||||
);
|
);
|
||||||
return allTracks;
|
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) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get all playlist tracks: ${error.message}`);
|
this.logger.error(`Failed to get all playlist tracks: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -12,6 +12,27 @@ export class SpotifyService {
|
|||||||
|
|
||||||
constructor(private readonly spotifyApiService: SpotifyApiService) {}
|
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(
|
async getPlaylistDetail(
|
||||||
spotifyUrl: string,
|
spotifyUrl: string,
|
||||||
): Promise<{ name: string; tracks: any[]; image: string }> {
|
): Promise<{ name: string; tracks: any[]; image: string }> {
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import { EnvironmentEnum } from '../environmentEnum';
|
|||||||
export class UtilsService {
|
export class UtilsService {
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
getPlaylistFolderPath(name: string): string {
|
getRootDownloadsPath(): string {
|
||||||
return resolve(
|
return resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'..',
|
'..',
|
||||||
this.configService.get<string>(EnvironmentEnum.DOWNLOADS_PATH),
|
this.configService.get<string>(EnvironmentEnum.DOWNLOADS_PATH),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaylistFolderPath(name: string): string {
|
||||||
|
return resolve(
|
||||||
|
this.getRootDownloadsPath(),
|
||||||
this.stripFileIllegalChars(name),
|
this.stripFileIllegalChars(name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
|
||||||
|
|
||||||
@Column({ default: Date.now() })
|
@Column({ default: Date.now() })
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -113,14 +113,20 @@ export class TrackService {
|
|||||||
if (
|
if (
|
||||||
!track.name ||
|
!track.name ||
|
||||||
!track.artist ||
|
!track.artist ||
|
||||||
!track.playlist ||
|
!track.playlist
|
||||||
!track.playlist.coverUrl
|
|
||||||
) {
|
) {
|
||||||
this.logger.error(
|
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;
|
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, {
|
await this.update(track.id, {
|
||||||
...track,
|
...track,
|
||||||
status: TrackStatusEnum.Downloading,
|
status: TrackStatusEnum.Downloading,
|
||||||
@@ -129,12 +135,14 @@ export class TrackService {
|
|||||||
try {
|
try {
|
||||||
const folderName = this.getFolderName(track, track.playlist);
|
const folderName = this.getFolderName(track, track.playlist);
|
||||||
await this.youtubeService.downloadAndFormat(track, folderName);
|
await this.youtubeService.downloadAndFormat(track, folderName);
|
||||||
await this.youtubeService.addImage(
|
if (coverUrl) {
|
||||||
folderName,
|
await this.youtubeService.addImage(
|
||||||
track.playlist.coverUrl,
|
folderName,
|
||||||
track.name,
|
coverUrl,
|
||||||
track.artist,
|
track.name,
|
||||||
);
|
track.artist,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
error = String(err);
|
error = String(err);
|
||||||
@@ -155,6 +163,14 @@ export class TrackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
|
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';
|
const safePlaylistName = playlist?.name || 'unknown_playlist';
|
||||||
return resolve(
|
return resolve(
|
||||||
this.utilsService.getPlaylistFolderPath(safePlaylistName),
|
this.utilsService.getPlaylistFolderPath(safePlaylistName),
|
||||||
|
|||||||
@@ -37,7 +37,28 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {CommonModule, NgFor} from "@angular/common";
|
|||||||
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
||||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
||||||
import {VersionService} from "./services/version.service";
|
import {VersionService} from "./services/version.service";
|
||||||
|
import {map} from "rxjs";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -16,7 +17,8 @@ export class AppComponent {
|
|||||||
|
|
||||||
url = ''
|
url = ''
|
||||||
createLoading$ = this.playlistService.createLoading$;
|
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();
|
version = this.versionService.getVersion();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<i class="fa-solid is-clickable hover-icon"
|
<ng-container *ngIf="!playlist.isTrack">
|
||||||
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
<i class="fa-solid is-clickable hover-icon"
|
||||||
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
||||||
(click)="toggleActive(playlist.id, playlist.active)"
|
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
||||||
></i>
|
(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-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 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>
|
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export interface Playlist {
|
|||||||
spotifyUrl: string;
|
spotifyUrl: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
isTrack?: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Track {
|
|||||||
status: TrackStatusEnum;
|
status: TrackStatusEnum;
|
||||||
playlistId?: number;
|
playlistId?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
coverUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TrackStatusEnum {
|
export enum TrackStatusEnum {
|
||||||
|
|||||||
Reference in New Issue
Block a user