diff --git a/Dockerfile b/Dockerfile index 5ebd0a4..2ed6646 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:19.2.0 as build +FROM node:20.3.0 as build WORKDIR /usr/src/app diff --git a/README.md b/README.md index c89d684..18ec072 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Introducing Singularity: Your browser's karaoke stage! Gather your friends for a SMTP_PASSWORD: SMTP_FROM: SONG_DIRECTORY: songs + ENABLE_AUTO_INDEXING: true volumes: - singularity-songs:/usr/src/app/songs ports: diff --git a/apps/singularity-api/src/app/song/song-indexing.service.ts b/apps/singularity-api/src/app/song/song-indexing.service.ts new file mode 100644 index 0000000..2ac1f30 --- /dev/null +++ b/apps/singularity-api/src/app/song/song-indexing.service.ts @@ -0,0 +1,267 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Song } from './models/song.entity'; +import { UltrastarParser } from './utils/ultrastar-parser'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class SongIndexingService implements OnModuleInit { + private readonly logger = new Logger(SongIndexingService.name); + private isIndexing = false; + + constructor( + @InjectRepository(Song) private readonly songRepository: Repository, + private readonly configService: ConfigService + ) {} + + async onModuleInit() { + // Auto-index on startup if enabled + if (this.configService.get('ENABLE_AUTO_INDEXING', 'false') === 'true') { + this.logger.log('Auto-indexing enabled, starting background scan...'); + // Run after a short delay to ensure the database is ready + setTimeout(() => this.indexSongs(), 5000); + } + } + + /** + * Scans the song directory for Ultrastar .txt files and indexes them + */ + async indexSongs(): Promise<{ indexed: number; skipped: number; errors: number }> { + if (this.isIndexing) { + this.logger.warn('Indexing already in progress, skipping...'); + return { indexed: 0, skipped: 0, errors: 0 }; + } + + this.isIndexing = true; + const stats = { indexed: 0, skipped: 0, errors: 0 }; + + try { + const baseDirectory = this.getSongDirectoryPath(); + this.logger.log(`Starting song indexing in directory: ${baseDirectory}`); + + if (!fs.existsSync(baseDirectory)) { + this.logger.warn(`Song directory does not exist: ${baseDirectory}`); + return stats; + } + + const txtFiles = await this.findUltrastarFiles(baseDirectory); + this.logger.log(`Found ${txtFiles.length} .txt files to process`); + + for (const txtFile of txtFiles) { + try { + const result = await this.indexSingleSong(txtFile); + if (result) { + stats.indexed++; + this.logger.log(`Indexed: ${result.artist} - ${result.name}`); + } else { + stats.skipped++; + } + } catch (error) { + stats.errors++; + this.logger.error(`Failed to index ${txtFile}: ${error.message}`); + } + } + + this.logger.log(`Indexing complete. Indexed: ${stats.indexed}, Skipped: ${stats.skipped}, Errors: ${stats.errors}`); + } catch (error) { + this.logger.error(`Indexing failed: ${error.message}`); + } finally { + this.isIndexing = false; + } + + return stats; + } + + /** + * Recursively finds all .txt files in the song directory + */ + private async findUltrastarFiles(dir: string): Promise { + const txtFiles: string[] = []; + + const scan = async (currentDir: string) => { + try { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + await scan(fullPath); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.txt')) { + // Check if this looks like an Ultrastar file by reading the first few lines + try { + const content = fs.readFileSync(fullPath, 'utf8'); + if (UltrastarParser.isUltrastarFile(content)) { + txtFiles.push(fullPath); + } + } catch (error) { + this.logger.warn(`Could not read file ${fullPath}: ${error.message}`); + } + } + } + } catch (error) { + this.logger.warn(`Could not scan directory ${currentDir}: ${error.message}`); + } + }; + + await scan(dir); + return txtFiles; + } + + + /** + * Indexes a single song from its .txt file + */ + private async indexSingleSong(txtFilePath: string): Promise { + const content = fs.readFileSync(txtFilePath, 'utf8'); + const songDir = path.dirname(txtFilePath); + + // Parse the Ultrastar file + const { metadata, notes, pointsPerBeat } = UltrastarParser.parse(content); + + const artist = metadata.artist; + const title = metadata.title; + + if (!metadata.artist || !metadata.title) { + this.logger.warn(`Missing artist or title in ${txtFilePath}`); + return null; + } + + // Check if song already exists + const existingSong = await this.songRepository.findOne({ + where: { artist, name: title } + }); + + if (existingSong) { + this.logger.debug(`Song already exists: ${artist} - ${title}`); + return null; + } + + // Find associated media files + const mediaFiles = this.findMediaFiles(songDir, txtFilePath); + + if (!mediaFiles.audio) { + this.logger.warn(`No audio file found for ${artist} - ${title} in ${songDir}`); + return null; + } + + // Create song entity + const song = this.songRepository.create(); + song.artist = metadata.artist; + song.name = metadata.title; + song.year = metadata.year; + song.bpm = metadata.bpm; + song.gap = metadata.gap; + song.start = metadata.start; + song.end = metadata.end; + song.notes = notes; + song.pointsPerBeat = pointsPerBeat; + + // Store relative paths from the song directory + const baseSongDir = this.getSongDirectoryPath(); + song.audioFileName = path.relative(baseSongDir, mediaFiles.audio); + song.videoFileName = mediaFiles.video ? path.relative(baseSongDir, mediaFiles.video) : ''; + song.coverFileName = mediaFiles.cover ? path.relative(baseSongDir, mediaFiles.cover) : ''; + + return this.songRepository.save(song); + } + + /** + * Finds associated media files (audio, video, cover) for a song + */ + private findMediaFiles(songDir: string, txtFilePath: string): { + audio?: string; + video?: string; + cover?: string; + } { + const txtBasename = path.basename(txtFilePath, '.txt'); + const files = fs.readdirSync(songDir); + const result: { audio?: string; video?: string; cover?: string } = {}; + + // Check for files referenced in the .txt file first + const txtContent = fs.readFileSync(txtFilePath, 'utf8'); + const audioRef = UltrastarParser.getMetadataValue(txtContent, '#MP3'); + const videoRef = UltrastarParser.getMetadataValue(txtContent, '#VIDEO'); + const coverRef = UltrastarParser.getMetadataValue(txtContent, '#COVER'); + + // Look for referenced files + if (audioRef) { + const audioPath = path.join(songDir, audioRef); + if (fs.existsSync(audioPath)) { + result.audio = audioPath; + } + } + if (videoRef) { + const videoPath = path.join(songDir, videoRef); + if (fs.existsSync(videoPath)) { + result.video = videoPath; + } + } + if (coverRef) { + const coverPath = path.join(songDir, coverRef); + if (fs.existsSync(coverPath)) { + result.cover = coverPath; + } + } + + // If not found via references, look for files with matching basename or common patterns + const audioExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.flac']; + const videoExtensions = ['.mp4', '.avi', '.mkv', '.mov', '.wmv']; + const coverExtensions = ['.jpg', '.jpeg', '.png', '.bmp']; + + for (const file of files) { + const filePath = path.join(songDir, file); + const fileBasename = path.basename(file, path.extname(file)); + const ext = path.extname(file).toLowerCase(); + + // Audio files + if (!result.audio && audioExtensions.includes(ext)) { + if (fileBasename.toLowerCase() === txtBasename.toLowerCase() || + file.toLowerCase().includes('audio') || + audioExtensions.includes(ext)) { + result.audio = filePath; + } + } + + // Video files + if (!result.video && videoExtensions.includes(ext)) { + if (fileBasename.toLowerCase() === txtBasename.toLowerCase() || + file.toLowerCase().includes('video') || + videoExtensions.includes(ext)) { + result.video = filePath; + } + } + + // Cover files + if (!result.cover && coverExtensions.includes(ext)) { + if (fileBasename.toLowerCase() === txtBasename.toLowerCase() || + file.toLowerCase().includes('cover') || + file.toLowerCase().includes('background') || + coverExtensions.includes(ext)) { + result.cover = filePath; + } + } + } + + return result; + } + + /** + * Gets the absolute path to the song directory + */ + private getSongDirectoryPath(): string { + const baseDirectory = this.configService.get('SONG_DIRECTORY', 'songs'); + return path.resolve(process.cwd(), baseDirectory); + } + + + /** + * Returns current indexing status + */ + getIndexingStatus(): { isIndexing: boolean } { + return { isIndexing: this.isIndexing }; + } +} \ No newline at end of file diff --git a/apps/singularity-api/src/app/song/song.controller.ts b/apps/singularity-api/src/app/song/song.controller.ts index 3d3cbc9..9eb3710 100644 --- a/apps/singularity-api/src/app/song/song.controller.ts +++ b/apps/singularity-api/src/app/song/song.controller.ts @@ -31,13 +31,15 @@ import { AdminGuard } from '../user-management/guards/admin-guard'; import { AuthGuard } from '@nestjs/passport'; import { SongFile } from './interfaces/song-file'; import { SongDownloadService } from './song-download.service'; +import { SongIndexingService } from './song-indexing.service'; import * as sharp from 'sharp'; @Controller('song') export class SongController { constructor(private readonly songService: SongService, - private readonly songDownloadService: SongDownloadService) { + private readonly songDownloadService: SongDownloadService, + private readonly songIndexingService: SongIndexingService) { } @Get() @@ -175,4 +177,16 @@ export class SongController { public deleteSong(@Param('id') id: string): Promise { return this.songService.deleteSong(+id); } + + @Post('index') + @UseGuards(AdminGuard()) + public async indexSongs(): Promise<{ indexed: number; skipped: number; errors: number }> { + return this.songIndexingService.indexSongs(); + } + + @Get('index/status') + @UseGuards(AdminGuard()) + public getIndexingStatus(): { isIndexing: boolean } { + return this.songIndexingService.getIndexingStatus(); + } } diff --git a/apps/singularity-api/src/app/song/song.module.ts b/apps/singularity-api/src/app/song/song.module.ts index f59873a..b83583c 100644 --- a/apps/singularity-api/src/app/song/song.module.ts +++ b/apps/singularity-api/src/app/song/song.module.ts @@ -11,11 +11,12 @@ import { YtService } from './yt.service'; import { FanartService } from './fanart.service'; import { HttpModule } from '@nestjs/axios'; import { SongDownloadService } from './song-download.service'; +import { SongIndexingService } from './song-indexing.service'; @Module({ controllers: [SongController], - providers: [SongService, YtService, FanartService, SongDownloadService, SongProfile, SongNoteProfile], + providers: [SongService, YtService, FanartService, SongDownloadService, SongIndexingService, SongProfile, SongNoteProfile], imports: [TypeOrmModule.forFeature([Song, SongNote]), ConfigModule, HttpModule], - exports: [SongService, SongProfile] + exports: [SongService, SongIndexingService, SongProfile] }) export class SongModule {} diff --git a/apps/singularity-api/src/app/song/song.service.ts b/apps/singularity-api/src/app/song/song.service.ts index 531d81a..bacb54b 100644 --- a/apps/singularity-api/src/app/song/song.service.ts +++ b/apps/singularity-api/src/app/song/song.service.ts @@ -1,14 +1,14 @@ import { Injectable, Logger } from '@nestjs/common'; import * as fs from 'fs'; -import { SongNote } from './models/song-note.entity'; +import * as path from 'path'; import { InjectRepository } from '@nestjs/typeorm'; import { Song } from './models/song.entity'; import { Repository } from 'typeorm'; -import { SongNoteType } from '@singularity/api-interfaces'; import { ConfigService } from '@nestjs/config'; import { SongAlreadyExistsError } from './errors/song-already-exists-error'; import { SongSaveError } from './errors/song-save-error'; import { SongFile } from './interfaces/song-file'; +import { UltrastarParser } from './utils/ultrastar-parser'; @Injectable() export class SongService { @@ -28,46 +28,55 @@ export class SongService { public async getSongAudioFile(id: number): Promise { const song = await this.getSongById(id); - return this.getSongDirectory(song.artist, song.name) - .then((directory: string) => new Promise((resolve, reject) => { - fs.readFile(`${directory}/${song.audioFileName}`, (error: Error, data: Buffer) => { - if (error) { - reject(error); - } + // Handle both old format (filename only) and new format (relative path) + const audioPath = song.audioFileName.includes('/') + ? this.getAbsoluteSongPath(song.audioFileName) + : await this.getSongDirectory(song.artist, song.name).then(dir => `${dir}/${song.audioFileName}`); - resolve(data); - }); - })); + return new Promise((resolve, reject) => { + fs.readFile(audioPath, (error: Error, data: Buffer) => { + if (error) { + reject(error); + } + resolve(data); + }); + }); } public async getSongVideoFile(id: number): Promise { const song = await this.getSongById(id); - return this.getSongDirectory(song.artist, song.name) - .then((directory: string) => new Promise((resolve, reject) => { - fs.readFile(`${directory}/${song.videoFileName}`, (error: Error, data: Buffer) => { - if (error) { - reject(error); - } + // Handle both old format (filename only) and new format (relative path) + const videoPath = song.videoFileName.includes('/') + ? this.getAbsoluteSongPath(song.videoFileName) + : await this.getSongDirectory(song.artist, song.name).then(dir => `${dir}/${song.videoFileName}`); - resolve(data); - }); - })); + return new Promise((resolve, reject) => { + fs.readFile(videoPath, (error: Error, data: Buffer) => { + if (error) { + reject(error); + } + resolve(data); + }); + }); } public async getSongCoverFile(id: number): Promise { const song = await this.getSongById(id); - return this.getSongDirectory(song.artist, song.name) - .then((directory: string) => new Promise((resolve, reject) => { - fs.readFile(`${directory}/${song.coverFileName}`, (error: Error, data: Buffer) => { - if (error) { - reject(error); - } + // Handle both old format (filename only) and new format (relative path) + const coverPath = song.coverFileName.includes('/') + ? this.getAbsoluteSongPath(song.coverFileName) + : await this.getSongDirectory(song.artist, song.name).then(dir => `${dir}/${song.coverFileName}`); - resolve(data); - }); - })); + return new Promise((resolve, reject) => { + fs.readFile(coverPath, (error: Error, data: Buffer) => { + if (error) { + reject(error); + } + resolve(data); + }); + }); } public async deleteSong(id: number): Promise { @@ -91,15 +100,15 @@ export class SongService { songStart?: number, songEnd?: number): Promise { const songText = txtFile.buffer.toString('utf8'); + const { metadata, notes, pointsPerBeat } = UltrastarParser.parse(songText); - - const artist = this.getSongMetadata(songText, '#ARTIST'); - const title = this.getSongMetadata(songText, '#TITLE'); - const year = +this.getSongMetadata(songText, '#YEAR') ?? 0; - const bpm = +(this.getSongMetadata(songText, '#BPM').replace(',', '.')); - const gap = +this.getSongMetadata(songText, '#GAP') ?? 0; - const start = songStart ?? +this.getSongMetadata(songText, '#START') ?? 0; - const end = songEnd ?? +this.getSongMetadata(songText, '#END') ?? 0; + const artist = metadata.artist; + const title = metadata.title; + const year = metadata.year; + const bpm = metadata.bpm; + const gap = metadata.gap; + const start = songStart ?? metadata.start; + const end = songEnd ?? metadata.end; if (await this.songRepository.exist({ where: { @@ -116,7 +125,6 @@ export class SongService { this.writeFile(directory, `${artist} - ${title}.${this.getFileType(videoFile)}`, videoFile.buffer); this.writeFile(directory, `${artist} - ${title}.${this.getFileType(coverFile)}`, coverFile.buffer); - const songNotes = this.getSongNotes(songText); const song = this.songRepository.create(); song.artist = artist; song.name = title; @@ -125,8 +133,8 @@ export class SongService { song.gap = gap; song.start = start; song.end = end; - song.notes = songNotes; - song.pointsPerBeat = this.getPointsPerBeat(songNotes); + song.notes = notes; + song.pointsPerBeat = pointsPerBeat; song.audioFileName = `${artist} - ${title}.${this.getFileType(audioFile)}`; song.videoFileName = `${artist} - ${title}.${this.getFileType(videoFile)}`; song.coverFileName = `${artist} - ${title}.${this.getFileType(coverFile)}`; @@ -139,61 +147,6 @@ export class SongService { } - private getSongMetadata(txt: string, metaDataKey: string): string { - const lines = txt.split('\n'); - const metaDataLine = lines.find((line: string) => line.startsWith(metaDataKey)); - const metaDatas = metaDataLine?.split(':') ?? []; - return metaDatas.slice(1).join(':').trim(); - } - - private getSongNotes(txt: string): SongNote[] { - const lines = txt.split('\n'); - return lines - .filter((line: string) => !line.startsWith('#')) - .map((line: string) => this.getSongNote(line)) - .filter((songNote: SongNote | null) => songNote !== null); - } - - private getSongNote(line: string): SongNote | null { - const songNote = new SongNote(); - - const lineArray = line.split(' '); - - switch (lineArray[0]) { - case ':': - songNote.type = SongNoteType.Regular; - break; - case '*': - songNote.type = SongNoteType.Golden; - break; - case 'F': - songNote.type = SongNoteType.Freestyle; - break; - case '-': - songNote.type = SongNoteType.LineBreak; - break; - case 'E': - return null; - default: - throw new Error('Reached unexpected SongNoteType: ' + lineArray[0]); - } - - songNote.startBeat = +lineArray[1] || 0; - songNote.lengthInBeats = +lineArray[2] || 0; - songNote.pitch = +lineArray[3] || 0; - songNote.text = lineArray.slice(4).join(' ').replace('\r', '').replace('\n', ''); - - return songNote; - } - - private getPointsPerBeat(songNotes: SongNote[]): number { - const songNotesWithoutLinebreaks = songNotes.filter((songNote: SongNote) => songNote.type !== SongNoteType.LineBreak); - const beatCount = songNotesWithoutLinebreaks.reduce((previous: number, current: SongNote) => - previous + current.lengthInBeats * (current.type === SongNoteType.Golden ? 2 : 1), // Golden Notes give us double points - 0); - - return 10000 / beatCount; - } private writeFile(path: string, fileName: string, buffer: Buffer | string): void { const stream = fs.createWriteStream(`${path}/${fileName}`); @@ -241,4 +194,9 @@ export class SongService { }); }); } + + private getAbsoluteSongPath(relativePath: string): string { + const baseDirectory = this.configService.get('SONG_DIRECTORY', 'songs'); + return path.resolve(process.cwd(), baseDirectory, relativePath); + } } diff --git a/apps/singularity-api/src/app/song/utils/ultrastar-parser.ts b/apps/singularity-api/src/app/song/utils/ultrastar-parser.ts new file mode 100644 index 0000000..f9c88f6 --- /dev/null +++ b/apps/singularity-api/src/app/song/utils/ultrastar-parser.ts @@ -0,0 +1,122 @@ +import { SongNote } from "../models/song-note.entity"; +import { SongNoteType } from "@singularity/api-interfaces"; + +export interface UltrastarMetadata { + artist: string; + title: string; + year: number; + bpm: number; + gap: number; + start: number; + end: number; + mp3?: string; + video?: string; + cover?: string; +} + +export class UltrastarParser { + static parse(txtContent: string): { + metadata: UltrastarMetadata; + notes: SongNote[]; + pointsPerBeat: number; + } { + const metadata = this.parseMetadata(txtContent); + const notes = this.parseNotes(txtContent); + const pointsPerBeat = this.calculatePointsPerBeat(notes); + + return { metadata, notes, pointsPerBeat }; + } + + static parseMetadata(txtContent: string): UltrastarMetadata { + return { + artist: this.getMetadataValue(txtContent, "#ARTIST"), + title: this.getMetadataValue(txtContent, "#TITLE"), + year: +this.getMetadataValue(txtContent, "#YEAR") || 0, + bpm: +this.getMetadataValue(txtContent, "#BPM").replace(",", ".") || 120, + gap: +this.getMetadataValue(txtContent, "#GAP") || 0, + start: +this.getMetadataValue(txtContent, "#START") || 0, + end: +this.getMetadataValue(txtContent, "#END") || 0, + mp3: this.getMetadataValue(txtContent, "#MP3") || undefined, + video: this.getMetadataValue(txtContent, "#VIDEO") || undefined, + cover: this.getMetadataValue(txtContent, "#COVER") || undefined, + }; + } + + static getMetadataValue(txt: string, metadataKey: string): string { + const lines = txt.split("\n"); + const metadataLine = lines.find((line: string) => + line.startsWith(metadataKey), + ); + if (!metadataLine) return ""; + + const metadataValues = metadataLine.split(":"); + return metadataValues.slice(1).join(":").trim(); + } + + static parseNotes(txt: string): SongNote[] { + const lines = txt.split("\n"); + return lines + .filter((line: string) => !line.startsWith("#") && line.trim().length > 0) + .map((line: string) => this.parseNote(line)) + .filter((note: SongNote | null) => note !== null) as SongNote[]; + } + + static parseNote(line: string): SongNote | null { + const lineArray = line.trim().split(" "); + + if (lineArray.length < 2) return null; + + const songNote = new SongNote(); + + switch (lineArray[0]) { + case ":": + songNote.type = SongNoteType.Regular; + break; + case "*": + songNote.type = SongNoteType.Golden; + break; + case "F": + songNote.type = SongNoteType.Freestyle; + break; + case "-": + songNote.type = SongNoteType.LineBreak; + break; + case "E": + return null; // End marker + default: + return null; // Unknown type, skip gracefully for indexing + } + + songNote.startBeat = +lineArray[1] || 0; + songNote.lengthInBeats = +lineArray[2] || 0; + songNote.pitch = +lineArray[3] || 0; + songNote.text = lineArray.slice(4).join(" ").replace(/\r?\n/g, ""); + + return songNote; + } + + static calculatePointsPerBeat(songNotes: SongNote[]): number { + const songNotesWithoutLinebreaks = songNotes.filter( + (songNote: SongNote) => songNote.type !== SongNoteType.LineBreak, + ); + + const beatCount = songNotesWithoutLinebreaks.reduce( + (previous: number, current: SongNote) => + previous + + current.lengthInBeats * (current.type === SongNoteType.Golden ? 2 : 1), // Golden Notes give us double points + 0, + ); + + return beatCount > 0 ? 10000 / beatCount : 100; + } + + static isUltrastarFile(content: string): boolean { + const lines = content.split("\n").slice(0, 20); + return lines.some( + (line) => + line.startsWith("#TITLE:") || + line.startsWith("#ARTIST:") || + line.startsWith("#BPM:"), + ); + } +} diff --git a/apps/singularity-api/src/config/appsettings.env b/apps/singularity-api/src/config/appsettings.env index 3ff7bd6..b60a389 100644 --- a/apps/singularity-api/src/config/appsettings.env +++ b/apps/singularity-api/src/config/appsettings.env @@ -18,3 +18,4 @@ AUTHENTICATION_JWT_SECRET= ENABLE_YOUTUBE=false SONG_DIRECTORY=songs +ENABLE_AUTO_INDEXING=true diff --git a/apps/singularity-client/src/app/management/songs/song-list/song-list.component.html b/apps/singularity-client/src/app/management/songs/song-list/song-list.component.html index 2327e0e..52b2815 100644 --- a/apps/singularity-client/src/app/management/songs/song-list/song-list.component.html +++ b/apps/singularity-client/src/app/management/songs/song-list/song-list.component.html @@ -5,6 +5,27 @@

{{ 'management.songs.songList.header' | transloco }}

+ +
+

{{ 'management.songs.songList.autoIndexing.header' | transloco }}

+

{{ 'management.songs.songList.autoIndexing.description' | transloco }}

+ +
+ + +
+

{{ 'management.songs.songList.autoIndexing.result' | transloco: indexingResult }}

+
+
+
+
+ diff --git a/apps/singularity-client/src/app/management/songs/song-list/song-list.component.scss b/apps/singularity-client/src/app/management/songs/song-list/song-list.component.scss index e69de29..9c42acc 100644 --- a/apps/singularity-client/src/app/management/songs/song-list/song-list.component.scss +++ b/apps/singularity-client/src/app/management/songs/song-list/song-list.component.scss @@ -0,0 +1,37 @@ +.indexing-section { + margin: 1rem 0 2rem 0; + padding: 1rem; + border: 1px solid var(--sui-border-color, #ddd); + border-radius: 8px; + + h2 { + margin-top: 0; + margin-bottom: 0.5rem; + font-size: 1.2rem; + } + + p { + margin-bottom: 1rem; + color: var(--sui-text-color-secondary, #666); + } + + .indexing-controls { + display: flex; + flex-direction: column; + gap: 1rem; + + button { + align-self: flex-start; + } + + .indexing-result { + p { + margin: 0; + padding: 0.5rem; + border: 1px solid var(--sui-success-color, #28a745); + border-radius: 4px; + color: var(--sui-success-color, #28a745); + } + } + } +} diff --git a/apps/singularity-client/src/app/management/songs/song-list/song-list.component.ts b/apps/singularity-client/src/app/management/songs/song-list/song-list.component.ts index 5a6c48a..6a229dc 100644 --- a/apps/singularity-client/src/app/management/songs/song-list/song-list.component.ts +++ b/apps/singularity-client/src/app/management/songs/song-list/song-list.component.ts @@ -14,6 +14,8 @@ export class SongListComponent implements OnInit, OnDestroy { public isAdmin$ = this.authenticationService.isAdmin$(); public downloadingSongs$?: Observable; public songs: SongOverviewDto[] = []; + public isIndexing = false; + public indexingResult?: { indexed: number; skipped: number; errors: number }; public songListDownloadState = SongListDownloadState; @@ -43,6 +45,31 @@ export class SongListComponent implements OnInit, OnDestroy { this.songs.splice(index, 1); } + public startIndexing(): void { + if (this.isIndexing) { + return; + } + + this.isIndexing = true; + this.indexingResult = undefined; + + this.songManagementService.indexSongs$() + .pipe(takeUntil(this.destroySubject)) + .subscribe({ + next: (result) => { + this.indexingResult = result; + this.isIndexing = false; + // Refresh the song list if any songs were indexed + if (result.indexed > 0) { + this.setupSongs(); + } + }, + error: () => { + this.isIndexing = false; + } + }); + } + private setupDownloadInfos(): void { this.downloadingSongs$ = timer(0, 5000).pipe( switchMap(() => this.songManagementService.getDownloadingSongs$()), diff --git a/apps/singularity-client/src/app/management/songs/song-management.service.ts b/apps/singularity-client/src/app/management/songs/song-management.service.ts index 26386cb..7f119b9 100644 --- a/apps/singularity-client/src/app/management/songs/song-management.service.ts +++ b/apps/singularity-client/src/app/management/songs/song-management.service.ts @@ -60,4 +60,12 @@ export class SongManagementService extends SongService { return this.removeDownload$(id) .pipe(switchMap(() => this.api.delete$(`api/song/${id}`))) } + + public indexSongs$(): Observable<{ indexed: number; skipped: number; errors: number }> { + return this.api.post$('api/song/index', {}); + } + + public getIndexingStatus$(): Observable<{ isIndexing: boolean }> { + return this.api.get$('api/song/index/status'); + } } diff --git a/apps/singularity-client/src/assets/i18n/de.json b/apps/singularity-client/src/assets/i18n/de.json index 9f1c9d2..ee4ff9e 100644 --- a/apps/singularity-client/src/assets/i18n/de.json +++ b/apps/singularity-client/src/assets/i18n/de.json @@ -115,7 +115,14 @@ "author": "Author", "year": "Jahr", "actions": "Aktionen", - "serverDownloadsSongs": "Der Server lädt gerade Songs herunter. Diese sind bald verfügbar." + "serverDownloadsSongs": "Der Server lädt gerade Songs herunter. Diese sind bald verfügbar.", + "autoIndexing": { + "header": "Songs Auto-Indizieren", + "description": "Automatisch das Song-Verzeichnis nach Ultrastar-Dateien durchsuchen und zur Datenbank hinzufügen.", + "startIndex": "Songs Indizieren", + "indexing": "Indiziere...", + "result": "{{indexed}} Songs indiziert, {{skipped}} übersprungen, {{errors}} Fehler." + } } }, "users": { diff --git a/apps/singularity-client/src/assets/i18n/en.json b/apps/singularity-client/src/assets/i18n/en.json index 4add356..9a8c851 100644 --- a/apps/singularity-client/src/assets/i18n/en.json +++ b/apps/singularity-client/src/assets/i18n/en.json @@ -115,7 +115,14 @@ "author": "Author", "year": "Year", "actions": "Actions", - "serverDownloadsSongs": "The server is currently downloading songs. They will be available soon." + "serverDownloadsSongs": "The server is currently downloading songs. They will be available soon.", + "autoIndexing": { + "header": "Auto-Index Songs", + "description": "Automatically scan your song directory for Ultrastar files and add them to the database.", + "startIndex": "Index Songs", + "indexing": "Indexing...", + "result": "Indexed {{indexed}} songs, skipped {{skipped}}, {{errors}} errors." + } } }, "users": { diff --git a/docker-compose.yml b/docker-compose.yml index bb57c42..4241f8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: SMTP_FROM: SONG_DIRECTORY: songs ENABLE_YOUTUBE: false + ENABLE_AUTO_INDEXING: true volumes: - singularity-songs:/usr/src/app/songs ports: