├── .nvmrc ├── src ├── frontend │ ├── src │ │ ├── app │ │ │ ├── app.component.scss │ │ │ ├── app.routes.ts │ │ │ ├── models │ │ │ │ ├── playlist.ts │ │ │ │ └── track.ts │ │ │ ├── components │ │ │ │ ├── track-list │ │ │ │ │ ├── track-list.component.scss │ │ │ │ │ ├── track-list.component.spec.ts │ │ │ │ │ ├── track-list.component.ts │ │ │ │ │ └── track-list.component.html │ │ │ │ └── playlist-box │ │ │ │ │ ├── playlist-box.component.scss │ │ │ │ │ ├── playlist-box.component.spec.ts │ │ │ │ │ ├── playlist-box.component.html │ │ │ │ │ └── playlist-box.component.ts │ │ │ ├── services │ │ │ │ ├── version.service.ts │ │ │ │ ├── track.service.spec.ts │ │ │ │ ├── version.service.spec.ts │ │ │ │ ├── playlist.service.spec.ts │ │ │ │ ├── track.service.ts │ │ │ │ └── playlist.service.ts │ │ │ ├── app.config.ts │ │ │ ├── app.component.spec.ts │ │ │ ├── app.component.ts │ │ │ └── app.component.html │ │ ├── main.ts │ │ ├── index.html │ │ └── styles.scss │ ├── public │ │ └── favicon.ico │ ├── .sassrc │ ├── .vscode │ │ ├── extensions.json │ │ ├── launch.json │ │ └── tasks.json │ ├── proxy.conf.json │ ├── .editorconfig │ ├── tsconfig.app.json │ ├── tsconfig.spec.json │ ├── .gitignore │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ └── angular.json └── backend │ ├── .prettierrc │ ├── tsconfig.build.json │ ├── nest-cli.json │ ├── src │ ├── app.controller.ts │ ├── environmentEnum.ts │ ├── track │ │ ├── track-search.processor.ts │ │ ├── track-download.processor.ts │ │ ├── track.entity.ts │ │ ├── track.module.ts │ │ ├── track.controller.ts │ │ └── track.service.ts │ ├── shared │ │ ├── shared.module.ts │ │ ├── utils.service.ts │ │ ├── spotify.service.ts │ │ ├── youtube.service.ts │ │ └── spotify-api.service.ts │ ├── app.controller.spec.ts │ ├── playlist │ │ ├── playlist.entity.ts │ │ ├── playlist.module.ts │ │ ├── playlist.controller.ts │ │ └── playlist.service.ts │ ├── main.ts │ └── app.module.ts │ ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts │ ├── .env.docker │ ├── .env.default │ ├── tsconfig.json │ ├── .eslintrc.js │ ├── .gitignore │ ├── package.json │ └── README.md ├── .czrc ├── .gitignore ├── .auto-changelog ├── Dockerfile ├── .release-it.json ├── LICENSE.md ├── package.json ├── .github └── workflows │ └── update-ytdl.yml ├── assets └── logo.svg ├── CHANGELOG.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.19.1 -------------------------------------------------------------------------------- /src/frontend/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } -------------------------------------------------------------------------------- /src/backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /.idea 4 | **/*.sqlite 5 | # Local Netlify folder 6 | .netlify 7 | -------------------------------------------------------------------------------- /src/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raiper34/spooty/HEAD/src/frontend/public/favicon.ico -------------------------------------------------------------------------------- /src/frontend/.sassrc : -------------------------------------------------------------------------------- 1 | { 2 | "includePaths": [ 3 | "../node_modules/", 4 | "src/" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/frontend/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const routes: Routes = []; 4 | -------------------------------------------------------------------------------- /src/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.auto-changelog: -------------------------------------------------------------------------------- 1 | { 2 | "ignoreCommitPattern": "(test\\()|(release\\()|(chore\\()", 3 | "commitLimit": false, 4 | "hideCredit": true, 5 | "startingVersion": "2.0.1" 6 | } -------------------------------------------------------------------------------- /src/frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /src/frontend/src/app/models/playlist.ts: -------------------------------------------------------------------------------- 1 | export interface Playlist { 2 | id: number; 3 | name?: string; 4 | spotifyUrl: string; 5 | error?: string; 6 | active: boolean; 7 | createdAt: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/frontend/src/app/components/track-list/track-list.component.scss: -------------------------------------------------------------------------------- 1 | .hover-icon { 2 | display: none; 3 | } 4 | 5 | .panel-block { 6 | padding: 5px 10px; 7 | 8 | &:hover .hover-icon { 9 | display: inline; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/backend/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class AppController { 5 | constructor() {} 6 | 7 | @Get() 8 | getHello(): string { 9 | return 'ONLINE'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/backend/src/environmentEnum.ts: -------------------------------------------------------------------------------- 1 | export enum EnvironmentEnum { 2 | DB_PATH = 'DB_PATH', 3 | FE_PATH = 'FE_PATH', 4 | DOWNLOADS_PATH = 'DOWNLOADS_PATH', 5 | FORMAT = 'FORMAT', 6 | REDIS_PORT = 'REDIS_PORT', 7 | REDIS_HOST = 'REDIS_HOST', 8 | REDIS_RUN = 'REDIS_RUN', 9 | } 10 | -------------------------------------------------------------------------------- /src/frontend/src/app/services/version.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {version} from '../../../package.json'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class VersionService { 8 | 9 | constructor() { } 10 | 11 | getVersion(): string { 12 | return version; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | import { devTools } from '@ngneat/elf-devtools'; 5 | 6 | bootstrapApplication(AppComponent, appConfig) 7 | .catch((err) => console.error(err)); 8 | devTools(); 9 | -------------------------------------------------------------------------------- /src/frontend/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api/**": { 3 | "target": "http://localhost:3000/api", 4 | "secure": false, 5 | "changeOrigin": true, 6 | "pathRewrite": { 7 | "^/api": "" 8 | } 9 | }, 10 | "/socket.io/*": { 11 | "target": "http://localhost:3000/socket.io/", 12 | "ws": true, 13 | "logLevel": "debug" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SpootyFe 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/backend/.env.docker: -------------------------------------------------------------------------------- 1 | DB_PATH=./config/db.sqlite 2 | FE_PATH=../frontend/browser 3 | DOWNLOADS_PATH=./downloads 4 | FORMAT=mp3 5 | PORT=3000 6 | REDIS_PORT=6379 7 | REDIS_HOST=localhost 8 | REDIS_RUN=true 9 | 10 | # Credentials for Spotify API 11 | SPOTIFY_CLIENT_ID=your_client_id 12 | SPOTIFY_CLIENT_SECRET=your_client_secret 13 | 14 | YT_COOKIES= 15 | YT_DOWNLOADS_PER_MINUTE=3 -------------------------------------------------------------------------------- /src/frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import "bulma/bulma.scss"; 3 | @import "@fortawesome/fontawesome-free/css/all.css"; 4 | 5 | :root { 6 | --bulma-primary-h: 141deg; 7 | --bulma-primary-s: 73%; 8 | --bulma-primary-l: 42%; 9 | } 10 | 11 | body { 12 | padding: 0; 13 | margin: 0; 14 | } 15 | -------------------------------------------------------------------------------- /src/frontend/src/app/components/playlist-box/playlist-box.component.scss: -------------------------------------------------------------------------------- 1 | .hover-icon { 2 | display: none; 3 | } 4 | 5 | .panel-heading { 6 | padding: 5px 10px; 7 | border-radius: 10px; 8 | 9 | &:hover .hover-icon { 10 | display: inline; 11 | } 12 | 13 | &:hover .hover-icon-reversed { 14 | display: none; 15 | } 16 | } 17 | 18 | .panel { 19 | margin: 5px 0; 20 | } 21 | -------------------------------------------------------------------------------- /src/frontend/src/app/models/track.ts: -------------------------------------------------------------------------------- 1 | export interface Track { 2 | id: number; 3 | artist: string; 4 | name: string; 5 | spotifyUrl: string; 6 | youtubeUrl: string; 7 | status: TrackStatusEnum; 8 | playlistId?: number; 9 | error?: string; 10 | } 11 | 12 | export enum TrackStatusEnum { 13 | New, 14 | Searching, 15 | Queued, 16 | Downloading, 17 | Completed, 18 | Error, 19 | } 20 | -------------------------------------------------------------------------------- /src/frontend/src/app/services/track.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { TrackService } from './track.service'; 4 | 5 | describe('TrackService', () => { 6 | let service: TrackService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(TrackService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/frontend/src/app/services/version.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { VersionService } from './version.service'; 4 | 5 | describe('VersionService', () => { 6 | let service: VersionService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(VersionService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/frontend/src/app/services/playlist.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PlaylistService } from './playlist.service'; 4 | 5 | describe('PlaylistService', () => { 6 | let service: PlaylistService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PlaylistService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/app", 7 | "types": [] 8 | }, 9 | "files": [ 10 | "src/main.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": [ 8 | "jasmine" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.spec.ts", 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/frontend/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/backend/src/track/track-search.processor.ts: -------------------------------------------------------------------------------- 1 | import { Processor, WorkerHost } from '@nestjs/bullmq'; 2 | import { Job } from 'bullmq'; 3 | import { TrackService } from './track.service'; 4 | import { TrackEntity } from './track.entity'; 5 | 6 | @Processor('track-search-processor') 7 | export class TrackSearchProcessor extends WorkerHost { 8 | constructor(private readonly trackService: TrackService) { 9 | super(); 10 | } 11 | 12 | async process(job: Job): Promise { 13 | await this.trackService.findOnYoutube(job.data); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/backend/.env.default: -------------------------------------------------------------------------------- 1 | DB_PATH=./config/db.sqlite 2 | FE_PATH=../frontend/browser 3 | DOWNLOADS_PATH=./downloads 4 | FORMAT=mp3 5 | PORT=3000 6 | REDIS_PORT=6379 7 | REDIS_HOST=localhost 8 | REDIS_RUN=false 9 | 10 | # Credentials for Spotify API 11 | SPOTIFY_CLIENT_ID=your_client_id 12 | SPOTIFY_CLIENT_SECRET=your_client_secret 13 | 14 | YT_COOKIES="AEC=xxx; APISID=xxx; APISID=xxx; HSID=xxx; HSID=xxx; LOGIN_INFO=xxx; NID=xxx; PREF=xxx; SAPISID=xxx; SAPISID=Nxxx; SEARCH_SAMESITE=xxx; SID=xxx; SID=xxx; SIDCC=xxx; SIDCC=xxx; SSID=xxx; SSID=xxx; STRP=xxx; VISITOR_PRIVACY_METADATA=xxx; YSC=xxx" 15 | YT_DOWNLOADS_PER_MINUTE=3 -------------------------------------------------------------------------------- /src/frontend/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import {ApplicationConfig, importProvidersFrom, provideZoneChangeDetection} from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | 4 | import { routes } from './app.routes'; 5 | import {provideHttpClient} from "@angular/common/http"; 6 | import {SocketIoModule} from "ngx-socket-io"; 7 | 8 | export const appConfig: ApplicationConfig = { 9 | providers: [ 10 | provideZoneChangeDetection({ eventCoalescing: true }), 11 | provideRouter(routes), 12 | provideHttpClient(), 13 | importProvidersFrom(SocketIoModule.forRoot({url: ''})) 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /src/backend/src/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UtilsService } from './utils.service'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { SpotifyService } from './spotify.service'; 5 | import { YoutubeService } from './youtube.service'; 6 | import { SpotifyApiService } from './spotify-api.service'; 7 | 8 | @Module({ 9 | imports: [ConfigModule], 10 | providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService], 11 | controllers: [], 12 | exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService], 13 | }) 14 | export class SharedModule {} 15 | -------------------------------------------------------------------------------- /src/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "../../dist/backend", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.20.4-alpine AS builder 2 | WORKDIR /spooty 3 | COPY . . 4 | RUN npm ci 5 | RUN npm run build 6 | 7 | FROM node:18.20.4-alpine 8 | WORKDIR /spooty 9 | COPY --from=builder /spooty/dist . 10 | COPY --from=builder /spooty/src ./src 11 | COPY --from=builder /spooty/package.json ./package.json 12 | COPY --from=builder /spooty/package-lock.json ./package-lock.json 13 | COPY --from=builder /spooty/src/backend/.env.docker ./.env 14 | RUN npm prune --production 15 | RUN rm -rf src package.json package-lock.json 16 | RUN apk add --no-cache ffmpeg 17 | RUN apk add --no-cache redis 18 | RUN apk add --no-cache python3 py3-pip 19 | EXPOSE 3000 20 | CMD ["node", "backend/main.js"] -------------------------------------------------------------------------------- /src/backend/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | 4 | describe('AppController', () => { 5 | let appController: AppController; 6 | 7 | beforeEach(async () => { 8 | const app: TestingModule = await Test.createTestingModule({ 9 | controllers: [AppController], 10 | providers: [], 11 | }).compile(); 12 | 13 | appController = app.get(AppController); 14 | }); 15 | 16 | describe('root', () => { 17 | it('should return "Hello World!"', () => { 18 | expect(appController.getHello()).toBe('Hello World!'); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/backend/src/shared/utils.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { resolve } from 'path'; 4 | import { EnvironmentEnum } from '../environmentEnum'; 5 | 6 | @Injectable() 7 | export class UtilsService { 8 | constructor(private readonly configService: ConfigService) {} 9 | 10 | getPlaylistFolderPath(name: string): string { 11 | return resolve( 12 | __dirname, 13 | '..', 14 | this.configService.get(EnvironmentEnum.DOWNLOADS_PATH), 15 | this.stripFileIllegalChars(name), 16 | ); 17 | } 18 | 19 | stripFileIllegalChars(text: string): string { 20 | return text.replace(/[/\\?%*:|"<>]/g, '-'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/backend/src/playlist/playlist.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; 2 | import { TrackEntity } from '../track/track.entity'; 3 | 4 | @Entity() 5 | export class PlaylistEntity { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column({ nullable: true }) 10 | name?: string; 11 | 12 | @Column() 13 | spotifyUrl: string; 14 | 15 | @Column({ nullable: true }) 16 | error?: string; 17 | 18 | @Column({ default: false }) 19 | active?: boolean; 20 | 21 | @Column({ default: () => Date.now() }) 22 | createdAt?: number; 23 | 24 | @Column({ nullable: true }) 25 | coverUrl?: string; 26 | 27 | @OneToMany(() => TrackEntity, (track) => track.playlist) 28 | tracks?: TrackEntity[]; 29 | } 30 | -------------------------------------------------------------------------------- /src/frontend/src/app/components/track-list/track-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TrackListComponent } from './track-list.component'; 4 | 5 | describe('TrackListComponent', () => { 6 | let component: TrackListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TrackListComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TrackListComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/backend/src/track/track-download.processor.ts: -------------------------------------------------------------------------------- 1 | import { Processor, WorkerHost } from '@nestjs/bullmq'; 2 | import { Job } from 'bullmq'; 3 | import { TrackService } from './track.service'; 4 | import { TrackEntity } from './track.entity'; 5 | 6 | @Processor('track-download-processor') 7 | export class TrackDownloadProcessor extends WorkerHost { 8 | constructor(private readonly trackService: TrackService) { 9 | super(); 10 | } 11 | 12 | async process(job: Job): Promise { 13 | const maxPerMinute = Number(process.env.YT_DOWNLOADS_PER_MINUTE || 3); 14 | const sleepMs = Math.floor(60000 / maxPerMinute); 15 | await new Promise((res) => setTimeout(res, sleepMs)); 16 | await this.trackService.downloadFromYoutube(job.data); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /src/frontend/src/app/components/playlist-box/playlist-box.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PlaylistBoxComponent } from './playlist-box.component'; 4 | 5 | describe('PlaylistListComponent', () => { 6 | let component: PlaylistBoxComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [PlaylistBoxComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(PlaylistBoxComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/backend/src/playlist/playlist.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { PlaylistEntity } from './playlist.entity'; 4 | import { PlaylistService } from './playlist.service'; 5 | import { PlaylistController } from './playlist.controller'; 6 | import { TrackModule } from '../track/track.module'; 7 | import { ConfigModule } from '@nestjs/config'; 8 | import { SharedModule } from '../shared/shared.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([PlaylistEntity]), 13 | ConfigModule, 14 | TrackModule, 15 | SharedModule, 16 | ], 17 | providers: [PlaylistService], 18 | controllers: [PlaylistController], 19 | exports: [PlaylistService], 20 | }) 21 | export class PlaylistModule {} 22 | -------------------------------------------------------------------------------- /src/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | config 49 | 50 | # Runtime data 51 | pids 52 | *.pid 53 | *.seed 54 | *.pid.lock 55 | 56 | # Diagnostic reports (https://nodejs.org/api/report.html) 57 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 58 | -------------------------------------------------------------------------------- /src/backend/src/track/track.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; 2 | import { PlaylistEntity } from '../playlist/playlist.entity'; 3 | 4 | export enum TrackStatusEnum { 5 | New, 6 | Searching, 7 | Queued, 8 | Downloading, 9 | Completed, 10 | Error, 11 | } 12 | 13 | @Entity() 14 | export class TrackEntity { 15 | @PrimaryGeneratedColumn() 16 | id?: number; 17 | 18 | @Column() 19 | artist: string; 20 | 21 | @Column() 22 | name: string; 23 | 24 | @Column({ nullable: true }) 25 | spotifyUrl: string; 26 | 27 | @Column({ nullable: true }) 28 | youtubeUrl?: string; 29 | 30 | @Column({ default: TrackStatusEnum.New }) 31 | status?: TrackStatusEnum; 32 | 33 | @Column({ nullable: true }) 34 | error?: string; 35 | 36 | @Column({ default: Date.now() }) 37 | createdAt?: number; 38 | 39 | @ManyToOne(() => PlaylistEntity, (playlist) => playlist.tracks, { 40 | onDelete: 'CASCADE', 41 | }) 42 | playlist?: PlaylistEntity; 43 | } 44 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/release-it@17/schema/release-it.json", 3 | "git": { 4 | "commitMessage": "chore(release): v${version}", 5 | "changelog": "npm run changelog -- --stdout --commit-limit false -u --template https://raw.githubusercontent.com/release-it/release-it/main/templates/changelog-compact.hbs" 6 | }, 7 | "github": { 8 | "release": true 9 | }, 10 | "npm": { 11 | "publish": false 12 | }, 13 | "plugins": { 14 | "@release-it/bumper": { 15 | "in": "package.json", 16 | "out": ["src/backend/package.json", "src/frontend/package.json"] 17 | }, 18 | "release-it-docker-plugin": { 19 | "latestTag": true, 20 | "imageName": "raiper34/spooty", 21 | "buildx": true, 22 | "builder": "container", 23 | "platform": "linux/arm64,linux/amd64", 24 | "output": "registry" 25 | } 26 | }, 27 | "hooks": { 28 | "before:init": ["npm run clean"], 29 | "after:bump": ["npm run changelog", "npm run build"], 30 | "after:release": [] 31 | } 32 | } -------------------------------------------------------------------------------- /src/backend/src/track/track.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { TrackEntity } from './track.entity'; 4 | import { TrackService } from './track.service'; 5 | import { TrackController } from './track.controller'; 6 | import { ConfigModule } from '@nestjs/config'; 7 | import { SharedModule } from '../shared/shared.module'; 8 | import { BullModule } from '@nestjs/bullmq'; 9 | import { TrackDownloadProcessor } from './track-download.processor'; 10 | import { TrackSearchProcessor } from './track-search.processor'; 11 | 12 | @Module({ 13 | imports: [ 14 | TypeOrmModule.forFeature([TrackEntity]), 15 | BullModule.registerQueue( 16 | { name: 'track-search-processor' }, 17 | { name: 'track-download-processor' }, 18 | ), 19 | ConfigModule, 20 | SharedModule, 21 | ], 22 | providers: [TrackService, TrackDownloadProcessor, TrackSearchProcessor], 23 | controllers: [TrackController], 24 | exports: [TrackService], 25 | }) 26 | export class TrackModule {} 27 | -------------------------------------------------------------------------------- /src/frontend/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(async () => { 6 | await TestBed.configureTestingModule({ 7 | imports: [AppComponent], 8 | }).compileComponents(); 9 | }); 10 | 11 | it('should create the app', () => { 12 | const fixture = TestBed.createComponent(AppComponent); 13 | const app = fixture.componentInstance; 14 | expect(app).toBeTruthy(); 15 | }); 16 | 17 | it(`should have the 'spooty-fe' title`, () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.componentInstance; 20 | expect(app.title).toEqual('spooty-fe'); 21 | }); 22 | 23 | it('should render title', () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | fixture.detectChanges(); 26 | const compiled = fixture.nativeElement as HTMLElement; 27 | expect(compiled.querySelector('h1')?.textContent).toContain('Hello, spooty-fe'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/frontend/src/app/components/track-list/track-list.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from '@angular/core'; 2 | import {CommonModule, NgFor, NgSwitch, NgSwitchCase} from "@angular/common"; 3 | import {TrackService} from "../../services/track.service"; 4 | import {Observable} from "rxjs"; 5 | import {Track, TrackStatusEnum} from "../../models/track"; 6 | 7 | @Component({ 8 | selector: 'app-track-list', 9 | imports: [CommonModule, NgFor, NgSwitch, NgSwitchCase], 10 | templateUrl: './track-list.component.html', 11 | styleUrl: './track-list.component.scss', 12 | standalone: true, 13 | }) 14 | export class TrackListComponent { 15 | 16 | @Input() set playlistId(value: number) { 17 | this.tracks$ = this.service.getAllByPlaylist(value); 18 | } 19 | tracks$!: Observable; 20 | trackStatuses = TrackStatusEnum; 21 | 22 | constructor( 23 | private readonly service: TrackService, 24 | ) { } 25 | 26 | delete(id: number): void { 27 | this.service.delete(id); 28 | } 29 | 30 | retry(id: number): void { 31 | this.service.retry(id); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Filip Gulan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/frontend/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.2.1", 3 | "name": "spooty", 4 | "workspaces": [ 5 | "src/backend", 6 | "src/frontend" 7 | ], 8 | "scripts": { 9 | "start:be": "npm run start:dev -w backend", 10 | "start:fe": "npm run start -w frontend", 11 | "build:be": "npm run build -w backend", 12 | "build:fe": "npm run build -w frontend", 13 | "build": "npm run build:be && npm run build:fe", 14 | "gen:fe": "npm run gen -w frontend", 15 | "gen:be": "npm run gen -w backend", 16 | "start": "npm run start:prod -w backend", 17 | "clean": "rimraf dist", 18 | "changelog": "auto-changelog -p", 19 | "release": "release-it", 20 | "commit": "cz", 21 | "check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u" 22 | }, 23 | "devDependencies": { 24 | "@release-it/bumper": "^7.0.1", 25 | "auto-changelog": "^2.5.0", 26 | "commitizen": "^4.3.1", 27 | "copy-files-from-to": "^3.2.2", 28 | "cz-conventional-changelog": "^3.3.0", 29 | "npm-check-updates": "^17.1.15", 30 | "release-it": "^18.1.2", 31 | "release-it-docker-plugin": "^2.0.0", 32 | "rimraf": "^6.0.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import * as fs from 'fs'; 4 | import { resolve } from 'path'; 5 | import { exec } from 'child_process'; 6 | import { EnvironmentEnum } from './environmentEnum'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | app.setGlobalPrefix('api'); 11 | await app.listen(process.env.PORT || 3000); 12 | } 13 | bootstrap(); 14 | 15 | if (!process.env[EnvironmentEnum.DOWNLOADS_PATH]) { 16 | throw new Error('DOWNLOADS_PATH environment variable is missing'); 17 | } 18 | const folderName = resolve( 19 | __dirname, 20 | process.env[EnvironmentEnum.DOWNLOADS_PATH], 21 | ); 22 | !fs.existsSync(folderName) && fs.mkdirSync(folderName); 23 | 24 | try { 25 | // not good idea, but I want to keep simple Dockerfile, I know ideally should be in another container and used docker compose 26 | Boolean(process.env[EnvironmentEnum.REDIS_RUN]) && 27 | exec(`redis-server --port ${process.env.REDIS_PORT}`); 28 | } catch (e) { 29 | console.log('Unable to run redis server form app'); 30 | console.log(e); 31 | } 32 | -------------------------------------------------------------------------------- /src/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "outDir": "./dist/out-tsc", 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "bundler", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/backend/src/playlist/playlist.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Post, 8 | Put, 9 | } from '@nestjs/common'; 10 | import { PlaylistService } from './playlist.service'; 11 | import { PlaylistEntity } from './playlist.entity'; 12 | 13 | @Controller('playlist') 14 | export class PlaylistController { 15 | constructor(private readonly service: PlaylistService) {} 16 | 17 | @Get() 18 | getAll(): Promise { 19 | return this.service.findAll(); 20 | } 21 | 22 | @Post() 23 | async create(@Body() playlist: PlaylistEntity): Promise { 24 | await this.service.create(playlist); 25 | } 26 | 27 | @Put(':id') 28 | update( 29 | @Param('id') id: number, 30 | @Body() playlist: Partial, 31 | ): Promise { 32 | return this.service.update(id, playlist); 33 | } 34 | 35 | @Delete(':id') 36 | remove(@Param('id') id: number): Promise { 37 | return this.service.remove(id); 38 | } 39 | 40 | @Get('retry/:id') 41 | retryFailedOfPlaylist(@Param('id') id: number): Promise { 42 | return this.service.retryFailedOfPlaylist(id); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/frontend/README.md: -------------------------------------------------------------------------------- 1 | # SpootyFe 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. 28 | -------------------------------------------------------------------------------- /.github/workflows/update-ytdl.yml: -------------------------------------------------------------------------------- 1 | name: update-ytdl 2 | 3 | on: 4 | workflow_dispatch: 5 | branches: main 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | update-ytdl: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | with: 16 | token: ${{ secrets.AUTOUPDATE_PAT }} 17 | - name: Get tags 18 | run: git fetch --tags origin 19 | - name: Set up Node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: '18.x' 23 | - name: Set up docker builder 24 | run: docker buildx create --name container --driver=docker-container 25 | - name: Update ytdlp-nodejs and release 26 | run: | 27 | docker login --username=${{ secrets.DOCKER_USERNAME }} --password=${{ secrets.DOCKER_PASSWORD }} 28 | npm ci --only=dev 29 | npm run check:lib 30 | if git diff --exit-code; then 31 | echo "NO changes detected" 32 | else 33 | npm update ytdlp-nodejs -w backend 34 | git config --global user.email "updater@spooty" 35 | git config --global user.name "AutoUpdater" 36 | git add . 37 | git commit -m "fix(ytdl): Upgrade ytdl package (automated)" 38 | npm run release -- --cli 39 | fi 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.AUTOUPDATE_PAT }} 42 | 43 | -------------------------------------------------------------------------------- /src/frontend/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {FormsModule} from "@angular/forms"; 3 | import {CommonModule, NgFor} from "@angular/common"; 4 | import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service"; 5 | import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component"; 6 | import {VersionService} from "./services/version.service"; 7 | 8 | @Component({ 9 | selector: 'app-root', 10 | imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent], 11 | templateUrl: './app.component.html', 12 | styleUrl: './app.component.scss', 13 | standalone: true, 14 | }) 15 | export class AppComponent { 16 | 17 | url = '' 18 | createLoading$ = this.playlistService.createLoading$; 19 | playlists$ = this.playlistService.all$; 20 | version = this.versionService.getVersion(); 21 | 22 | constructor( 23 | private readonly playlistService: PlaylistService, 24 | private readonly versionService: VersionService, 25 | ) { 26 | this.fetchPlaylists(); 27 | } 28 | 29 | fetchPlaylists(): void { 30 | this.playlistService.fetch(); 31 | } 32 | 33 | download(): void { 34 | this.url && this.playlistService.create(this.url); 35 | this.url = ''; 36 | } 37 | 38 | deleteCompleted(): void { 39 | this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Completed); 40 | } 41 | 42 | deleteFailed(): void { 43 | this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Error); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/backend/src/track/track.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Delete, 4 | Get, 5 | Param, 6 | Res, 7 | StreamableFile, 8 | } from '@nestjs/common'; 9 | import { TrackService } from './track.service'; 10 | import { createReadStream } from 'fs'; 11 | import type { Response } from 'express'; 12 | import { ConfigService } from '@nestjs/config'; 13 | import { TrackEntity } from './track.entity'; 14 | 15 | @Controller('track') 16 | export class TrackController { 17 | constructor( 18 | private readonly service: TrackService, 19 | private readonly configService: ConfigService, 20 | ) {} 21 | 22 | @Get('playlist/:id') 23 | getAllByPlaylist(@Param('id') playlistId: number): Promise { 24 | return this.service.getAllByPlaylist(playlistId); 25 | } 26 | 27 | @Get('download/:id') 28 | async getFile( 29 | @Res({ passthrough: true }) res: Response, 30 | @Param('id') id: number, 31 | ): Promise { 32 | const track = await this.service.get(id); 33 | const fileName = this.service.getTrackFileName(track); 34 | const readStream = createReadStream( 35 | this.service.getFolderName(track, track.playlist), 36 | ); 37 | res.set({ 'Content-Disposition': `attachment; filename="${fileName}` }); 38 | return new StreamableFile(readStream); 39 | } 40 | 41 | @Delete(':id') 42 | remove(@Param('id') id: number): Promise { 43 | return this.service.remove(id); 44 | } 45 | 46 | @Get('retry/:id') 47 | retry(@Param('id') id: number): Promise { 48 | return this.service.retry(id); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/frontend/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | 5 | Spooty 6 | v{{version}} 7 |

8 |

Self-hosted spotify downloader

9 |
10 |
11 | 12 |
13 |
14 |
15 |

Download

16 |
17 | 18 | 25 |
26 |
27 |
28 |
29 |
30 |

Playlists

31 |
32 | 35 | 38 |
39 |
40 | 41 |
42 |
43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /src/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "2.2.1", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve --proxy-config proxy.conf.json", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "gen": "ng generate" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^19.0.6", 15 | "@angular/common": "^19.0.6", 16 | "@angular/compiler": "^19.0.6", 17 | "@angular/core": "^19.0.6", 18 | "@angular/forms": "^19.0.6", 19 | "@angular/platform-browser": "^19.0.6", 20 | "@angular/platform-browser-dynamic": "^19.0.6", 21 | "@angular/router": "^19.0.6", 22 | "@distube/ytdl-core": "^4.15.9", 23 | "@fortawesome/fontawesome-free": "^6.5.2", 24 | "@ngneat/elf": "^2.5.1", 25 | "@ngneat/elf-cli-ng": "^1.0.0", 26 | "@ngneat/elf-devtools": "^1.3.0", 27 | "@ngneat/elf-entities": "^5.0.2", 28 | "@ngneat/elf-pagination": "^1.1.0", 29 | "@ngneat/elf-persist-state": "^1.2.1", 30 | "@ngneat/elf-requests": "^1.9.2", 31 | "@ngneat/elf-state-history": "^1.4.0", 32 | "bulma": "^1.0.1", 33 | "ngx-socket-io": "^4.7.0", 34 | "rxjs": "~7.8.0", 35 | "tslib": "^2.3.0", 36 | "zone.js": "~0.15.0" 37 | }, 38 | "devDependencies": { 39 | "@angular-devkit/build-angular": "^19.0.7", 40 | "@angular/cli": "^19.0.7", 41 | "@angular/compiler-cli": "^19.0.6", 42 | "@types/jasmine": "~5.1.0", 43 | "jasmine-core": "~5.1.0", 44 | "karma": "~6.4.0", 45 | "karma-chrome-launcher": "~3.2.0", 46 | "karma-coverage": "~2.2.0", 47 | "karma-jasmine": "~5.1.0", 48 | "karma-jasmine-html-reporter": "~2.1.0", 49 | "typescript": "~5.6.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/frontend/src/app/components/track-list/track-list.component.html: -------------------------------------------------------------------------------- 1 | 2 | 22 |
23 |   24 |   25 | 26 | New 27 | Searching 28 | Queued 29 | Downloading 30 | Completed 31 | Error 32 | 33 |
34 | 35 | -------------------------------------------------------------------------------- /src/backend/src/shared/spotify.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { TrackService } from '../track/track.service'; 3 | import { SpotifyApiService } from './spotify-api.service'; 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | const fetch = require('isomorphic-unfetch'); 6 | // eslint-disable-next-line @typescript-eslint/no-var-requires 7 | const { getDetails } = require('spotify-url-info')(fetch); 8 | 9 | @Injectable() 10 | export class SpotifyService { 11 | private readonly logger = new Logger(TrackService.name); 12 | 13 | constructor(private readonly spotifyApiService: SpotifyApiService) {} 14 | 15 | async getPlaylistDetail( 16 | spotifyUrl: string, 17 | ): Promise<{ name: string; tracks: any[]; image: string }> { 18 | this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`); 19 | 20 | try { 21 | const metadata = 22 | await this.spotifyApiService.getPlaylistMetadata(spotifyUrl); 23 | 24 | const tracks = 25 | await this.spotifyApiService.getAllPlaylistTracks(spotifyUrl); 26 | 27 | return { 28 | name: metadata.name, 29 | tracks: tracks || [], 30 | image: metadata.image, 31 | }; 32 | } catch (error) { 33 | this.logger.error(`Error getting playlist details: ${error.message}`); 34 | const detail = await getDetails(spotifyUrl); 35 | return { 36 | name: detail.preview.title, 37 | tracks: detail?.tracks ?? [], 38 | image: detail.preview.image, 39 | }; 40 | } 41 | } 42 | 43 | async getPlaylistTracks(spotifyUrl: string): Promise { 44 | this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`); 45 | try { 46 | return await this.spotifyApiService.getAllPlaylistTracks(spotifyUrl); 47 | } catch (error) { 48 | this.logger.error(`Error getting playlist tracks: ${error.message}`); 49 | return (await getDetails(spotifyUrl)?.tracks) ?? []; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/frontend/src/app/components/playlist-box/playlist-box.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 |   8 | {{playlist.error}} 9 | 10 | {{playlist.name}} 11 |   12 | 15 | 16 | 17 | 18 | 19 |   24 |   25 |   26 |   27 | {{trackCompletedCount$ | async}}/{{trackCount$ | async}}  28 | 29 |

30 | 31 | 32 | 33 |
34 | -------------------------------------------------------------------------------- /src/frontend/src/app/components/playlist-box/playlist-box.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from '@angular/core'; 2 | import {AsyncPipe, CommonModule, NgIf} from "@angular/common"; 3 | import {TrackListComponent} from "../track-list/track-list.component"; 4 | import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service"; 5 | import {Observable, map} from "rxjs"; 6 | import {Playlist} from "../../models/playlist"; 7 | 8 | const STATUS2CLASS = { 9 | [PlaylistStatusEnum.Completed]: 'is-success', 10 | [PlaylistStatusEnum.InProgress]: 'is-info', 11 | [PlaylistStatusEnum.Warning]: 'is-warning', 12 | [PlaylistStatusEnum.Error]: 'is-danger', 13 | [PlaylistStatusEnum.Subscribed]: 'is-primary', 14 | } 15 | 16 | @Component({ 17 | selector: 'app-playlist-box', 18 | imports: [ 19 | CommonModule, 20 | AsyncPipe, 21 | NgIf, 22 | TrackListComponent 23 | ], 24 | templateUrl: './playlist-box.component.html', 25 | styleUrl: './playlist-box.component.scss', 26 | standalone: true 27 | }) 28 | export class PlaylistBoxComponent { 29 | 30 | @Input() set playlist(val: Playlist & PlaylistUi) { 31 | this._playlist = val; 32 | this.trackCount$ = this.service.getTrackCount(val.id); 33 | this.trackCompletedCount$ = this.service.getCompletedTrackCount(val.id); 34 | this.statusClass$ = this.service.getStatus$(val.id).pipe( 35 | map(status => STATUS2CLASS[status]) 36 | ); 37 | } 38 | get playlist(): Playlist & PlaylistUi { 39 | return this._playlist; 40 | } 41 | _playlist!: Playlist & PlaylistUi; 42 | trackCount$!: Observable; 43 | trackCompletedCount$!: Observable; 44 | statusClass$!: Observable; 45 | 46 | constructor(private readonly service: PlaylistService) { } 47 | 48 | toggleCollapse(playlistId: number): void { 49 | this.service.toggleCollapsed(playlistId); 50 | } 51 | 52 | delete(id: number): void { 53 | this.service.delete(id); 54 | } 55 | 56 | retryFailed(id: number): void { 57 | this.service.retryFailed(id); 58 | } 59 | 60 | toggleActive(id: number, currentActive: boolean): void { 61 | this.service.setActive(id, !currentActive) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { ScheduleModule } from '@nestjs/schedule'; 4 | import { ServeStaticModule } from '@nestjs/serve-static'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { TrackEntity } from './track/track.entity'; 8 | import { TrackModule } from './track/track.module'; 9 | import { PlaylistModule } from './playlist/playlist.module'; 10 | import { PlaylistEntity } from './playlist/playlist.entity'; 11 | import { resolve } from 'path'; 12 | import { EnvironmentEnum } from './environmentEnum'; 13 | import { BullModule } from '@nestjs/bullmq'; 14 | 15 | @Module({ 16 | imports: [ 17 | ConfigModule.forRoot({ isGlobal: true }), 18 | ScheduleModule.forRoot(), 19 | TypeOrmModule.forRootAsync({ 20 | imports: [ConfigModule], 21 | useFactory: async (configService: ConfigService) => ({ 22 | type: 'sqlite', 23 | database: resolve( 24 | __dirname, 25 | configService.get(EnvironmentEnum.DB_PATH), 26 | ), 27 | entities: [TrackEntity, PlaylistEntity], 28 | synchronize: true, 29 | }), 30 | inject: [ConfigService], 31 | }), 32 | ServeStaticModule.forRootAsync({ 33 | imports: [ConfigModule], 34 | useFactory: async (configService: ConfigService) => [ 35 | { 36 | rootPath: resolve( 37 | __dirname, 38 | configService.get(EnvironmentEnum.FE_PATH), 39 | ), 40 | exclude: ['/api/(.*)'], 41 | }, 42 | ], 43 | inject: [ConfigService], 44 | }), 45 | BullModule.forRootAsync({ 46 | imports: [ConfigModule], 47 | useFactory: async (configService: ConfigService) => ({ 48 | defaultJobOptions: { 49 | removeOnComplete: true, 50 | }, 51 | connection: { 52 | host: configService.get(EnvironmentEnum.REDIS_HOST), 53 | port: configService.get(EnvironmentEnum.REDIS_PORT), 54 | }, 55 | }), 56 | inject: [ConfigService], 57 | }), 58 | TrackModule, 59 | PlaylistModule, 60 | ], 61 | controllers: [AppController], 62 | providers: [], 63 | }) 64 | export class AppModule {} 65 | -------------------------------------------------------------------------------- /src/frontend/src/app/services/track.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {createStore} from "@ngneat/elf"; 3 | import {deleteEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities"; 4 | import {Socket} from "ngx-socket-io"; 5 | import {map, Observable, tap} from "rxjs"; 6 | import {HttpClient} from "@angular/common/http"; 7 | import {Track, TrackStatusEnum} from "../models/track"; 8 | 9 | const STORE_NAME = 'track'; 10 | const ENDPOINT = '/api/track'; 11 | enum WsTrackOperation { 12 | New = 'trackNew', 13 | Update = 'trackUpdate', 14 | Delete = 'trackDelete', 15 | } 16 | 17 | @Injectable({ 18 | providedIn: 'root' 19 | }) 20 | export class TrackService { 21 | 22 | private store = createStore( 23 | { name: STORE_NAME }, 24 | withEntities(), 25 | ); 26 | 27 | getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable { 28 | return this.store.pipe( 29 | selectManyByPredicate((track) => track?.playlistId === id), 30 | map(data => data.filter(item => status === undefined || item.status === status)), 31 | ); 32 | } 33 | 34 | getCompletedByPlaylist(id: number): Observable { 35 | return this.getAllByPlaylist(id, TrackStatusEnum.Completed); 36 | } 37 | 38 | getErrorByPlaylist(id: number): Observable { 39 | return this.getAllByPlaylist(id, TrackStatusEnum.Error); 40 | } 41 | 42 | constructor( 43 | private readonly http: HttpClient, 44 | private readonly socket: Socket, 45 | ) { 46 | this.initWsConnection(); 47 | } 48 | 49 | fetch(playlistId: number): void { 50 | this.http.get(`${ENDPOINT}/playlist/${playlistId}`).pipe( 51 | tap((data: Track[]) => this.store.update(upsertEntities(data.map(track => ({...track, playlistId}))))), 52 | ).subscribe(); 53 | } 54 | 55 | delete(id: number): void { 56 | this.http.delete(`${ENDPOINT}/${id}`).subscribe(); 57 | } 58 | 59 | retry(id: number): void { 60 | this.http.get(`${ENDPOINT}/retry/${id}`).subscribe(); 61 | } 62 | 63 | private initWsConnection(): void { 64 | this.socket.on(WsTrackOperation.Update, (track: Track) => this.store.update(upsertEntities(track))); 65 | this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => this.store.update(deleteEntities(id))); 66 | this.socket.on(WsTrackOperation.New, ({track, playlistId}: {track: Track, playlistId: number}) => 67 | this.store.update(upsertEntities([{...track, playlistId}])) 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/backend/src/shared/youtube.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { TrackEntity } from '../track/track.entity'; 3 | import { EnvironmentEnum } from '../environmentEnum'; 4 | import { TrackService } from '../track/track.service'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { YtDlp } from 'ytdlp-nodejs'; 7 | import * as yts from 'yt-search'; 8 | const NodeID3 = require('node-id3'); 9 | 10 | const HEADERS = { 11 | 'User-Agent': 12 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 13 | }; 14 | 15 | @Injectable() 16 | export class YoutubeService { 17 | private readonly logger = new Logger(TrackService.name); 18 | 19 | constructor(private readonly configService: ConfigService) {} 20 | 21 | async findOnYoutubeOne(artist: string, name: string): Promise { 22 | this.logger.debug(`Searching ${artist} - ${name} on YT`); 23 | const url = (await yts(`${artist} - ${name}`)).videos[0].url; 24 | this.logger.debug(`Found ${artist} - ${name} on ${url}`); 25 | return url; 26 | } 27 | 28 | async downloadAndFormat( 29 | track: TrackEntity, 30 | output: string, 31 | ): Promise { 32 | this.logger.debug( 33 | `Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`, 34 | ); 35 | if (!track.youtubeUrl) { 36 | this.logger.error('youtubeUrl is null or undefined'); 37 | throw Error('youtubeUrl is null or undefined'); 38 | } 39 | const ytdlp = new YtDlp(); 40 | await ytdlp.downloadAsync(track.youtubeUrl, { 41 | format: { 42 | filter: 'audioonly', 43 | type: this.configService.get<'m4a'>(EnvironmentEnum.FORMAT), 44 | quality: 0, 45 | }, 46 | output, 47 | cookiesFromBrowser: this.configService.get('YT_COOKIES'), 48 | headers: HEADERS, 49 | }); 50 | this.logger.debug( 51 | `Downloaded ${track.artist} - ${track.name} to ${output}`, 52 | ); 53 | } 54 | 55 | async addImage( 56 | folderName: string, 57 | coverUrl: string, 58 | title: string, 59 | artist: string, 60 | ): Promise { 61 | if (coverUrl) { 62 | const res = await fetch(coverUrl); 63 | const arrayBuf = await res.arrayBuffer(); 64 | const imageBuffer = Buffer.from(arrayBuf); 65 | 66 | NodeID3.write( 67 | { 68 | title, 69 | artist, 70 | APIC: { 71 | mime: 'image/jpeg', 72 | type: { id: 3, name: 'front cover' }, 73 | description: 'cover', 74 | imageBuffer, 75 | }, 76 | }, 77 | folderName, 78 | ); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "2.2.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node ../../dist/backend/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json", 21 | "gen": "nest generate" 22 | }, 23 | "dependencies": { 24 | "@nestjs/bullmq": "^10.2.3", 25 | "@nestjs/common": "^10.0.0", 26 | "@nestjs/config": "^3.2.2", 27 | "@nestjs/core": "^10.0.0", 28 | "@nestjs/platform-express": "^10.0.0", 29 | "@nestjs/platform-socket.io": "^10.3.10", 30 | "@nestjs/schedule": "^4.0.2", 31 | "@nestjs/serve-static": "^4.0.2", 32 | "@nestjs/typeorm": "^10.0.2", 33 | "@nestjs/websockets": "^10.3.10", 34 | "@types/fluent-ffmpeg": "^2.1.24", 35 | "@types/yt-search": "^2.10.3", 36 | "bullmq": "^5.31.2", 37 | "fluent-ffmpeg": "^2.1.3", 38 | "isomorphic-unfetch": "^4.0.2", 39 | "node-id3": "^0.2.9", 40 | "reflect-metadata": "^0.2.0", 41 | "rxjs": "^7.8.1", 42 | "spotify-url-info": "^3.2.18", 43 | "sqlite3": "^5.1.7", 44 | "yt-search": "^2.12.1", 45 | "ytdlp-nodejs": "^2.3.5" 46 | }, 47 | "devDependencies": { 48 | "@nestjs/cli": "^10.0.0", 49 | "@nestjs/schematics": "^10.0.0", 50 | "@nestjs/testing": "^10.0.0", 51 | "@types/express": "^4.17.17", 52 | "@types/jest": "^29.5.2", 53 | "@types/node": "^20.3.1", 54 | "@types/supertest": "^6.0.0", 55 | "@typescript-eslint/eslint-plugin": "^6.0.0", 56 | "@typescript-eslint/parser": "^6.0.0", 57 | "eslint": "^8.42.0", 58 | "eslint-config-prettier": "^9.0.0", 59 | "eslint-plugin-prettier": "^5.0.0", 60 | "jest": "^29.5.0", 61 | "prettier": "^3.0.0", 62 | "source-map-support": "^0.5.21", 63 | "supertest": "^6.3.3", 64 | "ts-jest": "^29.1.0", 65 | "ts-loader": "^9.4.3", 66 | "ts-node": "^10.9.1", 67 | "tsconfig-paths": "^4.2.0", 68 | "typescript": "^5.1.3" 69 | }, 70 | "jest": { 71 | "moduleFileExtensions": [ 72 | "js", 73 | "json", 74 | "ts" 75 | ], 76 | "rootDir": "src", 77 | "testRegex": ".*\\.spec\\.ts$", 78 | "transform": { 79 | "^.+\\.(t|j)s$": "ts-jest" 80 | }, 81 | "collectCoverageFrom": [ 82 | "**/*.(t|j)s" 83 | ], 84 | "coverageDirectory": "../coverage", 85 | "testEnvironment": "node" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/frontend/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "spooty-fe": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "outputPath": "../../dist/frontend", 21 | "index": "src/index.html", 22 | "browser": "src/main.ts", 23 | "polyfills": [ 24 | "zone.js" 25 | ], 26 | "tsConfig": "tsconfig.app.json", 27 | "inlineStyleLanguage": "scss", 28 | "assets": [ 29 | { 30 | "glob": "**/*", 31 | "input": "public" 32 | } 33 | ], 34 | "styles": [ 35 | "src/styles.scss" 36 | ], 37 | "scripts": [] 38 | }, 39 | "configurations": { 40 | "production": { 41 | "budgets": [ 42 | { 43 | "type": "initial", 44 | "maximumWarning": "500kB", 45 | "maximumError": "2MB" 46 | }, 47 | { 48 | "type": "anyComponentStyle", 49 | "maximumWarning": "2kB", 50 | "maximumError": "4kB" 51 | } 52 | ], 53 | "outputHashing": "all" 54 | }, 55 | "development": { 56 | "optimization": false, 57 | "extractLicenses": false, 58 | "sourceMap": true 59 | } 60 | }, 61 | "defaultConfiguration": "production" 62 | }, 63 | "serve": { 64 | "builder": "@angular-devkit/build-angular:dev-server", 65 | "configurations": { 66 | "production": { 67 | "buildTarget": "spooty-fe:build:production" 68 | }, 69 | "development": { 70 | "buildTarget": "spooty-fe:build:development" 71 | } 72 | }, 73 | "defaultConfiguration": "development" 74 | }, 75 | "extract-i18n": { 76 | "builder": "@angular-devkit/build-angular:extract-i18n" 77 | }, 78 | "test": { 79 | "builder": "@angular-devkit/build-angular:karma", 80 | "options": { 81 | "polyfills": [ 82 | "zone.js", 83 | "zone.js/testing" 84 | ], 85 | "tsConfig": "tsconfig.spec.json", 86 | "inlineStyleLanguage": "scss", 87 | "assets": [ 88 | { 89 | "glob": "**/*", 90 | "input": "public" 91 | } 92 | ], 93 | "styles": [ 94 | "src/styles.scss" 95 | ], 96 | "scripts": [] 97 | } 98 | } 99 | } 100 | } 101 | }, 102 | "cli": { 103 | "analytics": false 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 42 | 44 | 53 | 58 | 63 | 66 | 69 | 87 | 97 | 98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /src/backend/src/track/track.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { TrackEntity, TrackStatusEnum } from './track.entity'; 5 | import { PlaylistEntity } from '../playlist/playlist.entity'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import { resolve } from 'path'; 8 | import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; 9 | import { Server } from 'socket.io'; 10 | import { EnvironmentEnum } from '../environmentEnum'; 11 | import { UtilsService } from '../shared/utils.service'; 12 | import { Queue } from 'bullmq'; 13 | import { InjectQueue } from '@nestjs/bullmq'; 14 | import { YoutubeService } from '../shared/youtube.service'; 15 | 16 | enum WsTrackOperation { 17 | New = 'trackNew', 18 | Update = 'trackUpdate', 19 | Delete = 'trackDelete', 20 | } 21 | 22 | @WebSocketGateway() 23 | @Injectable() 24 | export class TrackService { 25 | @WebSocketServer() io: Server; 26 | private readonly logger = new Logger(TrackService.name); 27 | 28 | constructor( 29 | @InjectRepository(TrackEntity) 30 | private repository: Repository, 31 | @InjectQueue('track-download-processor') private trackDownloadQueue: Queue, 32 | @InjectQueue('track-search-processor') private trackSearchQueue: Queue, 33 | private readonly configService: ConfigService, 34 | private readonly utilsService: UtilsService, 35 | private readonly youtubeService: YoutubeService, 36 | ) {} 37 | 38 | getAll( 39 | where?: { [key: string]: any }, 40 | relations: Record = {}, 41 | ): Promise { 42 | return this.repository.find({ where, relations }); 43 | } 44 | 45 | getAllByPlaylist(id: number): Promise { 46 | return this.repository.find({ where: { playlist: { id } } }); 47 | } 48 | 49 | get(id: number): Promise { 50 | return this.repository.findOne({ where: { id }, relations: ['playlist'] }); 51 | } 52 | 53 | async remove(id: number): Promise { 54 | await this.repository.delete(id); 55 | this.io.emit(WsTrackOperation.Delete, { id }); 56 | } 57 | 58 | async create(track: TrackEntity, playlist?: PlaylistEntity): Promise { 59 | const savedTrack = await this.repository.save({ ...track, playlist }); 60 | await this.trackSearchQueue.add('', savedTrack, { 61 | jobId: `id-${savedTrack.id}`, 62 | }); 63 | this.io.emit(WsTrackOperation.New, { 64 | track: savedTrack, 65 | playlistId: playlist.id, 66 | }); 67 | } 68 | 69 | async update(id: number, track: TrackEntity): Promise { 70 | await this.repository.update(id, track); 71 | this.io.emit(WsTrackOperation.Update, track); 72 | } 73 | 74 | async retry(id: number): Promise { 75 | const track = await this.get(id); 76 | await this.trackSearchQueue.add('', track, { jobId: `id-${id}` }); 77 | await this.update(id, { ...track, status: TrackStatusEnum.New }); 78 | } 79 | 80 | async findOnYoutube(track: TrackEntity): Promise { 81 | if (!(await this.get(track.id))) { 82 | return; 83 | } 84 | await this.update(track.id, { 85 | ...track, 86 | status: TrackStatusEnum.Searching, 87 | }); 88 | let updatedTrack: TrackEntity; 89 | try { 90 | const youtubeUrl = await this.youtubeService.findOnYoutubeOne( 91 | track.artist, 92 | track.name, 93 | ); 94 | updatedTrack = { ...track, youtubeUrl, status: TrackStatusEnum.Queued }; 95 | } catch (err) { 96 | this.logger.error(err); 97 | updatedTrack = { 98 | ...track, 99 | error: String(err), 100 | status: TrackStatusEnum.Error, 101 | }; 102 | } 103 | await this.trackDownloadQueue.add('', updatedTrack, { 104 | jobId: `id-${updatedTrack.id}`, 105 | }); 106 | await this.update(track.id, updatedTrack); 107 | } 108 | 109 | async downloadFromYoutube(track: TrackEntity): Promise { 110 | if (!(await this.get(track.id))) { 111 | return; 112 | } 113 | if ( 114 | !track.name || 115 | !track.artist || 116 | !track.playlist || 117 | !track.playlist.coverUrl 118 | ) { 119 | this.logger.error( 120 | `Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}, coverUrl=${track.playlist?.coverUrl}`, 121 | ); 122 | return; 123 | } 124 | await this.update(track.id, { 125 | ...track, 126 | status: TrackStatusEnum.Downloading, 127 | }); 128 | let error: string; 129 | try { 130 | const folderName = this.getFolderName(track, track.playlist); 131 | await this.youtubeService.downloadAndFormat(track, folderName); 132 | await this.youtubeService.addImage( 133 | folderName, 134 | track.playlist.coverUrl, 135 | track.name, 136 | track.artist, 137 | ); 138 | } catch (err) { 139 | this.logger.error(err); 140 | error = String(err); 141 | } 142 | const updatedTrack = { 143 | ...track, 144 | status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed, 145 | ...(error ? { error } : {}), 146 | }; 147 | await this.update(track.id, updatedTrack); 148 | } 149 | 150 | getTrackFileName(track: TrackEntity): string { 151 | const safeArtist = track.artist || 'unknown_artist'; 152 | const safeName = (track.name || 'unknown_track').replace('/', ''); 153 | const fileName = `${safeArtist} - ${safeName}`; 154 | return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get(EnvironmentEnum.FORMAT)}`; 155 | } 156 | 157 | getFolderName(track: TrackEntity, playlist: PlaylistEntity): string { 158 | const safePlaylistName = playlist?.name || 'unknown_playlist'; 159 | return resolve( 160 | this.utilsService.getPlaylistFolderPath(safePlaylistName), 161 | this.getTrackFileName(track), 162 | ); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/frontend/src/app/services/playlist.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {createStore} from "@ngneat/elf"; 3 | import {HttpClient} from "@angular/common/http"; 4 | import { 5 | deleteEntities, 6 | selectAllEntities, selectEntities, selectEntity, 7 | setEntities, 8 | UIEntitiesRef, 9 | unionEntities, updateEntities, upsertEntities, 10 | withEntities, 11 | withUIEntities 12 | } from "@ngneat/elf-entities"; 13 | import {joinRequestResult, trackRequestResult} from "@ngneat/elf-requests"; 14 | import {combineLatest, filter, first, map, Observable, of, switchMap, tap} from "rxjs"; 15 | import {TrackService} from "./track.service"; 16 | import {Socket} from "ngx-socket-io"; 17 | import {Playlist} from "../models/playlist"; 18 | 19 | const STORE_NAME = 'playlist'; 20 | const ENDPOINT = '/api/playlist'; 21 | const CREATE_LOADING = 'CREATE_LOADING'; 22 | enum WsPlaylistOperation { 23 | New = 'playlistNew', 24 | Update = 'playlistUpdate', 25 | Delete = 'playlistDelete', 26 | } 27 | 28 | export interface PlaylistUi { 29 | id: number, 30 | collapsed: boolean; 31 | } 32 | 33 | export enum PlaylistStatusEnum { 34 | InProgress, 35 | Completed, 36 | Warning, 37 | Error, 38 | Subscribed, 39 | } 40 | 41 | @Injectable({ 42 | providedIn: 'root' 43 | }) 44 | export class PlaylistService { 45 | 46 | private store = createStore( 47 | { name: STORE_NAME }, 48 | withEntities(), 49 | withUIEntities() 50 | ); 51 | 52 | all$ = this.store.combine({ 53 | entities: this.store.pipe(selectAllEntities()), 54 | UIEntities: this.store.pipe(selectEntities({ ref: UIEntitiesRef })), 55 | }).pipe(unionEntities(), map(data => data.sort((a, b) => this.groupActiveAndSortByCreation(a, b)))); 56 | createLoading$ = this.store.pipe(joinRequestResult([CREATE_LOADING], { initialStatus: 'idle' })); 57 | 58 | constructor(private readonly http: HttpClient, 59 | private readonly socket: Socket, 60 | private readonly trackService: TrackService, 61 | ) { 62 | this.initWsConnection(); 63 | } 64 | 65 | getById(id: number): Observable { 66 | return this.store.pipe(selectEntity(id)); 67 | } 68 | 69 | getTrackCount(id: number): Observable { 70 | return this.trackService.getAllByPlaylist(id).pipe(map(data => data.length)); 71 | } 72 | 73 | getCompletedTrackCount(id: number): Observable { 74 | return this.trackService.getCompletedByPlaylist(id).pipe(map(data => data.length)); 75 | } 76 | 77 | getErrorTrackCount(id: number): Observable { 78 | return this.trackService.getErrorByPlaylist(id).pipe(map(data => data.length)); 79 | } 80 | 81 | getStatus$(id: number): Observable { 82 | return combineLatest([ 83 | this.getById(id), 84 | this.getTrackCount(id), 85 | this.getCompletedTrackCount(id), 86 | this.getErrorTrackCount(id), 87 | ]).pipe(map(([playlist, trackCount, completedCount, errorCount]) => { 88 | if (playlist?.error || errorCount === trackCount) { 89 | return PlaylistStatusEnum.Error; 90 | } else if (trackCount === completedCount) { 91 | return playlist?.active ? PlaylistStatusEnum.Subscribed : PlaylistStatusEnum.Completed; 92 | } else if (errorCount > 1) { 93 | return PlaylistStatusEnum.Warning; 94 | } 95 | return PlaylistStatusEnum.InProgress; 96 | })); 97 | } 98 | 99 | deleteAllByStatus(status: PlaylistStatusEnum): void { 100 | this.all$.pipe( 101 | first(), 102 | switchMap(playlists => 103 | combineLatest(playlists.map(item => this.deleteIfStatusEquals$(item.id, status))) 104 | ) 105 | ).subscribe(); 106 | } 107 | 108 | private deleteIfStatusEquals$(id: number, status2Filter: PlaylistStatusEnum): Observable { 109 | return combineLatest([of(id), this.getStatus$(id)]).pipe( 110 | first(), 111 | filter(([_, status]) => status === status2Filter), 112 | switchMap(([id]) => this.delete$(id)), 113 | ); 114 | } 115 | 116 | fetch(): void { 117 | this.http.get(ENDPOINT).pipe( 118 | tap((data: Playlist[]) => this.store.update( 119 | setEntities(data), 120 | setEntities(data.map(item => ({id: item.id, collapsed: false})), {ref: UIEntitiesRef}) 121 | )), 122 | tap((data: Playlist[]) => data.forEach(playlist => this.trackService.fetch(playlist.id))), 123 | ).subscribe(); 124 | } 125 | 126 | create(spotifyUrl: string): void { 127 | this.http.post(ENDPOINT, {spotifyUrl}).pipe( 128 | trackRequestResult([CREATE_LOADING], { skipCache: true }) 129 | ).subscribe(); 130 | } 131 | 132 | toggleCollapsed(id: number): void { 133 | this.store.update(updateEntities(id, old => ({...old, collapsed: !old.collapsed}), { ref: UIEntitiesRef })); 134 | } 135 | 136 | delete(id: number): void { 137 | this.delete$(id).subscribe(); 138 | } 139 | 140 | retryFailed(id: number): void { 141 | this.http.get(`${ENDPOINT}/retry/${id}`).subscribe(); 142 | } 143 | 144 | setActive(id: number, active: boolean): void { 145 | this.http.put(`${ENDPOINT}/${id}`, {active}).subscribe(); 146 | } 147 | 148 | private delete$(id: number): Observable { 149 | return this.http.delete(`${ENDPOINT}/${id}`); 150 | } 151 | 152 | private groupActiveAndSortByCreation(a: Playlist & PlaylistUi, b: Playlist & PlaylistUi): number { 153 | return a.active === b.active ? (b.createdAt - a.createdAt) : (a.active < b.active ? 1 : -1); 154 | } 155 | 156 | private initWsConnection(): void { 157 | this.socket.on(WsPlaylistOperation.Update, (playlist: Playlist) => this.store.update(upsertEntities(playlist))); 158 | this.socket.on(WsPlaylistOperation.Delete, ({id}: {id: number}) => this.store.update(deleteEntities(Number(id)))); 159 | this.socket.on(WsPlaylistOperation.New, (playlist: Playlist) => 160 | this.store.update( 161 | upsertEntities(playlist), 162 | upsertEntities({id: playlist.id, collapsed: false}, {ref: UIEntitiesRef}) 163 | ) 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/backend/src/playlist/playlist.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { PlaylistEntity } from './playlist.entity'; 5 | import { TrackService } from '../track/track.service'; 6 | import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; 7 | import { Server } from 'socket.io'; 8 | import * as fs from 'fs'; 9 | import { Interval } from '@nestjs/schedule'; 10 | import { TrackStatusEnum } from '../track/track.entity'; 11 | import { UtilsService } from '../shared/utils.service'; 12 | import { SpotifyService } from '../shared/spotify.service'; 13 | 14 | enum WsPlaylistOperation { 15 | New = 'playlistNew', 16 | Update = 'playlistUpdate', 17 | Delete = 'playlistDelete', 18 | } 19 | 20 | @WebSocketGateway() 21 | @Injectable() 22 | export class PlaylistService { 23 | @WebSocketServer() io: Server; 24 | private readonly logger = new Logger(TrackService.name); 25 | 26 | constructor( 27 | @InjectRepository(PlaylistEntity) 28 | private repository: Repository, 29 | private readonly trackService: TrackService, 30 | private readonly utilsService: UtilsService, 31 | private readonly spotifyService: SpotifyService, 32 | ) {} 33 | 34 | findAll( 35 | relations: Record = { tracks: true }, 36 | where?: Partial, 37 | ): Promise { 38 | return this.repository.find({ where, relations }); 39 | } 40 | 41 | findOne(id: number): Promise { 42 | return this.repository.findOneBy({ id }); 43 | } 44 | 45 | async remove(id: number): Promise { 46 | await this.repository.delete(id); 47 | this.io.emit(WsPlaylistOperation.Delete, { id }); 48 | } 49 | 50 | async create(playlist: PlaylistEntity): Promise { 51 | let detail: { tracks: any; name: any; image: any }; 52 | let playlist2Save: PlaylistEntity; 53 | try { 54 | detail = await this.spotifyService.getPlaylistDetail(playlist.spotifyUrl); 55 | this.logger.debug( 56 | `Playlist detail retrieved with ${detail.tracks?.length || 0} tracks`, 57 | ); 58 | 59 | playlist2Save = { 60 | ...playlist, 61 | name: detail.name, 62 | coverUrl: detail.image, 63 | }; 64 | this.createPlaylistFolderStructure(playlist2Save.name); 65 | } catch (err) { 66 | this.logger.error(`Error getting playlist details: ${err}`); 67 | playlist2Save = { ...playlist, error: String(err) }; 68 | } 69 | const savedPlaylist = await this.save(playlist2Save); 70 | 71 | if (detail?.tracks && detail.tracks.length > 0) { 72 | this.logger.debug( 73 | `Starting to process ${detail.tracks.length} tracks for playlist ${savedPlaylist.name}`, 74 | ); 75 | 76 | let processedCount = 0; 77 | let skippedCount = 0; 78 | let errorCount = 0; 79 | 80 | for (const track of detail.tracks) { 81 | try { 82 | if (!track.artist || !track.name) { 83 | this.logger.warn( 84 | `Skipping track ${processedCount + skippedCount + 1}: Missing artist or name information`, 85 | ); 86 | skippedCount++; 87 | continue; 88 | } 89 | 90 | if (track.unavailable === true) { 91 | this.logger.warn( 92 | `Skipping unavailable track ${processedCount + skippedCount + 1}: ${track.artist} - ${track.name}`, 93 | ); 94 | skippedCount++; 95 | continue; 96 | } 97 | 98 | await this.trackService.create( 99 | { 100 | artist: track.artist, 101 | name: track.name, 102 | spotifyUrl: track.previewUrl || null, 103 | }, 104 | savedPlaylist, 105 | ); 106 | 107 | processedCount++; 108 | 109 | if (processedCount % 100 === 0) { 110 | this.logger.debug( 111 | `Processed ${processedCount} tracks so far for playlist ${savedPlaylist.name}`, 112 | ); 113 | } 114 | } catch (error) { 115 | this.logger.error( 116 | `Error creating track "${ 117 | track?.artist || 'Unknown' 118 | } - ${track?.name || 'Unknown'}": ${error.message}`, 119 | ); 120 | errorCount++; 121 | } 122 | } 123 | 124 | this.logger.debug( 125 | `Finished processing playlist ${savedPlaylist.name}: ` + 126 | `${processedCount} tracks processed, ${skippedCount} skipped, ${errorCount} errors`, 127 | ); 128 | } else { 129 | this.logger.warn(`No tracks found for playlist ${savedPlaylist.name}`); 130 | } 131 | } 132 | 133 | async save(playlist: PlaylistEntity): Promise { 134 | const savedPlaylist = await this.repository.save(playlist); 135 | this.io.emit(WsPlaylistOperation.New, savedPlaylist); 136 | return savedPlaylist; 137 | } 138 | 139 | async update(id: number, playlist: Partial): Promise { 140 | await this.repository.update(id, playlist); 141 | const dbPlaylist = await this.findOne(id); 142 | this.io.emit(WsPlaylistOperation.Update, dbPlaylist); 143 | } 144 | 145 | async retryFailedOfPlaylist(id: number): Promise { 146 | const tracks = await this.trackService.getAllByPlaylist(id); 147 | for (const track of tracks) { 148 | if (track.status === TrackStatusEnum.Error) { 149 | await this.trackService.retry(track.id); 150 | } 151 | } 152 | } 153 | 154 | private createPlaylistFolderStructure(playlistName: string): void { 155 | const playlistPath = this.utilsService.getPlaylistFolderPath(playlistName); 156 | !fs.existsSync(playlistPath) && fs.mkdirSync(playlistPath); 157 | } 158 | 159 | @Interval(3_600_000) 160 | async checkActivePlaylists(): Promise { 161 | const activePlaylists = await this.findAll({}, { active: true }); 162 | for (const playlist of activePlaylists) { 163 | let tracks = []; 164 | try { 165 | tracks = await this.spotifyService.getPlaylistTracks( 166 | playlist.spotifyUrl, 167 | ); 168 | this.createPlaylistFolderStructure(playlist.name); 169 | } catch (err) { 170 | await this.update(playlist.id, { ...playlist, error: String(err) }); 171 | } 172 | for (const track of tracks ?? []) { 173 | const track2Save = { 174 | artist: track.artist, 175 | name: track.name, 176 | spotifyUrl: track.previewUrl, 177 | }; 178 | const isExist = !!( 179 | await this.trackService.getAll({ 180 | ...track2Save, 181 | playlist: { id: playlist.id }, 182 | }) 183 | ).length; 184 | if (!isExist) { 185 | await this.trackService.create(track2Save, playlist); 186 | } 187 | } 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | #### [2.2.1](https://github.com/Raiper34/spooty/compare/2.2.0...2.2.1) 6 | 7 | - fix(downloading): migrate from @distube/ytdl-core to ytdlp-nodejs yt downloading library [`5795f7c`](https://github.com/Raiper34/spooty/commit/5795f7cc178ab4d8a8d3d8a7e94f33743ff6e9e0) 8 | - fix(docker): add python3 dependency into docker image for yt download library [`46d7c66`](https://github.com/Raiper34/spooty/commit/46d7c6699bc8698896b22837e8507885677a71bd) 9 | - docs(readme): fix yt cookies section heading [`285578f`](https://github.com/Raiper34/spooty/commit/285578ff1ed1d7fe14d7ddd0d95a4b8079276e4e) 10 | - docs(readme): add docker versio badge into readme [`4be3743`](https://github.com/Raiper34/spooty/commit/4be3743f7b5a8019bd6ed0d2248f23a254a82603) 11 | 12 | #### [2.2.0](https://github.com/Raiper34/spooty/compare/2.1.1...2.2.0) 13 | 14 | > 17 October 2025 15 | 16 | - feat(spotify): integrate Spotify API for playlist metadata and track retrieval [`de55e42`](https://github.com/Raiper34/spooty/commit/de55e42fcd246d86e8d3156d8e0ab4898b5755d6) 17 | - feat(youtube): use youtube cookies to bypass limitation & add timeout between each downloads [`4c05178`](https://github.com/Raiper34/spooty/commit/4c051782fd8808a808fe49fe1509ec0b0db9d05e) 18 | 19 | #### [2.1.1](https://github.com/Raiper34/spooty/compare/2.1.0...2.1.1) 20 | 21 | > 13 August 2025 22 | 23 | - feat(gui): add version to gui header [`#25`](https://github.com/Raiper34/spooty/issues/25) 24 | - fix(track): fix track missing artist and title tags [`b93993c`](https://github.com/Raiper34/spooty/commit/b93993c6cf034dccad3df08485d2126614874743) 25 | - style(gui): change gui primary to match spotify primary color [`5068fa8`](https://github.com/Raiper34/spooty/commit/5068fa8a4b724ef4a1060dc4d83cc80afee08bad) 26 | 27 | #### [2.1.0](https://github.com/Raiper34/spooty/compare/2.0.11...2.1.0) 28 | 29 | > 12 August 2025 30 | 31 | - feat(track): add track cover art into downloaded file [`#24`](https://github.com/Raiper34/spooty/issues/24) 32 | 33 | #### [2.0.11](https://github.com/Raiper34/spooty/compare/2.0.10...2.0.11) 34 | 35 | > 14 June 2025 36 | 37 | - fix(ytdl): Upgrade ytdl package (automated) [`fe79302`](https://github.com/Raiper34/spooty/commit/fe79302c2a747093df39405e42c06c08957acc3c) 38 | 39 | #### [2.0.10](https://github.com/Raiper34/spooty/compare/2.0.9...2.0.10) 40 | 41 | > 4 June 2025 42 | 43 | - fix(ytdl): Upgrade ytdl package (automated) [`f74a4d2`](https://github.com/Raiper34/spooty/commit/f74a4d2ed9da50e29f4e44b0027b2891c7473ec6) 44 | 45 | #### [2.0.9](https://github.com/Raiper34/spooty/compare/2.0.8...2.0.9) 46 | 47 | > 8 May 2025 48 | 49 | - fix(ytdl): Upgrade ytdl package (automated) [`cc6a201`](https://github.com/Raiper34/spooty/commit/cc6a20196d474152eecaeb9862edfb3505ed3106) 50 | 51 | #### [2.0.8](https://github.com/Raiper34/spooty/compare/2.0.7...2.0.8) 52 | 53 | > 27 April 2025 54 | 55 | - docs(readme): remove docs, netlify and docsify, keep only readme docs for now [`bc9482c`](https://github.com/Raiper34/spooty/commit/bc9482c6cb9a47aa0d96fa34866546ec552d1e3c) 56 | - fix(ytdl): Upgrade ytdl package (automated) [`17b875e`](https://github.com/Raiper34/spooty/commit/17b875e2825cb739767565be5d86aba3a48a9208) 57 | 58 | #### [2.0.7](https://github.com/Raiper34/spooty/compare/2.0.6...2.0.7) 59 | 60 | > 5 April 2025 61 | 62 | - fix(ytdl): Upgrade ytdl package (automated) [`9653db5`](https://github.com/Raiper34/spooty/commit/9653db54ea48e93526f2124b11b8012d987b7bcc) 63 | 64 | #### [2.0.6](https://github.com/Raiper34/spooty/compare/2.0.5...2.0.6) 65 | 66 | > 1 April 2025 67 | 68 | - ci(netlify): remove netlify cli from deps [`e4cde85`](https://github.com/Raiper34/spooty/commit/e4cde8585ba0dfc422cec89360612947ebe92ff1) 69 | - fix(ytdl): Upgrade ytdl package (automated) [`5f30f31`](https://github.com/Raiper34/spooty/commit/5f30f3116f34ceb48d00e3abd5c606e8f9b7caba) 70 | - feat(names): strip special characters from file and folder names [`6f39c1e`](https://github.com/Raiper34/spooty/commit/6f39c1e5dc4b8f87917a18d2a23cec6b5cd9ca60) 71 | - ci(github-actions): fix github actions update ytdl script [`04d8987`](https://github.com/Raiper34/spooty/commit/04d8987aa51a8887bf1b8b41e8680c2db9530bef) 72 | 73 | #### [2.0.5](https://github.com/Raiper34/spooty/compare/2.0.4...2.0.5) 74 | 75 | > 20 March 2025 76 | 77 | - ci(docker): fix docker buildx in release-it for github actions [`db65823`](https://github.com/Raiper34/spooty/commit/db65823ce0794280cb373a4381e24ad704a4db56) 78 | - fix(ytdl): Upgrade ytdl package (automated) [`16c57aa`](https://github.com/Raiper34/spooty/commit/16c57aaefd599f04b0a88723d55751f4ba27383a) 79 | 80 | #### [2.0.4](https://github.com/Raiper34/spooty/compare/2.0.3...2.0.4) 81 | 82 | > 15 March 2025 83 | 84 | - fix(ytdl): Upgrade ytdl package (automated) [`b960dc8`](https://github.com/Raiper34/spooty/commit/b960dc88db2a44c5865cb5c6f5964e4b4ffedc3f) 85 | 86 | #### [2.0.3](https://github.com/Raiper34/spooty/compare/2.0.2...2.0.3) 87 | 88 | > 15 March 2025 89 | 90 | - fix(ytdl): Upgrade ytdl package (automated) [`844d026`](https://github.com/Raiper34/spooty/commit/844d02668512cb8446f27f5629412fb5331b1298) 91 | 92 | #### [2.0.2](https://github.com/Raiper34/spooty/compare/2.0.1...2.0.2) 93 | 94 | > 15 March 2025 95 | 96 | - fix(ytdl): Upgrade ytdl package (automated) [`cf559eb`](https://github.com/Raiper34/spooty/commit/cf559eb41a2eb84cd29d4a732d68885f4ebcf348) 97 | 98 | #### [2.0.1](https://github.com/Raiper34/spooty/compare/2.0.0...2.0.1) 99 | 100 | > 23 February 2025 101 | 102 | - build(ytdl): Upgrade ytdl package (automated) [`#20`](https://github.com/Raiper34/spooty/pull/20) 103 | - refactor(backend): queue system presented [`#5`](https://github.com/Raiper34/spooty/issues/5) 104 | - fix(utdl): upgrade ytdl package [`66b55b3`](https://github.com/Raiper34/spooty/commit/66b55b39432f2da71bd401fd65af6c0a207dd6c2) 105 | - ci(github-actions): add automatic ytdl update into github actions [`2ca165b`](https://github.com/Raiper34/spooty/commit/2ca165b59a76147439d4d00055b84102cf7a7317) 106 | - ci(github-actions): add automatic ytdl update into github actions [`9baf3b9`](https://github.com/Raiper34/spooty/commit/9baf3b9eb7b093029300614bc9c1a913a73fb003) 107 | - ci(github-actions): add automatic ytdl update into github actions [`b2fbb01`](https://github.com/Raiper34/spooty/commit/b2fbb01a7415c4027aa50dd2a1773d0b1e0dde3a) 108 | - docs(website): change default environment variable for REDIS_RUN var [`f539a22`](https://github.com/Raiper34/spooty/commit/f539a227575569ce5641f5dfec6fcf50598a491d) 109 | - ci(github-actions): add automatic ytdl update into github actions [`cbe2c9f`](https://github.com/Raiper34/spooty/commit/cbe2c9fbdc8322881b6ae3fd90586b7656d155ec) 110 | - ci(github-actions): add automatic ytdl update into github actions [`a55ebf6`](https://github.com/Raiper34/spooty/commit/a55ebf659974137f96a465b74a8a0c8b649b3399) 111 | - ci(github-actions): add automatic ytdl update into github actions [`66516e6`](https://github.com/Raiper34/spooty/commit/66516e6916af1b1e29df13d870f775f5c7ce43c2) 112 | 113 | 114 | 115 | ### 2.0.0 116 | - refactor(backend): queue system presented 117 | 118 | ### 1.0.0 119 | - initial release -------------------------------------------------------------------------------- /src/backend/src/shared/spotify-api.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const fetch = require('isomorphic-unfetch'); 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | const { getDetails } = require('spotify-url-info')(fetch); 6 | 7 | @Injectable() 8 | export class SpotifyApiService { 9 | private readonly logger = new Logger(SpotifyApiService.name); 10 | private accessToken: string | null = null; 11 | private tokenExpiry: number = 0; 12 | 13 | constructor() {} 14 | 15 | private getPlaylistId(url: string): string { 16 | try { 17 | const urlObj = new URL(url); 18 | const pathParts = urlObj.pathname.split('/'); 19 | const playlistIndex = pathParts.findIndex((part) => part === 'playlist'); 20 | if (playlistIndex >= 0 && pathParts.length > playlistIndex + 1) { 21 | return pathParts[playlistIndex + 1].split('?')[0]; 22 | } 23 | throw new Error('Invalid Spotify playlist URL'); 24 | } catch (error) { 25 | this.logger.error(`Failed to extract playlist ID: ${error.message}`); 26 | throw error; 27 | } 28 | } 29 | 30 | async getPlaylistMetadata( 31 | spotifyUrl: string, 32 | ): Promise<{ name: string; image: string }> { 33 | try { 34 | this.logger.debug(`Getting playlist metadata for ${spotifyUrl}`); 35 | const detail = await getDetails(spotifyUrl); 36 | 37 | return { 38 | name: detail.preview.title, 39 | image: detail.preview.image, 40 | }; 41 | } catch (error) { 42 | this.logger.error(`Failed to get playlist metadata: ${error.message}`); 43 | throw error; 44 | } 45 | } 46 | 47 | private async getAccessToken(): Promise { 48 | if (this.accessToken && Date.now() < this.tokenExpiry) { 49 | return this.accessToken; 50 | } 51 | 52 | try { 53 | this.logger.debug('Getting new Spotify access token'); 54 | 55 | const clientId = process.env.SPOTIFY_CLIENT_ID; 56 | const clientSecret = process.env.SPOTIFY_CLIENT_SECRET; 57 | 58 | if (!clientId || !clientSecret) { 59 | throw new Error( 60 | 'Missing Spotify credentials. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env file', 61 | ); 62 | } 63 | 64 | const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString( 65 | 'base64', 66 | ); 67 | 68 | const response = await fetch('https://accounts.spotify.com/api/token', { 69 | method: 'POST', 70 | headers: { 71 | Authorization: `Basic ${credentials}`, 72 | 'Content-Type': 'application/x-www-form-urlencoded', 73 | }, 74 | body: 'grant_type=client_credentials', 75 | }); 76 | 77 | if (!response.ok) { 78 | const errorData = await response.text(); 79 | throw new Error(`Failed to get access token: ${errorData}`); 80 | } 81 | 82 | const data = await response.json(); 83 | this.accessToken = data.access_token; 84 | this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000; 85 | 86 | this.logger.debug('Successfully obtained Spotify access token'); 87 | return this.accessToken; 88 | } catch (error) { 89 | this.logger.error(`Error getting Spotify access token: ${error.message}`); 90 | throw error; 91 | } 92 | } 93 | 94 | async getAllPlaylistTracks(spotifyUrl: string): Promise { 95 | try { 96 | this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`); 97 | 98 | const detail = await getDetails(spotifyUrl); 99 | this.logger.debug( 100 | `Initial tracks count from spotify-url-info: ${detail.tracks?.length || 0}`, 101 | ); 102 | 103 | if (!detail.tracks || detail.tracks.length < 100) { 104 | return detail.tracks || []; 105 | } 106 | 107 | this.logger.debug( 108 | 'Playlist has 100 or more tracks, using official Spotify API for pagination', 109 | ); 110 | 111 | const playlistId = this.getPlaylistId(spotifyUrl); 112 | this.logger.debug(`Extracted playlist ID: ${playlistId}`); 113 | 114 | try { 115 | const accessToken = await this.getAccessToken(); 116 | 117 | const allTracks = [...detail.tracks]; 118 | let offset = 0; 119 | let hasMoreTracks = true; 120 | 121 | allTracks.length = 0; 122 | 123 | while (hasMoreTracks) { 124 | this.logger.debug( 125 | `Fetching tracks from Spotify API with offset ${offset}`, 126 | ); 127 | 128 | const response = await fetch( 129 | `https://api.spotify.com/v1/playlists/${playlistId}/tracks?offset=${offset}&limit=100&fields=items(track(name,artists,preview_url)),next`, 130 | { 131 | headers: { 132 | Authorization: `Bearer ${accessToken}`, 133 | }, 134 | }, 135 | ); 136 | 137 | if (!response.ok) { 138 | const errorText = await response.text(); 139 | this.logger.error( 140 | `Spotify API error: ${response.status} ${errorText}`, 141 | ); 142 | throw new Error(`Failed to fetch tracks: ${response.status}`); 143 | } 144 | 145 | const data = await response.json(); 146 | 147 | if (!data.items || data.items.length === 0) { 148 | this.logger.debug('No more tracks to fetch from Spotify API'); 149 | hasMoreTracks = false; 150 | continue; 151 | } 152 | 153 | const pageTracks = data.items 154 | .map( 155 | (item: { 156 | track: { name: any; artists: any[]; preview_url: any }; 157 | }) => { 158 | if (!item.track) return null; 159 | 160 | return { 161 | name: item.track.name, 162 | artist: item.track.artists.map((a) => a.name).join(', '), 163 | previewUrl: item.track.preview_url, 164 | }; 165 | }, 166 | ) 167 | .filter((track) => track !== null); 168 | 169 | this.logger.debug( 170 | `Retrieved ${pageTracks.length} tracks from Spotify API at offset ${offset}`, 171 | ); 172 | 173 | if (pageTracks.length > 0) { 174 | allTracks.push(...pageTracks); 175 | } 176 | 177 | if (pageTracks.length < 100) { 178 | hasMoreTracks = false; 179 | } else { 180 | offset += 100; 181 | } 182 | } 183 | 184 | this.logger.debug( 185 | `Total tracks retrieved from Spotify API: ${allTracks.length}`, 186 | ); 187 | return allTracks; 188 | } catch (apiError) { 189 | this.logger.error( 190 | `Failed to get tracks from Spotify API: ${apiError.message}`, 191 | ); 192 | this.logger.debug('Falling back to initial tracks only'); 193 | return detail.tracks || []; 194 | } 195 | } catch (error) { 196 | this.logger.error(`Failed to get all playlist tracks: ${error.message}`); 197 | throw error; 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://img.shields.io/docker/pulls/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty) 2 | [![npm version](https://img.shields.io/docker/image-size/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty) 3 | ![Docker Image Version](https://img.shields.io/docker/v/raiper34/spooty) 4 | [![npm version](https://img.shields.io/docker/stars/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty) 5 | [![GitHub License](https://img.shields.io/github/license/raiper34/spooty)](https://github.com/Raiper34/spooty) 6 | [![GitHub Repo stars](https://img.shields.io/github/stars/raiper34/spooty)](https://github.com/Raiper34/spooty) 7 | 8 | ![spooty logo](assets/logo.svg) 9 | # Spooty - selfhosted Spotify downloader 10 | Spooty is a self-hosted Spotify downloader. 11 | It allows download track/playlist/album from the Spotify url. 12 | It can also subscribe to a playlist or author page and download new songs upon release. 13 | Spooty basically downloads nothing from Spotify, it only gets information from spotify and then finds relevant and downloadeds music on Youtube. 14 | The project is based on NestJS and Angular. 15 | 16 | > [!IMPORTANT] 17 | > Please do not use this tool for piracy! Download only music you own rights! Use this tool only on your responsibility. 18 | 19 | ### Content 20 | - [🚀 Installation](#-installation) 21 | - [Spotify App Configuration](#spotify-app-configuration) 22 | - [Docker](#docker) 23 | - [Docker command](#docker-command) 24 | - [Docker compose](#docker-compose) 25 | - [Build from source](#build-from-source) 26 | - [Process](#requirements) 27 | - [Requirements](#process) 28 | - [Environment variables](#environment-variables) 29 | - [⚖️ License](#-license) 30 | 31 | ## 🚀 Installation 32 | Recommended and the easiest way how to start to use of Spooty is using docker. 33 | 34 | ### Spotify App Configuration 35 | 36 | To fully use Spooty, you need to create an application in the Spotify Developer Dashboard: 37 | 38 | 1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) 39 | 2. Sign in with your Spotify account 40 | 3. Create a new application 41 | 4. Note your `Client ID` and `Client Secret` 42 | 5. Configure the redirect URI to `http://localhost:3000/api/callback` (or the corresponding URL of your instance) 43 | 44 | These credentials will be used by Spooty to access the Spotify API. 45 | 46 | ### Docker 47 | 48 | Just run docker command or use docker compose configuration. 49 | For detailed configuration, see available [environment variables](#environment-variables). 50 | 51 | #### Docker command 52 | ```shell 53 | docker run -d -p 3000:3000 \ 54 | -v /path/to/downloads:/spooty/backend/downloads \ 55 | -e SPOTIFY_CLIENT_ID=your_client_id \ 56 | -e SPOTIFY_CLIENT_SECRET=your_client_secret \ 57 | raiper34/spooty:latest 58 | ``` 59 | 60 | #### Docker compose 61 | ```yaml 62 | services: 63 | spooty: 64 | image: raiper34/spooty:latest 65 | container_name: spooty 66 | restart: unless-stopped 67 | ports: 68 | - "3000:3000" 69 | volumes: 70 | - /path/to/downloads:/spooty/backend/downloads 71 | environment: 72 | - SPOTIFY_CLIENT_ID=your_client_id 73 | - SPOTIFY_CLIENT_SECRET=your_client_secret 74 | # Configure other environment variables if needed 75 | ``` 76 | 77 | ### Build from source 78 | 79 | Spooty can be also build from source files on your own. 80 | 81 | #### Requirements 82 | - Node v18.19.1 (it is recommended to use `nvm` node version manager to install proper version of node) 83 | - Redis in memory cache 84 | - Ffmpeg 85 | - Python3 86 | 87 | #### Process 88 | - install Node v18.19.1 using `nvm install` and use that node version `nvm use` 89 | - from project root install all dependencies using `npm install` 90 | - copy `.env.default` as `.env` in `src/backend` folder and modify desired environment properties (see [environment variables](#environment-variables)) 91 | - add your Spotify application credentials to the `.env` file: 92 | ``` 93 | SPOTIFY_CLIENT_ID=your_client_id 94 | SPOTIFY_CLIENT_SECRET=your_client_secret 95 | ``` 96 | - build source files `npm run build` 97 | - built project will be stored in `dist` folder 98 | - start server `npm run start` 99 | 100 | ### Environment variables 101 | 102 | Some behaviour and settings of Spooty can be configured using environment variables and `.env` file. 103 | 104 | Name | Default | Description | 105 | ----------------------|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| 106 | DB_PATH | `./config/db.sqlite` (relative to backend) | Path where Spooty database will be stored | 107 | FE_PATH | `../frontend/browser` (relative to backend) | Path to frontend part of application | 108 | DOWNLOADS_PATH | `./downloads` (relative to backend) | Path where downaloded files will be stored | 109 | FORMAT | `mp3` | Format of downloaded files (currently fully supported only `mp3` but you can try whatever you want from [ffmpeg](https://ffmpeg.org/ffmpeg-formats.html#Muxers)) | 110 | PORT | 3000 | Port of Spooty server | 111 | REDIS_PORT | 6379 | Port of Redis server | 112 | REDIS_HOST | localhost | Host of Redis server | 113 | RUN_REDIS | false | Whenever Redis server should be started from backend (recommended for Docker environment) | 114 | SPOTIFY_CLIENT_ID | your_client_id | Client ID of your Spotify application (required) | 115 | SPOTIFY_CLIENT_SECRET| your_client_secret | Client Secret of your Spotify application (required) | 116 | YT_DOWNLOADS_PER_MINUTE | 3 | Set the maximum number of YouTube downloads started per minute | 117 | YT_COOKIES | | Allows you to pass your YouTube cookies to bypass some download restrictions. See [below](#how-to-get-your-youtube-cookies) for instructions. | 118 | 119 | ### How to get your YouTube cookies (using browser dev tools): 120 | 1. Go to https://www.youtube.com and log in if needed. 121 | 2. Open the browser developer tools (F12 or right click > Inspect). 122 | 3. Go to the "Application" tab (in Chrome) or "Storage" (in Firefox). 123 | 4. In the left menu, find "Cookies" and select https://www.youtube.com. 124 | 5. Copy all the cookies (name=value) and join them with a semicolon and a space, like: 125 | VISITOR_INFO1_LIVE=xxxx; YSC=xxxx; SID=xxxx; ... 126 | 6. Paste this string into the YT_COOKIES environment variable (in your .env or Docker config). 127 | 128 | # ⚖️ License 129 | [MIT](https://choosealicense.com/licenses/mit/) 130 | --------------------------------------------------------------------------------