├── .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