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+ }
0 commit comments