diff --git a/package-lock.json b/package-lock.json index 14ee474..bf97ad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2581,6 +2581,33 @@ "node": ">=10.0.0" } }, + "node_modules/@distube/ytdl-core": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/@distube/ytdl-core/-/ytdl-core-4.13.5.tgz", + "integrity": "sha512-g+4UJIR/auAJbia7iB0aWvaJDbs22P53NySWa47b1NT4xMTDJYguxHFArPrvRkcJrb/AgKjv/XoSZGghpL0CJA==", + "dependencies": { + "http-cookie-agent": "^6.0.5", + "m3u8stream": "^0.8.6", + "miniget": "^4.2.3", + "sax": "^1.4.1", + "tough-cookie": "^4.1.4", + "undici": "^6.19.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/distubejs/ytdl-core?sponsor" + } + }, + "node_modules/@distube/ytdl-core/node_modules/undici": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.2.tgz", + "integrity": "sha512-JfjKqIauur3Q6biAtHJ564e3bWa8VvT+7cSiOJHFbX4Erv6CLGDpg8z+Fmg/1OI/47RA+GI2QZaF48SSaLvyBA==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.3.tgz", @@ -6341,7 +6368,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dev": true, "dependencies": { "debug": "^4.3.4" }, @@ -10493,6 +10519,29 @@ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "devOptional": true }, + "node_modules/http-cookie-agent": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-6.0.5.tgz", + "integrity": "sha512-sfZ8fDgDP3B1YB+teqSnAK1aPgBu8reUUGxSsndP2XnYN6cM29EURXWXZqQQiaRdor3B4QjpkUNfv21syaO4DA==", + "dependencies": { + "agent-base": "^7.1.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "tough-cookie": "^4.0.0", + "undici": "^5.11.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "undici": { + "optional": true + } + } + }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -14673,6 +14722,11 @@ "dev": true, "optional": true }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -14727,6 +14781,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -15005,8 +15064,7 @@ "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/resolve": { "version": "1.22.8", @@ -17162,6 +17220,36 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -17646,7 +17734,7 @@ "version": "6.18.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.18.0.tgz", "integrity": "sha512-nT8jjv/fE9Et1ilR6QoW8ingRTY2Pp4l2RUrdzV5Yz35RJDrtPc1DXvuNqcpsJSGIRHFdt3YKKktTzJA6r0fTA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=18.17" } @@ -17790,6 +17878,15 @@ "node": ">=6" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -19039,19 +19136,6 @@ "yt-search-video": "bin/mpv_video.sh" } }, - "node_modules/ytdl-core": { - "version": "4.11.5", - "resolved": "https://registry.npmjs.org/ytdl-core/-/ytdl-core-4.11.5.tgz", - "integrity": "sha512-27LwsW4n4nyNviRCO1hmr8Wr5J1wLLMawHCQvH8Fk0hiRqrxuIu028WzbJetiYH28K8XDbeinYW4/wcHQD1EXA==", - "dependencies": { - "m3u8stream": "^0.8.6", - "miniget": "^4.2.2", - "sax": "^1.1.3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/zone.js": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.7.tgz", @@ -19061,6 +19145,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@distube/ytdl-core": "^4.13.5", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", @@ -19076,8 +19161,7 @@ "rxjs": "^7.8.1", "spotify-url-info": "^3.2.15", "sqlite3": "^5.1.7", - "yt-search": "^2.11.0", - "ytdl-core": "^4.11.5" + "yt-search": "^2.11.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/spooty-be/package.json b/spooty-be/package.json index 19f4991..9720639 100644 --- a/spooty-be/package.json +++ b/spooty-be/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@distube/ytdl-core": "^4.13.5", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", @@ -35,8 +36,7 @@ "rxjs": "^7.8.1", "spotify-url-info": "^3.2.15", "sqlite3": "^5.1.7", - "yt-search": "^2.11.0", - "ytdl-core": "^4.11.5" + "yt-search": "^2.11.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/spooty-be/src/playlist/playlist.entity.ts b/spooty-be/src/playlist/playlist.entity.ts index e9c7f6d..399862d 100644 --- a/spooty-be/src/playlist/playlist.entity.ts +++ b/spooty-be/src/playlist/playlist.entity.ts @@ -7,12 +7,15 @@ export class PlaylistEntity { @PrimaryGeneratedColumn() id: number; - @Column() - name: string; + @Column({ nullable: true }) + name?: string; @Column() spotifyUrl: string; + @Column({ nullable: true }) + error?: string; + @Column({default: () => Date.now()}) createdAt?: number; diff --git a/spooty-be/src/playlist/playlist.model.ts b/spooty-be/src/playlist/playlist.model.ts index 69e26f7..64eb313 100644 --- a/spooty-be/src/playlist/playlist.model.ts +++ b/spooty-be/src/playlist/playlist.model.ts @@ -1,7 +1,8 @@ export interface PlaylistModel { id: number; - name: string; + name?: string; spotifyUrl: string; tracks?: any[]; //todo fix it + error?: string; createdAt?: number; } \ No newline at end of file diff --git a/spooty-be/src/playlist/playlist.service.ts b/spooty-be/src/playlist/playlist.service.ts index 5a2eb53..ec25252 100644 --- a/spooty-be/src/playlist/playlist.service.ts +++ b/spooty-be/src/playlist/playlist.service.ts @@ -33,10 +33,17 @@ export class PlaylistService { } async create(playlist: PlaylistEntity): Promise { - const details = await getDetails(playlist.spotifyUrl); - const savedPlaylist = await this.repository.save({...playlist, name: details.preview.title}); + let details; + let playlist2Save: PlaylistEntity; + try { + details = await getDetails(playlist.spotifyUrl); + playlist2Save = {...playlist, name: details.preview.title}; + } catch (err) { + playlist2Save = {...playlist, error: String(err)}; + } + const savedPlaylist = await this.repository.save(playlist2Save); this.io.emit('playlistNew', savedPlaylist); - for(let track of details.tracks) { + for(let track of details?.tracks ?? []) { await this.trackService.create({ artist: track.artist, song: track.name, diff --git a/spooty-be/src/track/track.service.ts b/spooty-be/src/track/track.service.ts index e217e7d..c7a2f13 100644 --- a/spooty-be/src/track/track.service.ts +++ b/spooty-be/src/track/track.service.ts @@ -6,7 +6,7 @@ import {PlaylistEntity} from "../playlist/playlist.entity"; import {Interval} from "@nestjs/schedule"; import {TrackStatusEnum} from "./track.model"; import * as yts from 'yt-search'; -import * as ytdl from 'ytdl-core'; +import * as ytdl from '@distube/ytdl-core'; import * as fs from 'fs'; import {ConfigService} from "@nestjs/config"; import {resolve} from "path"; diff --git a/spooty-fe/src/app/app.component.html b/spooty-fe/src/app/app.component.html index 59f2ed1..ee06d43 100644 --- a/spooty-fe/src/app/app.component.html +++ b/spooty-fe/src/app/app.component.html @@ -14,7 +14,11 @@

Download

-
@@ -24,22 +28,7 @@

Playlists

 
- -
-

- -   - {{playlist.name}} - - -

- - - -
+ diff --git a/spooty-fe/src/app/app.component.scss b/spooty-fe/src/app/app.component.scss index 61f6c33..e69de29 100644 --- a/spooty-fe/src/app/app.component.scss +++ b/spooty-fe/src/app/app.component.scss @@ -1,18 +0,0 @@ -.panel-heading { - padding: 5px 10px; - border-radius: 10px; -} - -.panel { - margin: 5px 0; -} - -.loading-animation { - animation: spin 0.3s linear infinite; -} - -@keyframes spin { - 100% { - transform:rotate(360deg); - } -} diff --git a/spooty-fe/src/app/app.component.ts b/spooty-fe/src/app/app.component.ts index cd600a8..492e076 100644 --- a/spooty-fe/src/app/app.component.ts +++ b/spooty-fe/src/app/app.component.ts @@ -1,23 +1,22 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import {FormsModule} from "@angular/forms"; -import {CommonModule, NgFor, NgSwitch, NgSwitchCase} from "@angular/common"; +import {CommonModule, NgFor} from "@angular/common"; import {PlaylistService} from "./services/playlist.service"; -import {TrackListComponent} from "./components/track-list/track-list.component"; +import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component"; @Component({ selector: 'app-root', standalone: true, - imports: [CommonModule, RouterOutlet, FormsModule, NgFor, NgSwitch, NgSwitchCase, TrackListComponent], + imports: [CommonModule, RouterOutlet, FormsModule, NgFor, PlaylistBoxComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) export class AppComponent { url = '' - playlists$ = this.playlistService.all$; - loading$ = this.playlistService.loading$; createLoading$ = this.playlistService.createLoading$; + playlists$ = this.playlistService.all$; constructor(private readonly playlistService: PlaylistService) { this.fetchPlaylists(); @@ -28,10 +27,7 @@ export class AppComponent { } download(): void { - this.playlistService.create(this.url); - } - - toggleCollapse(playlistId: number): void { - this.playlistService.toggleCollapsed(playlistId); + this.url && this.playlistService.create(this.url); + this.url = ''; } } diff --git a/spooty-fe/src/app/components/playlist-box/playlist-box.component.html b/spooty-fe/src/app/components/playlist-box/playlist-box.component.html new file mode 100644 index 0000000..2534bdb --- /dev/null +++ b/spooty-fe/src/app/components/playlist-box/playlist-box.component.html @@ -0,0 +1,21 @@ +
+

+ +   + {{playlist.error}} + + {{playlist.name}} +   + + + + {{trackCompletedCount$ | async}}/{{trackCount$ | async}}  + +

+ + + +
diff --git a/spooty-fe/src/app/components/playlist-box/playlist-box.component.scss b/spooty-fe/src/app/components/playlist-box/playlist-box.component.scss new file mode 100644 index 0000000..58891b6 --- /dev/null +++ b/spooty-fe/src/app/components/playlist-box/playlist-box.component.scss @@ -0,0 +1,8 @@ +.panel-heading { + padding: 5px 10px; + border-radius: 10px; +} + +.panel { + margin: 5px 0; +} diff --git a/spooty-fe/src/app/components/playlist-box/playlist-box.component.spec.ts b/spooty-fe/src/app/components/playlist-box/playlist-box.component.spec.ts new file mode 100644 index 0000000..a5807bb --- /dev/null +++ b/spooty-fe/src/app/components/playlist-box/playlist-box.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PlaylistBoxComponent } from './playlist-box.component'; + +describe('PlaylistListComponent', () => { + let component: PlaylistBoxComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PlaylistBoxComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PlaylistBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/spooty-fe/src/app/components/playlist-box/playlist-box.component.ts b/spooty-fe/src/app/components/playlist-box/playlist-box.component.ts new file mode 100644 index 0000000..9c6262b --- /dev/null +++ b/spooty-fe/src/app/components/playlist-box/playlist-box.component.ts @@ -0,0 +1,40 @@ +import {Component, Input} from '@angular/core'; +import {AsyncPipe, CommonModule, NgForOf, NgIf} from "@angular/common"; +import {TrackListComponent} from "../track-list/track-list.component"; +import {Playlist, PlaylistService, PlaylistUi} from "../../services/playlist.service"; +import {Observable} from "rxjs"; + +@Component({ + selector: 'app-playlist-box', + standalone: true, + imports: [ + CommonModule, + AsyncPipe, + NgForOf, + NgIf, + TrackListComponent + ], + templateUrl: './playlist-box.component.html', + styleUrl: './playlist-box.component.scss' +}) +export class PlaylistBoxComponent { + + @Input() set playlist(val: Playlist & PlaylistUi) { + this._playlist = val; + this.trackCount$ = this.playlistService.getTrackCount(val.id); + this.trackCompletedCount$ = this.playlistService.getCompletedTrackCount(val.id); + } + get playlist(): Playlist & PlaylistUi { + return this._playlist; + } + _playlist!: Playlist & PlaylistUi; + trackCount$!: Observable; + trackCompletedCount$!: Observable; + + constructor(private readonly playlistService: PlaylistService) { } + + + toggleCollapse(playlistId: number): void { + this.playlistService.toggleCollapsed(playlistId); + } +} diff --git a/spooty-fe/src/app/services/playlist.service.ts b/spooty-fe/src/app/services/playlist.service.ts index 79260f8..e15082e 100644 --- a/spooty-fe/src/app/services/playlist.service.ts +++ b/spooty-fe/src/app/services/playlist.service.ts @@ -10,7 +10,7 @@ import { withUIEntities } from "@ngneat/elf-entities"; import {joinRequestResult, trackRequestResult} from "@ngneat/elf-requests"; -import {map, tap} from "rxjs"; +import {map, Observable, tap} from "rxjs"; import {Track, TrackService} from "./track.service"; import {Socket} from "ngx-socket-io"; @@ -20,8 +20,9 @@ const CREATE_LOADING = 'CREATE_LOADING'; export interface Playlist { id: number; - name: string; + name?: string; spotifyUrl: string; + error?: string; createdAt: number; } @@ -61,6 +62,14 @@ export class PlaylistService { ); } + getTrackCount(id: number): Observable { + return this.trackService.getAllByPlaylist(id).pipe(map(data => data.length)); + } + + getCompletedTrackCount(id: number): Observable { + return this.trackService.getCompletedByPlaylist(id).pipe(map(data => data.length)); + } + fetch(): void { this.http.get(ENDPOINT).pipe( tap((data: Playlist[]) => this.store.update( diff --git a/spooty-fe/src/app/services/track.service.ts b/spooty-fe/src/app/services/track.service.ts index a9d4183..73aa489 100644 --- a/spooty-fe/src/app/services/track.service.ts +++ b/spooty-fe/src/app/services/track.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import {createStore} from "@ngneat/elf"; import {selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities"; import {Socket} from "ngx-socket-io"; -import {tap} from "rxjs"; +import {map, Observable, tap} from "rxjs"; import {trackRequestResult} from "@ngneat/elf-requests"; import {HttpClient} from "@angular/common/http"; @@ -39,10 +39,14 @@ export class TrackService { withEntities(), ); - getAllByPlaylist(id: number) { + getAllByPlaylist(id: number): Observable { return this.store.pipe(selectManyByPredicate(({playlistId}) => playlistId === id)) } + getCompletedByPlaylist(id: number): Observable { + return this.getAllByPlaylist(id).pipe(map(data => data.filter(item => item.status === TrackStatusEnum.Completed))); + } + constructor( private readonly http: HttpClient, private readonly socket: Socket,