websockets for tracks and ui improvement

This commit is contained in:
raiper34
2024-07-10 23:08:05 +02:00
parent 3455db8d71
commit 2f0e4626f8
22 changed files with 389 additions and 101 deletions
+130 -27
View File
@@ -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"
+2
View File
@@ -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",
@@ -13,6 +13,9 @@ export class PlaylistEntity {
@Column()
spotifyUrl: string;
@Column({default: () => Date.now()})
createdAt?: number;
@OneToMany(() => TrackEntity, track => track.playlist)
tracks?: TrackEntity[];
}
+1
View File
@@ -3,4 +3,5 @@ export interface PlaylistModel {
name: string;
spotifyUrl: string;
tracks?: any[]; //todo fix it
createdAt?: number;
}
@@ -8,6 +8,7 @@ const { getData, getPreview, getTracks, getDetails } = require('spotify-url-info
@Injectable()
export class PlaylistService {
constructor(
@InjectRepository(PlaylistEntity)
private repository: Repository<PlaylistEntity>,
+4 -20
View File
@@ -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<TrackModel> {
return this.service.findOne(id);
}
@Post()
create(@Body() track: TrackModel): Promise<TrackModel> {
return this.service.create(track as TrackEntity);
}
@Put(':id')
update(@Param('id') id: number, @Body() track: TrackModel): Promise<void> {
return this.service.update(id, track as TrackEntity);
}
@Delete(':id')
delete(@Param('id') id: number): Promise<void> {
return this.service.remove(id);
@Get('playlist/:id')
getAllByPlaylist(@Param('id') playlistId: number): Promise<TrackModel[]> {
return this.service.getAllByPlaylist(playlistId);
}
@Get('download/:id')
+7 -1
View File
@@ -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;
}
+5
View File
@@ -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,
}
+51 -16
View File
@@ -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<TrackEntity>,
@@ -23,6 +30,10 @@ export class TrackService {
return this.repository.find({where: criteria});
}
getAllByPlaylist(id: number): Promise<TrackEntity[]> {
return this.repository.find({where: {playlist: {id}}});
}
findOne(id: number): Promise<TrackEntity | null> {
return this.repository.findOneBy({ id });
}
@@ -31,41 +42,65 @@ export class TrackService {
await this.repository.delete(id);
}
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<TrackEntity> {
return this.repository.save({...track, playlist});
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
const savedTrack = await this.repository.save({...track, playlist});
this.io.emit('trackNew', {track: savedTrack, playlistId: playlist.id});
}
async update(id: number, track: TrackEntity): Promise<void> {
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<void> {
return new Promise((res, _reject) =>
ytdl(track.youtubeUrl, {quality: "highestaudio", filter: "audioonly"}).pipe(
fs.createWriteStream(resolve(__dirname, '..', this.configService.get<string>('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<string>('DOWNLOADS'), `${track.artist} - ${track.song.replace('/', '')}.mp3`))
.on('finish', () => res())
.on('error', (err) => reject(err))
)
);
}
+1
View File
@@ -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"
+5
View File
@@ -6,5 +6,10 @@
"pathRewrite": {
"^/api": ""
}
},
"/socket.io/*": {
"target": "http://localhost:3000/socket.io/",
"ws": true,
"logLevel": "debug"
}
}
+2 -23
View File
@@ -22,8 +22,7 @@
<hr>
<div class="box">
<div class="is-flex is-align-items-center">
<p class="subtitle">List</p>&nbsp;
<i class="fa-solid fa-arrows-rotate is-clickable" [class.loading-animation]="(loading$ | async)?.isLoading" (click)="fetchPlaylists()"></i>
<p class="subtitle">Playlists</p>&nbsp;
</div>
<article class="panel is-primary" *ngFor="let playlist of playlists$ | async">
@@ -38,27 +37,7 @@
<a [href]="playlist.spotifyUrl" target="_blank" class="is-color-black"><i class="fa-brands fa-spotify"></i></a>
</p>
<ng-container *ngIf="playlist.collapsed">
<a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of playlist.tracks">
<div>
<a href="api/track/download/{{track.id}}" class="panel-icon is-color-info" download>
<i class="fa-solid fa-download"></i>
</a>
<span>{{track.artist}} - {{track.song}}</span>&nbsp;
<a [href]="track.spotifyUrl" target="_blank" class="is-color-primary margin-left">
<i class="fa-brands fa-spotify"></i>
</a>&nbsp;
<a [href]="track.youtubeUrl" target="_blank" class="is-color-danger is-color-black">
<i class="fa-brands fa-youtube"></i>
</a>
</div>
<div>
<ng-container [ngSwitch]="track.status">
<span *ngSwitchCase="0" class="tag is-info">New</span>
<span *ngSwitchCase="1" class="tag is-warning">Queued</span>
<span *ngSwitchCase="2" class="tag is-success">Completed</span>
</ng-container>
</div>
</a>
<app-track-list [playlistId]="playlist.id"></app-track-list>
</ng-container>
</article>
</div>
-4
View File
@@ -3,10 +3,6 @@
border-radius: 10px;
}
.panel-block {
padding: 5px 10px;
}
.panel {
margin: 5px 0;
}
+2 -1
View File
@@ -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'
})
+4 -2
View File
@@ -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: ''}))
],
};
@@ -0,0 +1,24 @@
<a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async">
<div>
<span>{{ track.artist }} - {{ track.song }}</span>&nbsp;
<a [href]="track.spotifyUrl" target="_blank" class="is-color-primary margin-left">
<i class="fa-brands fa-spotify"></i>
</a>&nbsp;
<a [href]="track.youtubeUrl" target="_blank" class="is-color-danger is-color-black">
<i class="fa-brands fa-youtube"></i>
</a>&nbsp;
<a *ngIf="track.status === trackStatuses.Completed" href="api/track/download/{{track.id}}" class="is-color-info" download>
<i class="fa-solid fa-download"></i>
</a>
</div>
<div>
<ng-container [ngSwitch]="track.status">
<span *ngSwitchCase="trackStatuses.New" class="tag is-info">New</span>
<span *ngSwitchCase="trackStatuses.Searching" class="tag is-warning">Searching</span>
<span *ngSwitchCase="trackStatuses.Queued" class="tag is-warning">Queued</span>
<span *ngSwitchCase="trackStatuses.Downloading" class="tag is-warning">Downloading</span>
<span *ngSwitchCase="trackStatuses.Completed" class="tag is-success">Completed</span>
<span *ngSwitchCase="trackStatuses.Error" class="tag is-danger" [title]="track.error">Error</span>
</ng-container>
</div>
</a>
@@ -0,0 +1,3 @@
.panel-block {
padding: 5px 10px;
}
@@ -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<TrackListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TrackListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(TrackListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<Track[]>;
trackStatuses = TrackStatusEnum;
constructor(
private readonly service: TrackService,
) {
}
}
+17 -7
View File
@@ -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<Playlist>) => {
this.store.update(upsertEntities(playlist));
this.store.update(
upsertEntities(playlist),
upsertEntities({id: playlist.id, collapsed: false}, {ref: UIEntitiesRef})
)
});
}
toggleCollapsed(id: number): void {
@@ -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();
});
});
@@ -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<Track>(),
);
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<Track[]>(`${ENDPOINT}/playlist/${playlistId}`).pipe(
tap((data: Track[]) => this.store.update(upsertEntities(data.map(track => ({...track, playlistId}))))),
trackRequestResult([STORE_NAME], { skipCache: true }),
).subscribe();
}
}