From 2f0e4626f8dadd7aae35b99ff080fc3e2c81cc2e Mon Sep 17 00:00:00 2001 From: raiper34 Date: Wed, 10 Jul 2024 23:08:05 +0200 Subject: [PATCH] websockets for tracks and ui improvement --- package-lock.json | 157 +++++++++++++++--- spooty-be/package.json | 2 + spooty-be/src/playlist/playlist.entity.ts | 3 + spooty-be/src/playlist/playlist.model.ts | 1 + spooty-be/src/playlist/playlist.service.ts | 1 + spooty-be/src/track/track.controller.ts | 24 +-- spooty-be/src/track/track.entity.ts | 8 +- spooty-be/src/track/track.model.ts | 5 + spooty-be/src/track/track.service.ts | 67 ++++++-- spooty-fe/package.json | 1 + spooty-fe/proxy.conf.json | 5 + spooty-fe/src/app/app.component.html | 25 +-- spooty-fe/src/app/app.component.scss | 4 - spooty-fe/src/app/app.component.ts | 3 +- spooty-fe/src/app/app.config.ts | 6 +- .../track-list/track-list.component.html | 24 +++ .../track-list/track-list.component.scss | 3 + .../track-list/track-list.component.spec.ts | 23 +++ .../track-list/track-list.component.ts | 26 +++ .../src/app/services/playlist.service.ts | 24 ++- .../src/app/services/track.service.spec.ts | 16 ++ spooty-fe/src/app/services/track.service.ts | 62 +++++++ 22 files changed, 389 insertions(+), 101 deletions(-) create mode 100644 spooty-fe/src/app/components/track-list/track-list.component.html create mode 100644 spooty-fe/src/app/components/track-list/track-list.component.scss create mode 100644 spooty-fe/src/app/components/track-list/track-list.component.spec.ts create mode 100644 spooty-fe/src/app/components/track-list/track-list.component.ts create mode 100644 spooty-fe/src/app/services/track.service.spec.ts create mode 100644 spooty-fe/src/app/services/track.service.ts diff --git a/package-lock.json b/package-lock.json index 9d45304..14ee474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,7 @@ "workspaces": [ "spooty-be", "spooty-fe" - ], - "dependencies": { - "@ngneat/elf": "^2.5.1", - "@ngneat/elf-cli-ng": "^1.0.0", - "@ngneat/elf-devtools": "^1.3.0", - "@ngneat/elf-entities": "^5.0.2", - "@ngneat/elf-pagination": "^1.1.0", - "@ngneat/elf-persist-state": "^1.2.1", - "@ngneat/elf-requests": "^1.9.2", - "@ngneat/elf-state-history": "^1.4.0" - } + ] }, "node_modules/@ampproject/remapping": { "version": "2.3.0", @@ -4391,6 +4381,24 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/@nestjs/platform-socket.io": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.10.tgz", + "integrity": "sha512-LRd+nGWhUu9hND1txCLPZd78Hea+qKJVENb+c9aDU04T24GRjsInDF2RANMR16JLQFcI9mclktDWX4plE95SHg==", + "dependencies": { + "socket.io": "4.7.5", + "tslib": "2.6.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schedule": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.2.tgz", @@ -4613,6 +4621,28 @@ "typeorm": "^0.3.0" } }, + "node_modules/@nestjs/websockets": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.10.tgz", + "integrity": "sha512-F/fhAC0ylAhjfCZj4Xrgc0yTJ/qltooDCa+fke7BFZLofLmE0yj7WzBVrBHsk/46kppyRcs5XrYjIQLqcDze8g==", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.6.3" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@ngneat/elf": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@ngneat/elf/-/elf-2.5.1.tgz", @@ -5386,8 +5416,7 @@ "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, "node_modules/@sqltools/formatter": { "version": "1.2.5", @@ -5547,8 +5576,7 @@ "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", - "dev": true + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" }, "node_modules/@types/cookiejar": { "version": "2.1.5", @@ -5560,7 +5588,6 @@ "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -5706,7 +5733,6 @@ "version": "20.14.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.8.tgz", "integrity": "sha512-DO+2/jZinXfROG7j7WKFn/3C6nFwxy2lLpgLjEXJz+0XKphZlTLJ14mo8Vfg8X5BWN6XjyESXq+LcYdT7tR3bA==", - "devOptional": true, "dependencies": { "undici-types": "~5.26.4" } @@ -6945,7 +6971,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, "engines": { "node": "^4.5.0 || >= 5.9" } @@ -8075,6 +8100,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/core-js": { + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz", + "integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.37.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", @@ -8748,7 +8783,6 @@ "version": "6.5.5", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", - "dev": true, "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -8765,11 +8799,22 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", + "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.0.0" + } + }, "node_modules/engine.io-parser": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", - "dev": true, "engines": { "node": ">=10.0.0" } @@ -8778,7 +8823,6 @@ "version": "0.4.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -13281,6 +13325,29 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, + "node_modules/ngx-socket-io": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/ngx-socket-io/-/ngx-socket-io-4.7.0.tgz", + "integrity": "sha512-2uwc5OWPOXM2EmiTrsMpWKm97gE6K3Zuzo1JeScncWQ3r/VxLQuGismVXtGF4Dv3h1Orvurt3ISiNztqOuL3gQ==", + "dependencies": { + "core-js": "^3.0.0", + "reflect-metadata": "^0.1.10", + "socket.io": "^4.7.2", + "socket.io-client": "^4.7.2", + "tslib": "^2.3.0", + "zone.js": "~0.14.0" + }, + "peerDependencies": { + "@angular/common": "^18.0.0", + "@angular/core": "^18.0.0", + "rxjs": "^7.0.0" + } + }, + "node_modules/ngx-socket-io/node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==" + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", @@ -13722,6 +13789,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -15800,7 +15875,6 @@ "version": "4.7.5", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", - "dev": true, "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", @@ -15818,17 +15892,29 @@ "version": "2.5.5", "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", - "dev": true, "dependencies": { "debug": "~4.3.4", "ws": "~8.17.1" } }, + "node_modules/socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", - "dev": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" @@ -17568,8 +17654,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "devOptional": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unfetch": { "version": "5.0.0", @@ -18842,7 +18927,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, "engines": { "node": ">=10.0.0" }, @@ -18859,6 +18943,14 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -18973,9 +19065,11 @@ "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-socket.io": "^10.3.10", "@nestjs/schedule": "^4.0.2", "@nestjs/serve-static": "^4.0.2", "@nestjs/typeorm": "^10.0.2", + "@nestjs/websockets": "^10.3.10", "@types/yt-search": "^2.10.3", "isomorphic-unfetch": "^4.0.2", "reflect-metadata": "^0.2.0", @@ -19021,7 +19115,16 @@ "@angular/platform-browser-dynamic": "^18.0.0", "@angular/router": "^18.0.0", "@fortawesome/fontawesome-free": "^6.5.2", + "@ngneat/elf": "^2.5.1", + "@ngneat/elf-cli-ng": "^1.0.0", + "@ngneat/elf-devtools": "^1.3.0", + "@ngneat/elf-entities": "^5.0.2", + "@ngneat/elf-pagination": "^1.1.0", + "@ngneat/elf-persist-state": "^1.2.1", + "@ngneat/elf-requests": "^1.9.2", + "@ngneat/elf-state-history": "^1.4.0", "bulma": "^1.0.1", + "ngx-socket-io": "^4.7.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.3" diff --git a/spooty-be/package.json b/spooty-be/package.json index 103ffc0..19f4991 100644 --- a/spooty-be/package.json +++ b/spooty-be/package.json @@ -24,9 +24,11 @@ "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-socket.io": "^10.3.10", "@nestjs/schedule": "^4.0.2", "@nestjs/serve-static": "^4.0.2", "@nestjs/typeorm": "^10.0.2", + "@nestjs/websockets": "^10.3.10", "@types/yt-search": "^2.10.3", "isomorphic-unfetch": "^4.0.2", "reflect-metadata": "^0.2.0", diff --git a/spooty-be/src/playlist/playlist.entity.ts b/spooty-be/src/playlist/playlist.entity.ts index 845b059..e9c7f6d 100644 --- a/spooty-be/src/playlist/playlist.entity.ts +++ b/spooty-be/src/playlist/playlist.entity.ts @@ -13,6 +13,9 @@ export class PlaylistEntity { @Column() spotifyUrl: string; + @Column({default: () => Date.now()}) + createdAt?: number; + @OneToMany(() => TrackEntity, track => track.playlist) tracks?: TrackEntity[]; } \ No newline at end of file diff --git a/spooty-be/src/playlist/playlist.model.ts b/spooty-be/src/playlist/playlist.model.ts index 3fc0c4c..69e26f7 100644 --- a/spooty-be/src/playlist/playlist.model.ts +++ b/spooty-be/src/playlist/playlist.model.ts @@ -3,4 +3,5 @@ export interface PlaylistModel { name: string; spotifyUrl: string; tracks?: any[]; //todo fix it + 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 8237ce8..6d4f91f 100644 --- a/spooty-be/src/playlist/playlist.service.ts +++ b/spooty-be/src/playlist/playlist.service.ts @@ -8,6 +8,7 @@ const { getData, getPreview, getTracks, getDetails } = require('spotify-url-info @Injectable() export class PlaylistService { + constructor( @InjectRepository(PlaylistEntity) private repository: Repository, diff --git a/spooty-be/src/track/track.controller.ts b/spooty-be/src/track/track.controller.ts index 4b2fb6f..993b88a 100644 --- a/spooty-be/src/track/track.controller.ts +++ b/spooty-be/src/track/track.controller.ts @@ -1,7 +1,6 @@ -import {Body, Controller, Delete, Get, Param, Post, Put, Res, StreamableFile} from '@nestjs/common'; +import {Controller, Get, Param, Res, StreamableFile} from '@nestjs/common'; import {TrackService} from "./track.service"; import {TrackModel} from "./track.model"; -import {TrackEntity} from "./track.entity"; import {createReadStream} from "fs"; import { resolve } from 'path'; import type { Response } from 'express'; @@ -19,24 +18,9 @@ export class TrackController { return this.service.findAll(); } - @Get(':id') - get(@Param('id') id: number): Promise { - return this.service.findOne(id); - } - - @Post() - create(@Body() track: TrackModel): Promise { - return this.service.create(track as TrackEntity); - } - - @Put(':id') - update(@Param('id') id: number, @Body() track: TrackModel): Promise { - return this.service.update(id, track as TrackEntity); - } - - @Delete(':id') - delete(@Param('id') id: number): Promise { - return this.service.remove(id); + @Get('playlist/:id') + getAllByPlaylist(@Param('id') playlistId: number): Promise { + return this.service.getAllByPlaylist(playlistId); } @Get('download/:id') diff --git a/spooty-be/src/track/track.entity.ts b/spooty-be/src/track/track.entity.ts index da63e0c..a92a66b 100644 --- a/spooty-be/src/track/track.entity.ts +++ b/spooty-be/src/track/track.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; +import {Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, UpdateDateColumn} from 'typeorm'; import {PlaylistEntity} from "../playlist/playlist.entity"; import {TrackStatusEnum} from "./track.model"; @@ -23,6 +23,12 @@ export class TrackEntity { @Column({default: TrackStatusEnum.New}) status?: TrackStatusEnum; + @Column({ nullable: true }) + error?: string; + + @Column({default: Date.now()}) + createdAt?: number; + @ManyToOne(() => PlaylistEntity, playlist => playlist.tracks) playlist?: PlaylistEntity; } \ No newline at end of file diff --git a/spooty-be/src/track/track.model.ts b/spooty-be/src/track/track.model.ts index 77b08d9..5caf195 100644 --- a/spooty-be/src/track/track.model.ts +++ b/spooty-be/src/track/track.model.ts @@ -8,10 +8,15 @@ export interface TrackModel { youtubeUrl?: string; status?: TrackStatusEnum, playlist?: PlaylistModel; + createdAt?: number; + error?: string; } export enum TrackStatusEnum { New, + Searching, Queued, + Downloading, Completed, + Error, } \ No newline at end of file diff --git a/spooty-be/src/track/track.service.ts b/spooty-be/src/track/track.service.ts index 224e29f..de3ba5a 100644 --- a/spooty-be/src/track/track.service.ts +++ b/spooty-be/src/track/track.service.ts @@ -10,9 +10,16 @@ import * as ytdl from 'ytdl-core'; import * as fs from 'fs'; import {ConfigService} from "@nestjs/config"; import {resolve} from "path"; +import {WebSocketGateway, WebSocketServer} from "@nestjs/websockets"; +import {Server} from "socket.io"; +import {SearchResult} from "yt-search"; +@WebSocketGateway() @Injectable() export class TrackService { + + @WebSocketServer() io: Server; + constructor( @InjectRepository(TrackEntity) private repository: Repository, @@ -23,6 +30,10 @@ export class TrackService { return this.repository.find({where: criteria}); } + getAllByPlaylist(id: number): Promise { + return this.repository.find({where: {playlist: {id}}}); + } + findOne(id: number): Promise { return this.repository.findOneBy({ id }); } @@ -31,41 +42,65 @@ export class TrackService { await this.repository.delete(id); } - async create(track: TrackEntity, playlist?: PlaylistEntity): Promise { - return this.repository.save({...track, playlist}); + async create(track: TrackEntity, playlist?: PlaylistEntity): Promise { + const savedTrack = await this.repository.save({...track, playlist}); + this.io.emit('trackNew', {track: savedTrack, playlistId: playlist.id}); } async update(id: number, track: TrackEntity): Promise { await this.repository.update(id, track); + this.io.emit('track', track); } - @Interval(10000) + @Interval(1000) async findOnYoutube() { const newTracks = await this.findAll({status: TrackStatusEnum.New}); + for (let track of newTracks) { + await this.update(track.id, {...track, status: TrackStatusEnum.Searching}); + } for(let track of newTracks) { - const youtubeResult = await yts(`${track.artist} - ${track.song}`); - await this.update(track.id, { - ...track, - youtubeUrl: youtubeResult.videos[0].url, - status: TrackStatusEnum.Queued - }); + let youtubeResult: SearchResult; + let updatedTrack: TrackEntity; + try { + youtubeResult = await yts(`${track.artist} - ${track.song}`); + updatedTrack = {...track, youtubeUrl: youtubeResult.videos[0].url, status: TrackStatusEnum.Queued}; + } catch (err) { + updatedTrack = {...track, error: String(err), status: TrackStatusEnum.Error}; + } + await this.update(track.id, updatedTrack); } } - @Interval(15000) + @Interval(1000) async download() { const queuedTracks = await this.findAll({status: TrackStatusEnum.Queued}); + for (let track of queuedTracks) { + await this.update(track.id, {...track, status: TrackStatusEnum.Downloading}); + } for(let track of queuedTracks) { - await this.youtubeDownload(track); - await this.update(track.id, {...track, status: TrackStatusEnum.Completed}) + let error: string; + try { + await this.youtubeDownload(track); + } catch(err) { + console.log(err); + error = String(err); + } + const updatedTrack = { + ...track, + status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed, + ...(error ? {error} : {}), + }; + await this.update(track.id, updatedTrack); } } private youtubeDownload(track: TrackEntity): Promise { - return new Promise((res, _reject) => - ytdl(track.youtubeUrl, {quality: "highestaudio", filter: "audioonly"}).pipe( - fs.createWriteStream(resolve(__dirname, '..', this.configService.get('DOWNLOADS'), `${track.artist} - ${track.song.replace('/', '')}.mp3`)) - .on('finish', () => res()) + return new Promise((res, reject) => + ytdl(track.youtubeUrl, {quality: "highestaudio", filter: "audioonly"}) + .on('error', (err) => reject(err)).pipe( + fs.createWriteStream(resolve(__dirname, '..', this.configService.get('DOWNLOADS'), `${track.artist} - ${track.song.replace('/', '')}.mp3`)) + .on('finish', () => res()) + .on('error', (err) => reject(err)) ) ); } diff --git a/spooty-fe/package.json b/spooty-fe/package.json index e10577a..25b6f52 100644 --- a/spooty-fe/package.json +++ b/spooty-fe/package.json @@ -29,6 +29,7 @@ "@ngneat/elf-requests": "^1.9.2", "@ngneat/elf-state-history": "^1.4.0", "bulma": "^1.0.1", + "ngx-socket-io": "^4.7.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.3" diff --git a/spooty-fe/proxy.conf.json b/spooty-fe/proxy.conf.json index 94b6722..15f9c3a 100644 --- a/spooty-fe/proxy.conf.json +++ b/spooty-fe/proxy.conf.json @@ -6,5 +6,10 @@ "pathRewrite": { "^/api": "" } + }, + "/socket.io/*": { + "target": "http://localhost:3000/socket.io/", + "ws": true, + "logLevel": "debug" } } diff --git a/spooty-fe/src/app/app.component.html b/spooty-fe/src/app/app.component.html index 0b8ade7..59f2ed1 100644 --- a/spooty-fe/src/app/app.component.html +++ b/spooty-fe/src/app/app.component.html @@ -22,8 +22,7 @@
-

List

  - +

Playlists

 
@@ -38,27 +37,7 @@

- -
- - - - {{track.artist}} - {{track.song}}  - - -   - - - -
-
- - New - Queued - Completed - -
- +
diff --git a/spooty-fe/src/app/app.component.scss b/spooty-fe/src/app/app.component.scss index 2e6b8f9..61f6c33 100644 --- a/spooty-fe/src/app/app.component.scss +++ b/spooty-fe/src/app/app.component.scss @@ -3,10 +3,6 @@ border-radius: 10px; } -.panel-block { - padding: 5px 10px; -} - .panel { margin: 5px 0; } diff --git a/spooty-fe/src/app/app.component.ts b/spooty-fe/src/app/app.component.ts index 5fba698..cd600a8 100644 --- a/spooty-fe/src/app/app.component.ts +++ b/spooty-fe/src/app/app.component.ts @@ -3,11 +3,12 @@ import { RouterOutlet } from '@angular/router'; import {FormsModule} from "@angular/forms"; import {CommonModule, NgFor, NgSwitch, NgSwitchCase} from "@angular/common"; import {PlaylistService} from "./services/playlist.service"; +import {TrackListComponent} from "./components/track-list/track-list.component"; @Component({ selector: 'app-root', standalone: true, - imports: [CommonModule, RouterOutlet, FormsModule, NgFor, NgSwitch, NgSwitchCase], + imports: [CommonModule, RouterOutlet, FormsModule, NgFor, NgSwitch, NgSwitchCase, TrackListComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) diff --git a/spooty-fe/src/app/app.config.ts b/spooty-fe/src/app/app.config.ts index 4e46653..73ea233 100644 --- a/spooty-fe/src/app/app.config.ts +++ b/spooty-fe/src/app/app.config.ts @@ -1,13 +1,15 @@ -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import {ApplicationConfig, importProvidersFrom, provideZoneChangeDetection} from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import {provideHttpClient} from "@angular/common/http"; +import {SocketIoModule} from "ngx-socket-io"; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(), - ] + importProvidersFrom(SocketIoModule.forRoot({url: ''})) + ], }; diff --git a/spooty-fe/src/app/components/track-list/track-list.component.html b/spooty-fe/src/app/components/track-list/track-list.component.html new file mode 100644 index 0000000..f9f535a --- /dev/null +++ b/spooty-fe/src/app/components/track-list/track-list.component.html @@ -0,0 +1,24 @@ + + +
+ + New + Searching + Queued + Downloading + Completed + Error + +
+ diff --git a/spooty-fe/src/app/components/track-list/track-list.component.scss b/spooty-fe/src/app/components/track-list/track-list.component.scss new file mode 100644 index 0000000..aabde9b --- /dev/null +++ b/spooty-fe/src/app/components/track-list/track-list.component.scss @@ -0,0 +1,3 @@ +.panel-block { + padding: 5px 10px; +} diff --git a/spooty-fe/src/app/components/track-list/track-list.component.spec.ts b/spooty-fe/src/app/components/track-list/track-list.component.spec.ts new file mode 100644 index 0000000..8c72f90 --- /dev/null +++ b/spooty-fe/src/app/components/track-list/track-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TrackListComponent } from './track-list.component'; + +describe('TrackListComponent', () => { + let component: TrackListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TrackListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TrackListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/spooty-fe/src/app/components/track-list/track-list.component.ts b/spooty-fe/src/app/components/track-list/track-list.component.ts new file mode 100644 index 0000000..a72d189 --- /dev/null +++ b/spooty-fe/src/app/components/track-list/track-list.component.ts @@ -0,0 +1,26 @@ +import {Component, Input} from '@angular/core'; +import {CommonModule, NgFor, NgSwitch, NgSwitchCase} from "@angular/common"; +import {Track, TrackService, TrackStatusEnum} from "../../services/track.service"; +import {Observable} from "rxjs"; + +@Component({ + selector: 'app-track-list', + standalone: true, + imports: [CommonModule, NgFor, NgSwitch, NgSwitchCase], + templateUrl: './track-list.component.html', + styleUrl: './track-list.component.scss' +}) +export class TrackListComponent { + + @Input() set playlistId(value: number) { + console.log(value); + this.tracks$ = this.service.getAllByPlaylist(value); + } + tracks$!: Observable; + trackStatuses = TrackStatusEnum; + + constructor( + private readonly service: TrackService, + ) { + } +} diff --git a/spooty-fe/src/app/services/playlist.service.ts b/spooty-fe/src/app/services/playlist.service.ts index df2426b..2f7dc57 100644 --- a/spooty-fe/src/app/services/playlist.service.ts +++ b/spooty-fe/src/app/services/playlist.service.ts @@ -2,16 +2,17 @@ import { Injectable } from '@angular/core'; import {createStore} from "@ngneat/elf"; import {HttpClient} from "@angular/common/http"; import { - addEntities, selectAllEntities, selectEntities, setEntities, UIEntitiesRef, - unionEntities, updateEntities, + unionEntities, updateEntities, upsertEntities, withEntities, withUIEntities } from "@ngneat/elf-entities"; import {joinRequestResult, trackRequestResult} from "@ngneat/elf-requests"; -import {tap} from "rxjs"; +import {map, tap} from "rxjs"; +import {TrackService} from "./track.service"; +import {PlaylistEntity} from "spooty-be/dist/playlist/playlist.entity"; const STORE_NAME = 'playlist'; const ENDPOINT = '/api/playlist'; @@ -21,7 +22,7 @@ export interface Playlist { id: number; name: string; spotifyUrl: string; - tracks?: any[]; //todo fix it + createdAt: number; } export interface PlaylistUi { @@ -42,12 +43,14 @@ export class PlaylistService { all$ = this.store.combine({ entities: this.store.pipe(selectAllEntities()), UIEntities: this.store.pipe(selectEntities({ ref: UIEntitiesRef })), - }).pipe(unionEntities()); + }).pipe(unionEntities(), map(data => data.sort((a, b) => b.createdAt - a.createdAt))); loading$ = this.store.pipe(joinRequestResult([STORE_NAME])); createLoading$ = this.store.pipe(joinRequestResult([CREATE_LOADING], { initialStatus: 'idle' })); - constructor(private readonly http: HttpClient) { + constructor(private readonly http: HttpClient, + private readonly trackService: TrackService, + ) { } fetch(): void { @@ -56,6 +59,7 @@ export class PlaylistService { setEntities(data), setEntities(data.map(item => ({id: item.id, collapsed: false})), {ref: UIEntitiesRef}) )), + tap((data: Playlist[]) => data.forEach(playlist => this.trackService.fetch(playlist.id))), trackRequestResult([STORE_NAME], { skipCache: true }), ).subscribe(); } @@ -63,7 +67,13 @@ export class PlaylistService { create(spotifyUrl: string): void { this.http.post(ENDPOINT, {spotifyUrl}).pipe( trackRequestResult([CREATE_LOADING], { skipCache: true }) - ).subscribe(); + ).subscribe((playlist: Partial) => { + this.store.update(upsertEntities(playlist)); + this.store.update( + upsertEntities(playlist), + upsertEntities({id: playlist.id, collapsed: false}, {ref: UIEntitiesRef}) + ) + }); } toggleCollapsed(id: number): void { diff --git a/spooty-fe/src/app/services/track.service.spec.ts b/spooty-fe/src/app/services/track.service.spec.ts new file mode 100644 index 0000000..a22c311 --- /dev/null +++ b/spooty-fe/src/app/services/track.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { TrackService } from './track.service'; + +describe('TrackService', () => { + let service: TrackService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TrackService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/spooty-fe/src/app/services/track.service.ts b/spooty-fe/src/app/services/track.service.ts new file mode 100644 index 0000000..ee3c5fd --- /dev/null +++ b/spooty-fe/src/app/services/track.service.ts @@ -0,0 +1,62 @@ +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 {trackRequestResult} from "@ngneat/elf-requests"; +import {HttpClient} from "@angular/common/http"; + +const STORE_NAME = 'track'; +const ENDPOINT = '/api/track'; + +export interface Track { + id: number; + artist: string; + song: string; + spotifyUrl: string; + youtubeUrl: string; + status: TrackStatusEnum; + playlistId?: number; + error?: string; +} + +export enum TrackStatusEnum { + New, + Searching, + Queued, + Downloading, + Completed, + Error, +} + +@Injectable({ + providedIn: 'root' +}) +export class TrackService { + + private store = createStore( + { name: STORE_NAME }, + withEntities(), + ); + + getAllByPlaylist(id: number) { + return this.store.pipe(selectManyByPredicate(({playlistId}) => playlistId === id)) + } + + constructor( + private readonly http: HttpClient, + private readonly socket: Socket, + ) { + this.socket.on('track', (track: Track) => this.store.update(upsertEntities(track))); + this.socket.on('trackNew', ({track, playlistId}: {track: Track, playlistId: number}) => + this.store.update(upsertEntities([{...track, playlistId}])) + ); + } + + fetch(playlistId: number): void { + this.http.get(`${ENDPOINT}/playlist/${playlistId}`).pipe( + tap((data: Track[]) => this.store.update(upsertEntities(data.map(track => ({...track, playlistId}))))), + trackRequestResult([STORE_NAME], { skipCache: true }), + ).subscribe(); + } +}