Skip to content

Commit 69a95fb

Browse files
authored
Merge pull request #28 from kiliankoe/automatic-indexing
Automatic indexing
2 parents 4522e46 + 4fdf7a6 commit 69a95fb

File tree

15 files changed

+573
-101
lines changed

15 files changed

+573
-101
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:19.2.0 as build
1+
FROM node:20.3.0 as build
22

33
WORKDIR /usr/src/app
44

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Introducing Singularity: Your browser's karaoke stage! Gather your friends for a
7575
SMTP_PASSWORD: <SMTP-PASSWORD>
7676
SMTP_FROM: <SMTP-FROM>
7777
SONG_DIRECTORY: songs
78+
ENABLE_AUTO_INDEXING: true
7879
volumes:
7980
- singularity-songs:/usr/src/app/songs
8081
ports:
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { InjectRepository } from '@nestjs/typeorm';
4+
import { Repository } from 'typeorm';
5+
import { Song } from './models/song.entity';
6+
import { UltrastarParser } from './utils/ultrastar-parser';
7+
import * as fs from 'fs';
8+
import * as path from 'path';
9+
10+
@Injectable()
11+
export class SongIndexingService implements OnModuleInit {
12+
private readonly logger = new Logger(SongIndexingService.name);
13+
private isIndexing = false;
14+
15+
constructor(
16+
@InjectRepository(Song) private readonly songRepository: Repository<Song>,
17+
private readonly configService: ConfigService
18+
) {}
19+
20+
async onModuleInit() {
21+
// Auto-index on startup if enabled
22+
if (this.configService.get('ENABLE_AUTO_INDEXING', 'false') === 'true') {
23+
this.logger.log('Auto-indexing enabled, starting background scan...');
24+
// Run after a short delay to ensure the database is ready
25+
setTimeout(() => this.indexSongs(), 5000);
26+
}
27+
}
28+
29+
/**
30+
* Scans the song directory for Ultrastar .txt files and indexes them
31+
*/
32+
async indexSongs(): Promise<{ indexed: number; skipped: number; errors: number }> {
33+
if (this.isIndexing) {
34+
this.logger.warn('Indexing already in progress, skipping...');
35+
return { indexed: 0, skipped: 0, errors: 0 };
36+
}
37+
38+
this.isIndexing = true;
39+
const stats = { indexed: 0, skipped: 0, errors: 0 };
40+
41+
try {
42+
const baseDirectory = this.getSongDirectoryPath();
43+
this.logger.log(`Starting song indexing in directory: ${baseDirectory}`);
44+
45+
if (!fs.existsSync(baseDirectory)) {
46+
this.logger.warn(`Song directory does not exist: ${baseDirectory}`);
47+
return stats;
48+
}
49+
50+
const txtFiles = await this.findUltrastarFiles(baseDirectory);
51+
this.logger.log(`Found ${txtFiles.length} .txt files to process`);
52+
53+
for (const txtFile of txtFiles) {
54+
try {
55+
const result = await this.indexSingleSong(txtFile);
56+
if (result) {
57+
stats.indexed++;
58+
this.logger.log(`Indexed: ${result.artist} - ${result.name}`);
59+
} else {
60+
stats.skipped++;
61+
}
62+
} catch (error) {
63+
stats.errors++;
64+
this.logger.error(`Failed to index ${txtFile}: ${error.message}`);
65+
}
66+
}
67+
68+
this.logger.log(`Indexing complete. Indexed: ${stats.indexed}, Skipped: ${stats.skipped}, Errors: ${stats.errors}`);
69+
} catch (error) {
70+
this.logger.error(`Indexing failed: ${error.message}`);
71+
} finally {
72+
this.isIndexing = false;
73+
}
74+
75+
return stats;
76+
}
77+
78+
/**
79+
* Recursively finds all .txt files in the song directory
80+
*/
81+
private async findUltrastarFiles(dir: string): Promise<string[]> {
82+
const txtFiles: string[] = [];
83+
84+
const scan = async (currentDir: string) => {
85+
try {
86+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
87+
88+
for (const entry of entries) {
89+
const fullPath = path.join(currentDir, entry.name);
90+
91+
if (entry.isDirectory()) {
92+
await scan(fullPath);
93+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.txt')) {
94+
// Check if this looks like an Ultrastar file by reading the first few lines
95+
try {
96+
const content = fs.readFileSync(fullPath, 'utf8');
97+
if (UltrastarParser.isUltrastarFile(content)) {
98+
txtFiles.push(fullPath);
99+
}
100+
} catch (error) {
101+
this.logger.warn(`Could not read file ${fullPath}: ${error.message}`);
102+
}
103+
}
104+
}
105+
} catch (error) {
106+
this.logger.warn(`Could not scan directory ${currentDir}: ${error.message}`);
107+
}
108+
};
109+
110+
await scan(dir);
111+
return txtFiles;
112+
}
113+
114+
115+
/**
116+
* Indexes a single song from its .txt file
117+
*/
118+
private async indexSingleSong(txtFilePath: string): Promise<Song | null> {
119+
const content = fs.readFileSync(txtFilePath, 'utf8');
120+
const songDir = path.dirname(txtFilePath);
121+
122+
// Parse the Ultrastar file
123+
const { metadata, notes, pointsPerBeat } = UltrastarParser.parse(content);
124+
125+
const artist = metadata.artist;
126+
const title = metadata.title;
127+
128+
if (!metadata.artist || !metadata.title) {
129+
this.logger.warn(`Missing artist or title in ${txtFilePath}`);
130+
return null;
131+
}
132+
133+
// Check if song already exists
134+
const existingSong = await this.songRepository.findOne({
135+
where: { artist, name: title }
136+
});
137+
138+
if (existingSong) {
139+
this.logger.debug(`Song already exists: ${artist} - ${title}`);
140+
return null;
141+
}
142+
143+
// Find associated media files
144+
const mediaFiles = this.findMediaFiles(songDir, txtFilePath);
145+
146+
if (!mediaFiles.audio) {
147+
this.logger.warn(`No audio file found for ${artist} - ${title} in ${songDir}`);
148+
return null;
149+
}
150+
151+
// Create song entity
152+
const song = this.songRepository.create();
153+
song.artist = metadata.artist;
154+
song.name = metadata.title;
155+
song.year = metadata.year;
156+
song.bpm = metadata.bpm;
157+
song.gap = metadata.gap;
158+
song.start = metadata.start;
159+
song.end = metadata.end;
160+
song.notes = notes;
161+
song.pointsPerBeat = pointsPerBeat;
162+
163+
// Store relative paths from the song directory
164+
const baseSongDir = this.getSongDirectoryPath();
165+
song.audioFileName = path.relative(baseSongDir, mediaFiles.audio);
166+
song.videoFileName = mediaFiles.video ? path.relative(baseSongDir, mediaFiles.video) : '';
167+
song.coverFileName = mediaFiles.cover ? path.relative(baseSongDir, mediaFiles.cover) : '';
168+
169+
return this.songRepository.save(song);
170+
}
171+
172+
/**
173+
* Finds associated media files (audio, video, cover) for a song
174+
*/
175+
private findMediaFiles(songDir: string, txtFilePath: string): {
176+
audio?: string;
177+
video?: string;
178+
cover?: string;
179+
} {
180+
const txtBasename = path.basename(txtFilePath, '.txt');
181+
const files = fs.readdirSync(songDir);
182+
const result: { audio?: string; video?: string; cover?: string } = {};
183+
184+
// Check for files referenced in the .txt file first
185+
const txtContent = fs.readFileSync(txtFilePath, 'utf8');
186+
const audioRef = UltrastarParser.getMetadataValue(txtContent, '#MP3');
187+
const videoRef = UltrastarParser.getMetadataValue(txtContent, '#VIDEO');
188+
const coverRef = UltrastarParser.getMetadataValue(txtContent, '#COVER');
189+
190+
// Look for referenced files
191+
if (audioRef) {
192+
const audioPath = path.join(songDir, audioRef);
193+
if (fs.existsSync(audioPath)) {
194+
result.audio = audioPath;
195+
}
196+
}
197+
if (videoRef) {
198+
const videoPath = path.join(songDir, videoRef);
199+
if (fs.existsSync(videoPath)) {
200+
result.video = videoPath;
201+
}
202+
}
203+
if (coverRef) {
204+
const coverPath = path.join(songDir, coverRef);
205+
if (fs.existsSync(coverPath)) {
206+
result.cover = coverPath;
207+
}
208+
}
209+
210+
// If not found via references, look for files with matching basename or common patterns
211+
const audioExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
212+
const videoExtensions = ['.mp4', '.avi', '.mkv', '.mov', '.wmv'];
213+
const coverExtensions = ['.jpg', '.jpeg', '.png', '.bmp'];
214+
215+
for (const file of files) {
216+
const filePath = path.join(songDir, file);
217+
const fileBasename = path.basename(file, path.extname(file));
218+
const ext = path.extname(file).toLowerCase();
219+
220+
// Audio files
221+
if (!result.audio && audioExtensions.includes(ext)) {
222+
if (fileBasename.toLowerCase() === txtBasename.toLowerCase() ||
223+
file.toLowerCase().includes('audio') ||
224+
audioExtensions.includes(ext)) {
225+
result.audio = filePath;
226+
}
227+
}
228+
229+
// Video files
230+
if (!result.video && videoExtensions.includes(ext)) {
231+
if (fileBasename.toLowerCase() === txtBasename.toLowerCase() ||
232+
file.toLowerCase().includes('video') ||
233+
videoExtensions.includes(ext)) {
234+
result.video = filePath;
235+
}
236+
}
237+
238+
// Cover files
239+
if (!result.cover && coverExtensions.includes(ext)) {
240+
if (fileBasename.toLowerCase() === txtBasename.toLowerCase() ||
241+
file.toLowerCase().includes('cover') ||
242+
file.toLowerCase().includes('background') ||
243+
coverExtensions.includes(ext)) {
244+
result.cover = filePath;
245+
}
246+
}
247+
}
248+
249+
return result;
250+
}
251+
252+
/**
253+
* Gets the absolute path to the song directory
254+
*/
255+
private getSongDirectoryPath(): string {
256+
const baseDirectory = this.configService.get('SONG_DIRECTORY', 'songs');
257+
return path.resolve(process.cwd(), baseDirectory);
258+
}
259+
260+
261+
/**
262+
* Returns current indexing status
263+
*/
264+
getIndexingStatus(): { isIndexing: boolean } {
265+
return { isIndexing: this.isIndexing };
266+
}
267+
}

