From b8309f2d01afef542d2f4eb306d5f9891d9e2f78 Mon Sep 17 00:00:00 2001 From: Raiper34 Date: Tue, 12 Aug 2025 21:38:06 +0000 Subject: [PATCH] feat(track): add track cover art into downloaded file close #24 --- package-lock.json | 24 +++++++++++++++++-- src/backend/package.json | 1 + src/backend/src/playlist/playlist.entity.ts | 3 +++ src/backend/src/playlist/playlist.service.ts | 10 ++++++-- src/backend/src/shared/spotify.service.ts | 8 +++++-- src/backend/src/shared/youtube.service.ts | 25 +++++++++++++++++++- src/backend/src/track/track.service.ts | 1 + src/frontend/angular.json | 3 +++ 8 files changed, 68 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4955ffd..7d3f35b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14482,6 +14482,25 @@ "node": ">=18" } }, + "node_modules/node-id3": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/node-id3/-/node-id3-0.2.9.tgz", + "integrity": "sha512-dSxhuxrkkGVRgUhDHFxdY0pilzOREcodO01HcZWfaRkCaPWGmo0dOgD8ygyL6ln4Iv4cmfRxAWn1WD9bIB9Bhw==", + "dependencies": { + "iconv-lite": "0.6.2" + } + }, + "node_modules/node-id3/node_modules/iconv-lite": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", + "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "dev": true, @@ -20475,7 +20494,7 @@ "license": "MIT" }, "src/backend": { - "version": "2.0.10", + "version": "2.0.11", "license": "UNLICENSED", "dependencies": { "@distube/ytdl-core": "^4.16.12", @@ -20494,6 +20513,7 @@ "bullmq": "^5.31.2", "fluent-ffmpeg": "^2.1.3", "isomorphic-unfetch": "^4.0.2", + "node-id3": "^0.2.9", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "spotify-url-info": "^3.2.18", @@ -20933,7 +20953,7 @@ } }, "src/frontend": { - "version": "2.0.10", + "version": "2.0.11", "dependencies": { "@angular/animations": "^19.0.6", "@angular/common": "^19.0.6", diff --git a/src/backend/package.json b/src/backend/package.json index 5c8b662..0f2e10f 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -37,6 +37,7 @@ "bullmq": "^5.31.2", "fluent-ffmpeg": "^2.1.3", "isomorphic-unfetch": "^4.0.2", + "node-id3": "^0.2.9", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "spotify-url-info": "^3.2.18", diff --git a/src/backend/src/playlist/playlist.entity.ts b/src/backend/src/playlist/playlist.entity.ts index 03702cb..26841a3 100644 --- a/src/backend/src/playlist/playlist.entity.ts +++ b/src/backend/src/playlist/playlist.entity.ts @@ -21,6 +21,9 @@ export class PlaylistEntity { @Column({ default: () => Date.now() }) createdAt?: number; + @Column({ nullable: true }) + coverUrl?: string; + @OneToMany(() => TrackEntity, (track) => track.playlist) tracks?: TrackEntity[]; } diff --git a/src/backend/src/playlist/playlist.service.ts b/src/backend/src/playlist/playlist.service.ts index 6e2f281..e727d19 100644 --- a/src/backend/src/playlist/playlist.service.ts +++ b/src/backend/src/playlist/playlist.service.ts @@ -52,7 +52,11 @@ export class PlaylistService { let playlist2Save: PlaylistEntity; try { detail = await this.spotifyService.getPlaylistDetail(playlist.spotifyUrl); - playlist2Save = { ...playlist, name: detail.name }; + playlist2Save = { + ...playlist, + name: detail.name, + coverUrl: detail.image, + }; this.createPlaylistFolderStructure(playlist2Save.name); } catch (err) { playlist2Save = { ...playlist, error: String(err) }; @@ -102,7 +106,9 @@ export class PlaylistService { for (const playlist of activePlaylists) { let tracks = []; try { - tracks = await this.spotifyService.getPlaylistTracks(playlist.spotifyUrl); + tracks = await this.spotifyService.getPlaylistTracks( + playlist.spotifyUrl, + ); this.createPlaylistFolderStructure(playlist.name); } catch (err) { await this.update(playlist.id, { ...playlist, error: String(err) }); diff --git a/src/backend/src/shared/spotify.service.ts b/src/backend/src/shared/spotify.service.ts index 5471580..89e1cee 100644 --- a/src/backend/src/shared/spotify.service.ts +++ b/src/backend/src/shared/spotify.service.ts @@ -9,10 +9,14 @@ export class SpotifyService { async getPlaylistDetail( spotifyUrl: string, - ): Promise<{ name: string; tracks: any[] }> { + ): Promise<{ name: string; tracks: any[]; image: string }> { this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`); const detail = await getDetails(spotifyUrl); - return { name: detail.preview.title, tracks: detail?.tracks ?? [] }; + return { + name: detail.preview.title, + tracks: detail?.tracks ?? [], + image: detail.preview.image, + }; } async getPlaylistTracks(spotifyUrl: string): Promise { diff --git a/src/backend/src/shared/youtube.service.ts b/src/backend/src/shared/youtube.service.ts index 048b9d3..ea9834c 100644 --- a/src/backend/src/shared/youtube.service.ts +++ b/src/backend/src/shared/youtube.service.ts @@ -8,6 +8,7 @@ import * as ffmpeg from 'fluent-ffmpeg'; import * as yts from 'yt-search'; import * as ytdl from '@distube/ytdl-core'; import { Readable } from 'stream'; +const NodeID3 = require('node-id3'); const META_DATA_TITLE = '-metadata'; const YT_SETTINGS: ytdl.downloadOptions = { @@ -34,7 +35,9 @@ export class YoutubeService { } downloadAndFormat(track: TrackEntity, folderName: string): Promise { - this.logger.debug(`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`); + this.logger.debug( + `Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`, + ); return new Promise((res, reject) => { ffmpeg(this.getYoutubeAudio(track.youtubeUrl, reject)) .outputOptions(...this.getFfmpegOptions(track.name, track.artist)) @@ -54,6 +57,26 @@ export class YoutubeService { }); } + async addImage(folderName: string, coverUrl: string): Promise { + if (coverUrl) { + const res = await fetch(coverUrl); + const arrayBuf = await res.arrayBuffer(); + const imageBuffer = Buffer.from(arrayBuf); + + NodeID3.write( + { + APIC: { + mime: 'image/jpeg', + type: { id: 3, name: 'front cover' }, + description: 'cover', + imageBuffer, + }, + }, + folderName, + ); + } + } + private getFfmpegOptions(name: string, artist: string): string[] { return [ META_DATA_TITLE, diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index 443054a..2f6e9db 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -118,6 +118,7 @@ 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); } catch (err) { this.logger.error(err); error = String(err); diff --git a/src/frontend/angular.json b/src/frontend/angular.json index 9e0d685..e39aab0 100644 --- a/src/frontend/angular.json +++ b/src/frontend/angular.json @@ -98,5 +98,8 @@ } } } + }, + "cli": { + "analytics": false } }