apps/singularity-api/src/app/song/song.controller.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ import { AdminGuard } from '../user-management/guards/admin-guard';
3131
import { AuthGuard } from '@nestjs/passport';
3232
import { SongFile } from './interfaces/song-file';
3333
import { SongDownloadService } from './song-download.service';
34+
import { SongIndexingService } from './song-indexing.service';
3435
import * as sharp from 'sharp';
3536

3637
@Controller('song')
3738
export class SongController {
3839

3940
constructor(private readonly songService: SongService,
40-
private readonly songDownloadService: SongDownloadService) {
41+
private readonly songDownloadService: SongDownloadService,
42+
private readonly songIndexingService: SongIndexingService) {
4143
}
4244

4345
@Get()
@@ -175,4 +177,16 @@ export class SongController {
175177
public deleteSong(@Param('id') id: string): Promise<Song> {
176178
return this.songService.deleteSong(+id);
177179
}
180+
181+
@Post('index')
182+
@UseGuards(AdminGuard())
183+
public async indexSongs(): Promise<{ indexed: number; skipped: number; errors: number }> {
184+
return this.songIndexingService.indexSongs();
185+
}
186+
187+
@Get('index/status')
188+
@UseGuards(AdminGuard())
189+
public getIndexingStatus(): { isIndexing: boolean } {
190+
return this.songIndexingService.getIndexingStatus();
191+
}
178192
}

apps/singularity-api/src/app/song/song.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import { YtService } from './yt.service';
1111
import { FanartService } from './fanart.service';
1212
import { HttpModule } from '@nestjs/axios';
1313
import { SongDownloadService } from './song-download.service';
14+
import { SongIndexingService } from './song-indexing.service';
1415

1516
@Module({
1617
controllers: [SongController],
17-
providers: [SongService, YtService, FanartService, SongDownloadService, SongProfile, SongNoteProfile],
18+
providers: [SongService, YtService, FanartService, SongDownloadService, SongIndexingService, SongProfile, SongNoteProfile],
1819
imports: [TypeOrmModule.forFeature([Song, SongNote]), ConfigModule, HttpModule],
19-
exports: [SongService, SongProfile]
20+
exports: [SongService, SongIndexingService, SongProfile]
2021
})
2122
export class SongModule {}

0 commit comments

Comments
 (0)