From 16de76d9eb3ec02b99c176410ac4b3f403127271 Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Tue, 9 Jun 2026 16:56:52 +0300 Subject: [PATCH] Improve AI music and add music world explore --- src/app.module.ts | 2 + .../storage/managed-storage.service.ts | 13 + .../ai-music-prompt-enhancer.service.spec.ts | 118 ++++++ .../media/ai-music-prompt-enhancer.service.ts | 341 ++++++++++++++++++ src/modules/media/dto/text-to-music.dto.ts | 15 + src/modules/media/media.module.ts | 3 +- src/modules/media/media.service.spec.ts | 109 ++++-- src/modules/media/media.service.ts | 288 +++------------ .../music-world.controller.spec.ts | 174 +++++++++ .../music-world/music-world.controller.ts | 31 ++ src/modules/music-world/music-world.module.ts | 13 + .../music-world/music-world.service.ts | 256 +++++++++++++ src/modules/search/search.module.ts | 1 + 13 files changed, 1100 insertions(+), 264 deletions(-) create mode 100644 src/modules/media/ai-music-prompt-enhancer.service.spec.ts create mode 100644 src/modules/media/ai-music-prompt-enhancer.service.ts create mode 100644 src/modules/music-world/music-world.controller.spec.ts create mode 100644 src/modules/music-world/music-world.controller.ts create mode 100644 src/modules/music-world/music-world.module.ts create mode 100644 src/modules/music-world/music-world.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 50320b9..9e97118 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ import { FollowsModule } from './modules/follows/follows.module'; import { LikesModule } from './modules/likes/likes.module'; import { MediaModule } from './modules/media/media.module'; import { MarketplaceModule } from './modules/marketplace/marketplace.module'; +import { MusicWorldModule } from './modules/music-world/music-world.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { OutboxModule } from './modules/outbox/outbox.module'; import { PostsModule } from './modules/posts/posts.module'; @@ -64,6 +65,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard'; ChatModule, MediaModule, MarketplaceModule, + MusicWorldModule, ReportsModule, SavesModule, SearchModule, diff --git a/src/infrastructure/storage/managed-storage.service.ts b/src/infrastructure/storage/managed-storage.service.ts index 201c9be..9f4355b 100644 --- a/src/infrastructure/storage/managed-storage.service.ts +++ b/src/infrastructure/storage/managed-storage.service.ts @@ -151,6 +151,19 @@ export class ManagedStorageService implements OnModuleDestroy { } } + resolveLocalFilePath(fileUrl?: string): string | null { + if (!fileUrl || this.getProvider() !== 'local') { + return null; + } + + const relativePath = this.resolveLocalRelativePath(fileUrl); + if (!relativePath || relativePath.includes('..')) { + return null; + } + + return join(process.cwd(), ...relativePath.split('/')); + } + async deleteContainingDirectory(fileUrl?: string): Promise { if (!fileUrl) { return; diff --git a/src/modules/media/ai-music-prompt-enhancer.service.spec.ts b/src/modules/media/ai-music-prompt-enhancer.service.spec.ts new file mode 100644 index 0000000..c723504 --- /dev/null +++ b/src/modules/media/ai-music-prompt-enhancer.service.spec.ts @@ -0,0 +1,118 @@ +import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; + +describe('AiMusicPromptEnhancerService', () => { + const service = new AiMusicPromptEnhancerService(); + + it('creates a strict solo oud prompt for Arabic oud-only requests', () => { + const result = service.enhance({ + prompt: 'بدي عزف عود فقط', + durationSeconds: 12, + }); + + expect(result.detectedMusicIntent).toMatchObject({ + language: 'ar', + instrument: 'oud', + solo: true, + percussion: 'none', + vocals: false, + }); + expect(result.enhancedPrompt).toContain('Solo oud performance only'); + expect(result.enhancedPrompt).toContain('No percussion, no drums'); + expect(result.enhancedPrompt).toContain('No backing instruments'); + expect(result.enhancedPrompt).toContain('Instrumental only, no vocals, no lyrics'); + }); + + it('detects Arabic oud sad Hijaz maqam prompts', () => { + const result = service.enhance({ + prompt: 'بدي عود حزين مقام حجاز', + durationSeconds: 12, + }); + + expect(result.detectedMusicIntent).toMatchObject({ + language: 'ar', + instrument: 'oud', + maqam: 'hijaz', + mood: 'sad', + style: 'traditional_arabic', + }); + expect(result.enhancedPrompt).toContain('oud'); + expect(result.enhancedPrompt).toContain('sad, emotional, expressive'); + expect(result.enhancedPrompt).toContain('Hijaz maqam'); + expect(result.enhancedPrompt).toContain('traditional Middle Eastern Arabic style'); + }); + + it('detects Levantine calm ney prompts with light arrangement', () => { + const result = service.enhance({ + prompt: 'موسيقى شامية هادئة مع ناي خفيف', + durationSeconds: 12, + }); + + expect(result.detectedMusicIntent).toMatchObject({ + language: 'ar', + instrument: 'ney', + mood: 'calm', + style: 'levantine', + percussion: 'light', + }); + expect(result.enhancedPrompt).toContain('Levantine Arabic style'); + expect(result.enhancedPrompt).toContain('calm, soft, slow'); + expect(result.enhancedPrompt).toContain('ney flute'); + expect(result.enhancedPrompt).toContain('Light subtle percussion'); + }); + + it('enforces no percussion when Arabic prompt asks for no rhythm', () => { + const result = service.enhance({ + prompt: 'بدون إيقاع', + durationSeconds: 12, + }); + + expect(result.detectedMusicIntent.percussion).toBe('none'); + expect(result.enhancedPrompt).toContain('No percussion, no drums'); + }); + + it('creates a strict solo oud prompt for English oud-only requests', () => { + const result = service.enhance({ + prompt: 'solo oud only', + durationSeconds: 12, + }); + + expect(result.detectedMusicIntent).toMatchObject({ + language: 'en', + instrument: 'oud', + solo: true, + percussion: 'none', + }); + expect(result.enhancedPrompt).toContain('Solo oud performance only'); + expect(result.enhancedPrompt).toContain('No backing instruments'); + }); + + it('keeps existing English prompts usable', () => { + const result = service.enhance({ + prompt: 'Calm cinematic music for a sunset scene', + durationSeconds: 12, + }); + + expect(result.detectedMusicIntent.language).toBe('en'); + expect(result.enhancedPrompt).toContain('Instrumental Arabic Middle Eastern music piece'); + expect(result.enhancedPrompt).toContain('User idea: Calm cinematic music for a sunset scene'); + }); + + it('uses optional structured fields for stronger control', () => { + const result = service.enhance({ + prompt: 'warm intro', + instrument: 'qanun', + maqam: 'nahawand', + mood: 'romantic', + style: 'cinematic_arabic', + tempo: 'medium', + percussion: 'light', + vocals: false, + }); + + expect(result.enhancedPrompt).toContain('Nahawand maqam, dramatic minor Arabic mood'); + expect(result.enhancedPrompt).toContain('romantic, warm'); + expect(result.enhancedPrompt).toContain('Instrumental Arabic qanun melody'); + expect(result.enhancedPrompt).toContain('Light subtle percussion'); + expect(result.enhancedPrompt).toContain('cinematic Arabic soundtrack'); + }); +}); diff --git a/src/modules/media/ai-music-prompt-enhancer.service.ts b/src/modules/media/ai-music-prompt-enhancer.service.ts new file mode 100644 index 0000000..0619e9a --- /dev/null +++ b/src/modules/media/ai-music-prompt-enhancer.service.ts @@ -0,0 +1,341 @@ +import { Injectable } from '@nestjs/common'; +import { TextToMusicDto } from './dto/text-to-music.dto'; + +type MusicLanguage = 'ar' | 'en'; +type MusicInstrument = 'oud' | 'qanun' | 'ney' | 'violin' | 'darbuka' | 'riq' | 'mixed'; +type MusicMaqam = 'hijaz' | 'bayati' | 'rast' | 'nahawand' | 'kurd' | 'saba'; +type MusicMood = + | 'sad' + | 'calm' + | 'energetic' + | 'romantic' + | 'spiritual' + | 'traditional' + | 'joyful'; +type MusicStyle = + | 'levantine' + | 'egyptian' + | 'gulf' + | 'andalusian' + | 'traditional_arabic' + | 'classical_arabic' + | 'cinematic_arabic'; + +export type AiMusicDetectedIntent = { + language: MusicLanguage; + instrument: MusicInstrument; + solo: boolean; + maqam: MusicMaqam | null; + mood: MusicMood | null; + style: MusicStyle | null; + percussion: 'none' | 'light' | 'medium'; + vocals: boolean; + tempo: 'slow' | 'medium' | 'fast'; + restrictions: string[]; +}; + +export type AiMusicPromptEnhancement = { + originalPrompt: string; + enhancedPrompt: string; + detectedMusicIntent: AiMusicDetectedIntent; +}; + +const SAFE_PRODUCTION_INSTRUCTIONS = + 'No artist imitation, no copyrighted melody imitation, clean studio recording, balanced mix.'; + +const maqamDescriptions: Record = { + hijaz: 'Hijaz maqam, emotional Middle Eastern Arabic sound', + bayati: 'Bayati maqam, warm Levantine Arabic folk mood', + rast: 'Rast maqam, classical Arabic optimistic character', + nahawand: 'Nahawand maqam, dramatic minor Arabic mood', + kurd: 'Kurd maqam, simple minor Arabic mood', + saba: 'Saba maqam, deeply sad and expressive Arabic mood', +}; + +const moodDescriptions: Record = { + sad: 'sad, emotional, expressive', + calm: 'calm, soft, slow', + energetic: 'energetic, heroic, powerful', + romantic: 'romantic, warm', + spiritual: 'spiritual, meditative', + traditional: 'traditional Arabic folk', + joyful: 'joyful, uplifting', +}; + +const styleDescriptions: Record = { + levantine: 'Levantine Arabic style', + egyptian: 'Egyptian Arabic music style', + gulf: 'Gulf Arabic music style', + andalusian: 'North African and Andalusian Arabic influence', + traditional_arabic: 'traditional Middle Eastern Arabic style', + classical_arabic: 'classical Arabic music style', + cinematic_arabic: 'cinematic Arabic soundtrack', +}; + +const instrumentDescriptions: Record = { + oud: 'oud', + qanun: 'qanun', + ney: 'ney flute', + violin: 'violin', + darbuka: 'darbuka', + riq: 'riq frame drum', + mixed: 'Arabic ensemble', +}; + +const instrumentTerms: Record = { + oud: ['عود', 'العود', 'عزف عود', 'oud'], + qanun: ['قانون', 'القانون', 'qanun'], + ney: ['ناي', 'الناي', 'ney', 'nay'], + violin: ['كمان', 'كمنجة', 'violin'], + darbuka: ['دربكة', 'طبلة', 'darbuka'], + riq: ['رق', 'دف', 'riq'], + mixed: ['فرقة', 'تخت', 'ensemble', 'mixed'], +}; + +const maqamTerms: Record = { + hijaz: ['حجاز', 'مقام حجاز', 'hijaz'], + bayati: ['بياتي', 'مقام بياتي', 'bayati'], + rast: ['راست', 'مقام راست', 'rast'], + nahawand: ['نهاوند', 'مقام نهاوند', 'nahawand'], + kurd: ['كرد', 'مقام كرد', 'kurd'], + saba: ['صبا', 'مقام صبا', 'saba'], +}; + +const moodTerms: Record = { + sad: ['حزين', 'حزن', 'مؤثر', 'عاطفي', 'sad'], + calm: ['هادئ', 'هادي', 'رايق', 'ناعم', 'calm', 'soft'], + energetic: ['حماسي', 'قوي', 'ملحمي', 'energetic', 'heroic'], + romantic: ['رومانسي', 'romantic'], + spiritual: ['صوفي', 'روحاني', 'spiritual'], + traditional: ['تراثي', 'شعبي', 'traditional', 'folk'], + joyful: ['فرح', 'مبهج', 'joyful', 'uplifting'], +}; + +const styleTerms: Record = { + levantine: ['شامي', 'سوري', 'لبناني', 'فلسطيني', 'أردني', 'levantine'], + egyptian: ['مصري', 'egyptian'], + gulf: ['خليجي', 'gulf'], + andalusian: ['مغربي', 'أندلسي', 'اندلسي', 'andalusian', 'north african'], + traditional_arabic: ['شرقي', 'traditional middle eastern'], + classical_arabic: ['كلاسيكي عربي', 'classical arabic'], + cinematic_arabic: ['سينمائي', 'cinematic'], +}; + +@Injectable() +export class AiMusicPromptEnhancerService { + enhance(dto: TextToMusicDto): AiMusicPromptEnhancement { + const originalPrompt = dto.prompt.trim(); + const normalizedPrompt = this.normalize(originalPrompt); + const language: MusicLanguage = /[\u0600-\u06ff]/.test(originalPrompt) ? 'ar' : 'en'; + const explicitInstrument = this.normalizeKnownKey(dto.instrument, instrumentDescriptions); + const instrument = + explicitInstrument ?? + this.detectByTerms(normalizedPrompt, instrumentTerms) ?? + this.detectByTerms(this.normalize((dto.instruments ?? []).join(' ')), instrumentTerms) ?? + 'mixed'; + const maqam = + this.normalizeKnownKey(dto.maqam, maqamDescriptions) ?? + this.detectByTerms(normalizedPrompt, maqamTerms); + const mood = + this.normalizeKnownKey(dto.mood, moodDescriptions) ?? this.detectByTerms(normalizedPrompt, moodTerms); + const style = + this.normalizeKnownKey(dto.style, styleDescriptions) ?? + this.detectByTerms(normalizedPrompt, styleTerms) ?? + (language === 'ar' ? 'traditional_arabic' : null); + const restrictions = this.detectRestrictions(normalizedPrompt); + const solo = + dto.solo === true || + restrictions.includes('solo') || + (instrument !== 'mixed' && this.includesAny(normalizedPrompt, ['only', 'solo', 'فقط', 'بس', 'لوحده', 'لحاله'])); + const vocals = + dto.vocals === true + ? true + : dto.vocals === false || restrictions.includes('no_vocals') + ? false + : false; + const percussion = + dto.percussion ?? + (solo || restrictions.includes('no_percussion') + ? 'none' + : this.inferPercussion(normalizedPrompt, instrument)); + const tempo = dto.tempo ?? this.inferTempo(normalizedPrompt, mood); + + const detectedMusicIntent: AiMusicDetectedIntent = { + language, + instrument, + solo, + maqam, + mood, + style, + percussion, + vocals, + tempo, + restrictions, + }; + + return { + originalPrompt, + enhancedPrompt: this.buildPrompt(originalPrompt, detectedMusicIntent), + detectedMusicIntent, + }; + } + + private buildPrompt(originalPrompt: string, intent: AiMusicDetectedIntent): string { + const parts: string[] = []; + const instrument = instrumentDescriptions[intent.instrument]; + + if (intent.solo && intent.instrument !== 'mixed') { + parts.push(`Solo ${instrument} performance only`); + parts.push('No backing instruments, no ensemble, no strings, no piano, no other instruments'); + } else if (intent.instrument !== 'mixed') { + parts.push(`Instrumental Arabic ${instrument} melody`); + } else { + parts.push('Instrumental Arabic Middle Eastern music piece'); + } + + if (intent.maqam) { + parts.push(maqamDescriptions[intent.maqam]); + } + if (intent.mood) { + parts.push(moodDescriptions[intent.mood]); + } + if (intent.style) { + parts.push(styleDescriptions[intent.style]); + } + if (intent.restrictions.includes('taqsim') || (intent.solo && intent.instrument === 'oud')) { + parts.push('Traditional Arabic taqsim improvisation'); + } + if (intent.percussion === 'none') { + parts.push('No percussion, no drums'); + } else if (intent.percussion === 'light') { + parts.push('Light subtle percussion'); + } else { + parts.push('Medium Arabic percussion groove'); + } + parts.push(`${intent.tempo} tempo`); + if (!intent.vocals) { + parts.push('Instrumental only, no vocals, no lyrics'); + } + parts.push(SAFE_PRODUCTION_INSTRUCTIONS); + if (intent.language !== 'ar' && !intent.solo) { + parts.push(`User idea: ${originalPrompt}`); + } + + return this.dedupe(parts).join(', '); + } + + private detectRestrictions(normalizedPrompt: string): string[] { + const restrictions = new Set(); + if (this.includesAny(normalizedPrompt, ['فقط', 'بس', 'لوحده', 'لحاله', 'only', 'solo'])) { + restrictions.add('solo'); + } + if ( + this.includesAny(normalizedPrompt, [ + 'بدون غناء', + 'لا غناء', + 'من غير صوت', + 'بدون صوت', + 'no vocals', + 'no lyrics', + ]) + ) { + restrictions.add('no_vocals'); + } + if ( + this.includesAny(normalizedPrompt, [ + 'بدون ايقاع', + 'بدون إيقاع', + 'بدون طبله', + 'بدون طبلة', + 'بدون درامز', + 'no percussion', + 'no drums', + ]) + ) { + restrictions.add('no_percussion'); + } + if (this.includesAny(normalizedPrompt, ['بدون الات ثانيه', 'بدون آلات ثانية', 'no other instruments'])) { + restrictions.add('no_other_instruments'); + restrictions.add('solo'); + } + if (this.includesAny(normalizedPrompt, ['تقاسيم', 'تقسيم', 'taqsim'])) { + restrictions.add('taqsim'); + } + return [...restrictions]; + } + + private inferPercussion( + normalizedPrompt: string, + instrument: MusicInstrument, + ): 'none' | 'light' | 'medium' { + if (this.includesAny(normalizedPrompt, ['خفيف', 'light', 'ناعم'])) { + return 'light'; + } + if ( + instrument === 'darbuka' || + instrument === 'riq' || + this.includesAny(normalizedPrompt, ['ايقاع', 'إيقاع', 'دربكه', 'دربكة', 'طبله', 'طبلة', 'percussion']) + ) { + return 'medium'; + } + return 'none'; + } + + private inferTempo(normalizedPrompt: string, mood: MusicMood | null): 'slow' | 'medium' | 'fast' { + if (this.includesAny(normalizedPrompt, ['سريع', 'fast']) || mood === 'energetic') { + return 'fast'; + } + if (this.includesAny(normalizedPrompt, ['بطي', 'هاد', 'slow']) || mood === 'sad' || mood === 'calm') { + return 'slow'; + } + return 'medium'; + } + + private detectByTerms( + normalizedPrompt: string, + termMap: Record, + ): T | null { + for (const [key, terms] of Object.entries(termMap) as Array<[T, string[]]>) { + if (this.includesAny(normalizedPrompt, terms.map((term) => this.normalize(term)))) { + return key; + } + } + return null; + } + + private normalizeKnownKey(value: string | undefined, known: Record): T | null { + if (!value) { + return null; + } + const normalized = this.normalize(value).replace(/\s+/g, '_'); + return normalized in known ? (normalized as T) : null; + } + + private includesAny(value: string, terms: string[]): boolean { + return terms.some((term) => value.includes(this.normalize(term))); + } + + private normalize(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[أإآ]/g, 'ا') + .replace(/ى/g, 'ي') + .replace(/ة/g, 'ه') + .replace(/[ًٌٍَُِّْـ]/g, ''); + } + + private dedupe(parts: string[]): string[] { + const seen = new Set(); + const cleanParts: string[] = []; + for (const part of parts) { + const clean = part.trim().replace(/\s+/g, ' '); + const key = clean.toLowerCase(); + if (clean && !seen.has(key)) { + cleanParts.push(clean); + seen.add(key); + } + } + return cleanParts; + } +} diff --git a/src/modules/media/dto/text-to-music.dto.ts b/src/modules/media/dto/text-to-music.dto.ts index 1e82f0d..14d3268 100644 --- a/src/modules/media/dto/text-to-music.dto.ts +++ b/src/modules/media/dto/text-to-music.dto.ts @@ -1,6 +1,7 @@ import { Type } from 'class-transformer'; import { IsArray, + IsBoolean, IsIn, IsInt, IsNotEmpty, @@ -31,6 +32,15 @@ export class TextToMusicDto { @Max(2147483647) seed?: number; + @IsOptional() + @IsIn(['oud', 'qanun', 'ney', 'violin', 'darbuka', 'riq', 'mixed']) + instrument?: 'oud' | 'qanun' | 'ney' | 'violin' | 'darbuka' | 'riq' | 'mixed'; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + solo?: boolean; + @IsOptional() @IsString() @MaxLength(80) @@ -59,4 +69,9 @@ export class TextToMusicDto { @IsOptional() @IsIn(['none', 'light', 'medium']) percussion?: 'none' | 'light' | 'medium'; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + vocals?: boolean; } diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts index c24cde2..9fec428 100644 --- a/src/modules/media/media.module.ts +++ b/src/modules/media/media.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { MediaController } from './media.controller'; +import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; import { MediaService } from './media.service'; import { MediaRepository } from './media.repository'; @Module({ controllers: [MediaController], - providers: [MediaService, MediaRepository], + providers: [MediaService, MediaRepository, AiMusicPromptEnhancerService], exports: [MediaService], }) export class MediaModule {} diff --git a/src/modules/media/media.service.spec.ts b/src/modules/media/media.service.spec.ts index 501b777..152c044 100644 --- a/src/modules/media/media.service.spec.ts +++ b/src/modules/media/media.service.spec.ts @@ -1,4 +1,5 @@ import { ServiceUnavailableException } from '@nestjs/common'; +import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; import { MediaService } from './media.service'; const createPcmWavBuffer = (frames = 8000) => { @@ -32,7 +33,7 @@ const createPcmWavBuffer = (frames = 8000) => { return buffer; }; -describe('MediaService Google credentials resolution', () => { +describe('MediaService', () => { const createService = ( googleCredentialsBase64 = '', overrides: { @@ -57,6 +58,7 @@ describe('MediaService Google credentials resolution', () => { configService as any, (overrides.storageService ?? {}) as any, (overrides.mediaProbeService ?? {}) as any, + new AiMusicPromptEnhancerService(), ) as any; }; @@ -105,8 +107,10 @@ describe('MediaService Google credentials resolution', () => { }) as any; const storageService = { saveFile: jest.fn().mockResolvedValue('/uploads/ai-music/generated.wav'), + resolveLocalFilePath: jest.fn().mockReturnValue(null), }; const mediaProbeService = { + extractDurationSeconds: jest.fn(), extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(32), }; const service = createService('', { @@ -131,10 +135,18 @@ describe('MediaService Google credentials resolution', () => { expect(result.prompt).toBe('test prompt'); expect(result.originalPrompt).toBe('test prompt'); expect(result.enhancedPrompt).toContain('Instrumental only, no vocals'); + expect(result.detectedMusicIntent).toMatchObject({ + language: 'en', + vocals: false, + }); expect(result.audioUrl).toBe('/uploads/ai-music/generated.wav'); expect(result.mimeType).toBe('audio/wav'); expect(result.sizeBytes).toBe(audioBuffer.length); expect(result.waveformPeaks).toHaveLength(100); + expect(storageService.resolveLocalFilePath).toHaveBeenCalledWith( + '/uploads/ai-music/generated.wav', + ); + expect(mediaProbeService.extractDurationSeconds).not.toHaveBeenCalled(); expect(mediaProbeService.extractDurationSecondsFromBuffer).toHaveBeenCalledWith( audioBuffer, { @@ -147,7 +159,7 @@ describe('MediaService Google credentials resolution', () => { } }); - it('enhances Arabic Middle Eastern music prompts before sending them to Lyria', async () => { + it('sends the enhanced Arabic prompt to Lyria while preserving the original prompt', async () => { const audioBuffer = createPcmWavBuffer(); const originalFetch = global.fetch; global.fetch = jest.fn().mockResolvedValue({ @@ -171,52 +183,97 @@ describe('MediaService Google credentials resolution', () => { }, storageService: { saveFile: jest.fn().mockResolvedValue('/uploads/ai-music/generated.wav'), + resolveLocalFilePath: jest.fn().mockReturnValue(null), }, mediaProbeService: { + extractDurationSeconds: jest.fn(), extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(12), }, }); try { + const prompt = 'بدي لحن عود حزين مقام حجاز مع إيقاع خفيف'; const result = await service.generateMusicFromText('user-1', { - prompt: 'بدي لحن عود حزين مقام حجاز مع إيقاع خفيف', + prompt, durationSeconds: 12, }); const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); const sentPrompt = fetchBody.instances[0].prompt as string; - expect(result.prompt).toBe('بدي لحن عود حزين مقام حجاز مع إيقاع خفيف'); - expect(result.originalPrompt).toBe('بدي لحن عود حزين مقام حجاز مع إيقاع خفيف'); + expect(result.prompt).toBe(prompt); + expect(result.originalPrompt).toBe(prompt); expect(result.enhancedPrompt).toBe(sentPrompt); + expect(result.detectedMusicIntent).toMatchObject({ + language: 'ar', + instrument: 'oud', + maqam: 'hijaz', + mood: 'sad', + percussion: 'light', + }); expect(sentPrompt).toContain('Instrumental Arabic oud melody'); - expect(sentPrompt).toContain('Hijaz maqam, emotional Middle Eastern sound'); - expect(sentPrompt).toContain('sad, emotional, expressive mood'); - expect(sentPrompt).toContain('light riq percussion'); + expect(sentPrompt).toContain('Hijaz maqam, emotional Middle Eastern Arabic sound'); + expect(sentPrompt).toContain('sad, emotional, expressive'); + expect(sentPrompt).toContain('Light subtle percussion'); expect(sentPrompt).toContain('slow tempo'); - expect(sentPrompt).toContain( - 'Instrumental only, no vocals, no lyrics, no artist imitation, clean studio recording, balanced mix.', - ); + expect(sentPrompt).toContain('Instrumental only, no vocals, no lyrics'); + expect(sentPrompt).toContain('No artist imitation, no copyrighted melody imitation'); } finally { global.fetch = originalFetch; } }); - it('uses optional maqam mood instruments and percussion fields in the enhanced prompt', () => { - const service = createService(); - const enhancedPrompt = service.buildEnhancedMusicPrompt({ - prompt: 'warm intro', - maqam: 'Nahawand', - mood: 'romantic', - instruments: ['qanun', 'ney'], - style: 'cinematic Levantine arrangement', - tempo: 'medium', - percussion: 'light', + it('probes duration from the saved local audio file when local storage path is available', async () => { + const audioBuffer = createPcmWavBuffer(); + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + predictions: [ + { + bytesBase64Encoded: audioBuffer.toString('base64'), + mimeType: 'audio/wav', + }, + ], + }), + }) as any; + const storageService = { + saveFile: jest.fn().mockResolvedValue('/uploads/ai-music/generated.wav'), + resolveLocalFilePath: jest + .fn() + .mockReturnValue('C:\\app\\uploads\\ai-music\\generated.wav'), + }; + const mediaProbeService = { + extractDurationSeconds: jest.fn().mockResolvedValue(32), + extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(12), + }; + const service = createService('', { + config: { + 'aiMusic.enabled': true, + 'aiMusic.apiKey': 'test-api-key', + 'aiMusic.projectId': 'accordev', + 'aiMusic.location': 'us-central1', + 'aiMusic.model': 'lyria-002', + }, + storageService, + mediaProbeService, }); - expect(enhancedPrompt).toContain('Nahawand maqam, dramatic minor Arabic mood'); - expect(enhancedPrompt).toContain('romantic, warm mood'); - expect(enhancedPrompt).toContain('Instrumental Arabic qanun melody'); - expect(enhancedPrompt).toContain('light percussion'); - expect(enhancedPrompt).toContain('cinematic Levantine arrangement'); + try { + const result = await service.generateMusicFromText('user-1', { + prompt: 'test prompt', + durationSeconds: 12, + }); + + expect(result.durationSeconds).toBe(32); + expect(storageService.resolveLocalFilePath).toHaveBeenCalledWith( + '/uploads/ai-music/generated.wav', + ); + expect(mediaProbeService.extractDurationSeconds).toHaveBeenCalledWith( + 'C:\\app\\uploads\\ai-music\\generated.wav', + ); + expect(mediaProbeService.extractDurationSecondsFromBuffer).not.toHaveBeenCalled(); + } finally { + global.fetch = originalFetch; + } }); }); diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 756e9be..729c683 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,47 +1,20 @@ import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { readFile } from 'fs/promises'; import { GoogleAuth } from 'google-auth-library'; import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { MediaProbeService } from '../../infrastructure/storage/media-probe.service'; +import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; import { TextToMusicDto } from './dto/text-to-music.dto'; -const AI_MUSIC_SAFE_PRODUCTION_INSTRUCTIONS = - 'Instrumental only, no vocals, no lyrics, no artist imitation, clean studio recording, balanced mix.'; - -const ARABIC_MAQAM_MAPPINGS: Array<{ terms: string[]; description: string }> = [ - { terms: ['حجاز', 'hijaz'], description: 'Hijaz maqam, emotional Middle Eastern sound' }, - { terms: ['بياتي', 'bayati'], description: 'Bayati maqam, warm Levantine folk mood' }, - { terms: ['راست', 'rast'], description: 'Rast maqam, classical Arabic optimistic character' }, - { terms: ['نهاوند', 'nahawand'], description: 'Nahawand maqam, dramatic minor Arabic mood' }, - { terms: ['كرد', 'kurd'], description: 'Kurd maqam, simple minor Arabic mood' }, - { terms: ['صبا', 'saba'], description: 'Saba maqam, deeply sad Arabic mood' }, -]; - -const ARABIC_INSTRUMENT_MAPPINGS: Array<{ terms: string[]; description: string }> = [ - { terms: ['عود', 'oud'], description: 'oud' }, - { terms: ['قانون', 'qanun'], description: 'qanun' }, - { terms: ['ناي', 'ney'], description: 'ney flute' }, - { terms: ['كمان', 'violin'], description: 'violin' }, - { terms: ['رق', 'riq'], description: 'riq frame drum' }, - { terms: ['طبلة', 'دربكة', 'darbuka'], description: 'darbuka' }, -]; - -const ARABIC_MOOD_MAPPINGS: Array<{ terms: string[]; description: string }> = [ - { terms: ['حزين', 'sad'], description: 'sad, emotional, expressive' }, - { terms: ['هادئ', 'هادي', 'calm'], description: 'calm, soft, slow' }, - { terms: ['حماسي', 'energetic'], description: 'energetic, heroic' }, - { terms: ['رومانسي', 'romantic'], description: 'romantic, warm' }, - { terms: ['صوفي', 'spiritual'], description: 'spiritual, meditative' }, - { terms: ['تراثي', 'traditional'], description: 'traditional Arabic folk' }, -]; - @Injectable() export class MediaService { constructor( private readonly configService: ConfigService, private readonly storageService: ManagedStorageService, private readonly mediaProbeService: MediaProbeService, + private readonly promptEnhancer: AiMusicPromptEnhancerService, ) {} async getMediaHealth() { @@ -84,7 +57,9 @@ export class MediaService { warnings.push('S3 provider selected but missing required env variables'); } if (storageProvider === 's3' && !storageHealth.storagePublicBaseUrlConfigured) { - warnings.push('STORAGE_PUBLIC_BASE_URL is not configured; media may be served from the storage endpoint instead of CDN'); + warnings.push( + 'STORAGE_PUBLIC_BASE_URL is not configured; media may be served from the storage endpoint instead of CDN', + ); } const s3Health = storageHealth.s3 as Record | undefined; if (storageProvider === 's3' && s3Health?.reachable === false) { @@ -178,9 +153,9 @@ export class MediaService { authorizationHeader = `Bearer ${accessToken}`; } - const enhancedPrompt = this.buildEnhancedMusicPrompt(dto); + const promptEnhancement = this.promptEnhancer.enhance(dto); const requestBody: Record = { - instances: [{ prompt: enhancedPrompt }], + instances: [{ prompt: promptEnhancement.enhancedPrompt }], parameters: { sampleCount: 1, durationSeconds: dto.durationSeconds ?? 12, @@ -232,26 +207,59 @@ export class MediaService { contentType: mimeType, fileNamePrefix: `ai-${userId}`, }); - const actualDurationSeconds = await this.mediaProbeService.extractDurationSecondsFromBuffer( - buffer, - { - originalname: `ai-music.${extension}`, - mimetype: mimeType, - }, - ); + const savedAudioPath = this.storageService.resolveLocalFilePath(audioUrl); + const [actualDurationSeconds, waveformBuffer] = await Promise.all([ + this.resolveSavedAudioDurationSeconds(savedAudioPath, buffer, extension, mimeType), + this.resolveSavedAudioBuffer(savedAudioPath, buffer), + ]); return { prompt: dto.prompt, - originalPrompt: dto.prompt, - enhancedPrompt, + originalPrompt: promptEnhancement.originalPrompt, + enhancedPrompt: promptEnhancement.enhancedPrompt, + detectedMusicIntent: promptEnhancement.detectedMusicIntent, durationSeconds: actualDurationSeconds ?? dto.durationSeconds ?? 12, mimeType, sizeBytes: buffer.length, audioUrl, - waveformPeaks: generateWaveformPeaksFromBuffer(buffer), + waveformPeaks: generateWaveformPeaksFromBuffer(waveformBuffer), }; } + private async resolveSavedAudioDurationSeconds( + savedAudioPath: string | null, + buffer: Buffer, + extension: string, + mimeType: string, + ): Promise { + if (savedAudioPath) { + const durationSeconds = await this.mediaProbeService.extractDurationSeconds(savedAudioPath); + if (durationSeconds) { + return durationSeconds; + } + } + + return this.mediaProbeService.extractDurationSecondsFromBuffer(buffer, { + originalname: `ai-music.${extension}`, + mimetype: mimeType, + }); + } + + private async resolveSavedAudioBuffer( + savedAudioPath: string | null, + fallbackBuffer: Buffer, + ): Promise { + if (!savedAudioPath) { + return fallbackBuffer; + } + + try { + return await readFile(savedAudioPath); + } catch { + return fallbackBuffer; + } + } + private resolveAudioExtension(mimeType: string): string { if (mimeType.includes('mpeg') || mimeType.includes('mp3')) { return 'mp3'; @@ -268,200 +276,6 @@ export class MediaService { return 'wav'; } - private buildEnhancedMusicPrompt(dto: TextToMusicDto): string { - const originalPrompt = dto.prompt.trim(); - const normalizedPrompt = this.normalizeArabicText(originalPrompt).toLowerCase(); - const isArabicPrompt = /[\u0600-\u06ff]/.test(originalPrompt); - - const maqam = this.resolveMappedDescription( - dto.maqam, - normalizedPrompt, - ARABIC_MAQAM_MAPPINGS, - ); - const moods = this.resolveMappedDescriptions( - dto.mood ? [dto.mood] : [], - normalizedPrompt, - ARABIC_MOOD_MAPPINGS, - ); - const instruments = this.resolveMappedDescriptions( - dto.instruments ?? [], - normalizedPrompt, - ARABIC_INSTRUMENT_MAPPINGS, - ); - const tempo = dto.tempo ?? this.inferTempo(normalizedPrompt, moods); - const percussion = dto.percussion ?? this.inferPercussion(normalizedPrompt, instruments); - const style = dto.style?.trim(); - - const parts: string[] = []; - if (isArabicPrompt || maqam || instruments.length || moods.length) { - const mainInstruments = instruments.filter( - (instrument) => !instrument.includes('drum') && !instrument.includes('riq'), - ); - const melodyInstrument = mainInstruments[0] ?? instruments[0]; - parts.push( - melodyInstrument - ? `Instrumental Arabic ${melodyInstrument} melody` - : 'Instrumental Arabic music piece', - ); - } else { - parts.push(`Instrumental music based on this idea: ${originalPrompt}`); - } - - if (maqam) { - parts.push(`in ${maqam}`); - } - if (moods.length) { - parts.push(`${moods.join(', ')} mood`); - } - if (percussion !== 'none') { - parts.push(this.describePercussion(percussion, instruments, isArabicPrompt)); - } - if (tempo) { - parts.push(`${tempo} tempo`); - } - if (style) { - parts.push(style); - } else if (isArabicPrompt) { - parts.push('traditional Arabic atmosphere'); - } - - parts.push(AI_MUSIC_SAFE_PRODUCTION_INSTRUCTIONS); - return this.dedupePromptParts(parts).join(', '); - } - - private normalizeArabicText(value: string): string { - return value - .replace(/[أإآ]/g, 'ا') - .replace(/ى/g, 'ي') - .replace(/ة/g, 'ه') - .replace(/[ًٌٍَُِّْـ]/g, ''); - } - - private resolveMappedDescription( - explicitValue: string | undefined, - normalizedPrompt: string, - mappings: Array<{ terms: string[]; description: string }>, - ): string | null { - return this.resolveMappedDescriptions(explicitValue ? [explicitValue] : [], normalizedPrompt, mappings)[0] ?? null; - } - - private resolveMappedDescriptions( - explicitValues: string[], - normalizedPrompt: string, - mappings: Array<{ terms: string[]; description: string }>, - ): string[] { - const values = new Set(); - const normalizedExplicitValues = explicitValues.map((value) => - this.normalizeArabicText(value).toLowerCase(), - ); - - for (const mapping of mappings) { - const normalizedTerms = mapping.terms.map((term) => - this.normalizeArabicText(term).toLowerCase(), - ); - if ( - normalizedTerms.some( - (term) => - normalizedPrompt.includes(term) || - normalizedExplicitValues.some((value) => value.includes(term)), - ) - ) { - values.add(mapping.description); - } - } - - for (const value of explicitValues) { - const trimmed = value.trim(); - if (trimmed && ![...values].some((mappedValue) => mappedValue.toLowerCase().includes(trimmed.toLowerCase()))) { - values.add(trimmed); - } - } - - return [...values]; - } - - private inferTempo(normalizedPrompt: string, moods: string[]): 'slow' | 'medium' | 'fast' { - if ( - normalizedPrompt.includes('سريع') || - normalizedPrompt.includes('fast') || - moods.some((mood) => mood.includes('energetic')) - ) { - return 'fast'; - } - if ( - normalizedPrompt.includes('بطي') || - normalizedPrompt.includes('هاد') || - normalizedPrompt.includes('slow') || - moods.some((mood) => mood.includes('sad') || mood.includes('calm')) - ) { - return 'slow'; - } - return 'medium'; - } - - private inferPercussion( - normalizedPrompt: string, - instruments: string[], - ): 'none' | 'light' | 'medium' { - if (normalizedPrompt.includes('بدون ايقاع') || normalizedPrompt.includes('no percussion')) { - return 'none'; - } - if ( - normalizedPrompt.includes('خفيف') || - normalizedPrompt.includes('light') || - instruments.includes('riq frame drum') - ) { - return 'light'; - } - if ( - normalizedPrompt.includes('ايقاع') || - normalizedPrompt.includes('دربكه') || - normalizedPrompt.includes('طبله') || - normalizedPrompt.includes('percussion') || - instruments.includes('darbuka') - ) { - return 'medium'; - } - return 'none'; - } - - private describePercussion( - percussion: 'light' | 'medium', - instruments: string[], - isArabicPrompt: boolean, - ): string { - const hasDarbuka = instruments.includes('darbuka'); - const hasRiq = instruments.includes('riq frame drum'); - if (percussion === 'light') { - if (hasDarbuka) { - return 'light darbuka percussion'; - } - if (hasRiq || isArabicPrompt) { - return 'light riq percussion'; - } - return 'light percussion'; - } - - if (hasRiq && !hasDarbuka) { - return 'medium riq percussion'; - } - return isArabicPrompt || hasDarbuka ? 'medium darbuka percussion' : 'medium percussion'; - } - - private dedupePromptParts(parts: string[]): string[] { - const seen = new Set(); - const cleanParts: string[] = []; - for (const part of parts) { - const clean = part.trim().replace(/\s+/g, ' '); - const key = clean.toLowerCase(); - if (clean && !seen.has(key)) { - cleanParts.push(clean); - seen.add(key); - } - } - return cleanParts; - } - private resolveGoogleApplicationCredentials(): { credentials?: any; } { diff --git a/src/modules/music-world/music-world.controller.spec.ts b/src/modules/music-world/music-world.controller.spec.ts new file mode 100644 index 0000000..83d4c40 --- /dev/null +++ b/src/modules/music-world/music-world.controller.spec.ts @@ -0,0 +1,174 @@ +import { MusicWorldController } from './music-world.controller'; +import { MusicWorldService } from './music-world.service'; + +describe('MusicWorldController', () => { + const createController = ( + feedService = { getExplore: jest.fn() }, + searchService = { searchPosts: jest.fn() }, + ) => new MusicWorldController(new MusicWorldService(feedService as any, searchService as any)); + + it('returns Flutter-ready Arabic music discovery cards', () => { + const controller = createController(); + + const result = controller.getMusicWorld(); + + expect(result.title).toBe('عالم الموسيقى'); + expect(result.searchPlaceholder).toBe('شو حابب تلاقي اليوم؟'); + expect(result.cards).toHaveLength(6); + expect(result.cards[0]).toEqual({ + key: 'oud', + title: 'عود', + subtitle: 'اكتشف عزف العود والمقامات الشرقية', + imageUrl: '/uploads/music-world/oud.jpg', + type: 'search', + endpoint: '/api/v1/search/posts', + query: 'عود', + }); + expect(result.cards.some((card) => card.type === 'navigation')).toBe(true); + expect(result.sections.map((section) => section.key)).toEqual([ + 'trending', + 'explore', + 'artists', + ]); + }); + + it('maps feed explore posts into Flutter grid items', async () => { + const feedService = { + getExplore: jest.fn().mockResolvedValue({ + items: [ + { + id: 'post-1', + feedItemType: 'post', + postType: 'video', + content: 'عود حجاز', + thumbnailUrl: '/uploads/thumb.jpg', + media: { + mediaType: 'video', + displayUrl: '/uploads/thumb-medium.jpg', + thumbnailUrl: '/uploads/thumb.jpg', + }, + authorId: { + _id: 'user-1', + name: 'Artist', + username: 'artist', + avatar: '/avatar.jpg', + isVerified: true, + }, + engagement: { + likesCount: 3, + commentsCount: 2, + viewCount: 10, + playCount: 7, + }, + likedByMe: true, + savedByMe: false, + createdAt: '2026-06-09T00:00:00.000Z', + }, + { + id: 'card-1', + feedItemType: 'featured_marketplace', + }, + ], + page: 1, + limit: 20, + total: 1, + totalPages: 1, + nextCursor: null, + pagination: { hasNextPage: false, nextCursor: null, page: 1, limit: 20 }, + }), + }; + const controller = createController(feedService); + + const result = await controller.getExplore({ sub: 'viewer-1' } as any, { + page: 1, + limit: 20, + }); + + expect(feedService.getExplore).toHaveBeenCalledWith('viewer-1', { page: 1, limit: 20 }); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + id: 'post-1', + postType: 'video', + content: 'عود حجاز', + thumbnailUrl: '/uploads/thumb.jpg', + displayUrl: '/uploads/thumb-medium.jpg', + author: { + id: 'user-1', + username: 'artist', + isVerified: true, + }, + engagement: { + likesCount: 3, + commentsCount: 2, + viewCount: 10, + playCount: 7, + }, + isLiked: true, + isSaved: false, + }); + expect(result.pagination).toMatchObject({ hasNextPage: false }); + }); + + it('maps music-world search results into the same grid item shape', async () => { + const searchService = { + searchPosts: jest.fn().mockResolvedValue({ + items: [ + { + _id: 'post-2', + postType: 'image', + content: 'ناي', + imageUrls: ['/uploads/ney.jpg'], + authorId: { + _id: 'user-2', + name: 'Ney Artist', + username: 'ney_artist', + avatar: '', + isVerified: false, + }, + likesCount: 4, + commentsCount: 1, + isLiked: false, + isSaved: true, + createdAt: '2026-06-09T00:00:00.000Z', + }, + ], + page: 1, + limit: 20, + total: 1, + totalPages: 1, + nextCursor: null, + pagination: { hasNextPage: false }, + }), + }; + const controller = createController(undefined, searchService); + + const result = await controller.search({ sub: 'viewer-1' } as any, { + q: 'ناي', + page: 1, + limit: 20, + }); + + expect(searchService.searchPosts).toHaveBeenCalledWith('viewer-1', { + q: 'ناي', + page: 1, + limit: 20, + type: 'posts', + }); + expect(result.items[0]).toMatchObject({ + id: 'post-2', + postType: 'image', + displayUrl: '/uploads/ney.jpg', + thumbnailUrl: '/uploads/ney.jpg', + author: { + id: 'user-2', + username: 'ney_artist', + }, + engagement: { + likesCount: 4, + commentsCount: 1, + }, + isLiked: false, + isSaved: true, + }); + }); +}); diff --git a/src/modules/music-world/music-world.controller.ts b/src/modules/music-world/music-world.controller.ts new file mode 100644 index 0000000..af7bd60 --- /dev/null +++ b/src/modules/music-world/music-world.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; +import { FeedQueryDto } from '../feed/dto/feed-query.dto'; +import { SearchQueryDto } from '../search/dto/search-query.dto'; +import { MusicWorldService } from './music-world.service'; + +@ApiTags('Music World') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('music-world') +export class MusicWorldController { + constructor(private readonly musicWorldService: MusicWorldService) {} + + @Get() + getMusicWorld() { + return this.musicWorldService.getMusicWorld(); + } + + @Get('explore') + getExplore(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) { + return this.musicWorldService.getExplore(user.sub, query); + } + + @Get('search') + search(@CurrentUser() user: JwtPayload, @Query() query: SearchQueryDto) { + return this.musicWorldService.search(user.sub, query); + } +} diff --git a/src/modules/music-world/music-world.module.ts b/src/modules/music-world/music-world.module.ts new file mode 100644 index 0000000..9dac8e5 --- /dev/null +++ b/src/modules/music-world/music-world.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { FeedModule } from '../feed/feed.module'; +import { SearchModule } from '../search/search.module'; +import { MusicWorldController } from './music-world.controller'; +import { MusicWorldService } from './music-world.service'; + +@Module({ + imports: [FeedModule, SearchModule], + controllers: [MusicWorldController], + providers: [MusicWorldService], + exports: [MusicWorldService], +}) +export class MusicWorldModule {} diff --git a/src/modules/music-world/music-world.service.ts b/src/modules/music-world/music-world.service.ts new file mode 100644 index 0000000..fac1378 --- /dev/null +++ b/src/modules/music-world/music-world.service.ts @@ -0,0 +1,256 @@ +import { Injectable } from '@nestjs/common'; +import { FeedQueryDto } from '../feed/dto/feed-query.dto'; +import { FeedService } from '../feed/feed.service'; +import { SearchQueryDto } from '../search/dto/search-query.dto'; +import { SearchService } from '../search/search.service'; + +export type MusicWorldCardType = 'search' | 'navigation'; + +export type MusicWorldCard = { + key: string; + title: string; + subtitle: string; + imageUrl: string | null; + type: MusicWorldCardType; + endpoint: string; + query?: string; +}; + +export type MusicWorldSection = { + key: string; + title: string; + endpoint: string; +}; + +export type MusicWorldResponse = { + title: string; + searchPlaceholder: string; + cards: MusicWorldCard[]; + sections: MusicWorldSection[]; +}; + +type ExploreGridItem = { + id: string; + postType: string; + content: string; + thumbnailUrl: string; + displayUrl: string; + media: Record; + author: { + id: string; + name: string; + username: string; + avatar: string; + isVerified: boolean; + }; + engagement: { + likesCount: number; + commentsCount: number; + viewCount: number; + playCount: number; + }; + isLiked: boolean; + isSaved: boolean; + createdAt: unknown; +}; + +@Injectable() +export class MusicWorldService { + constructor( + private readonly feedService: FeedService, + private readonly searchService: SearchService, + ) {} + + getMusicWorld(): MusicWorldResponse { + return { + title: 'عالم الموسيقى', + searchPlaceholder: 'شو حابب تلاقي اليوم؟', + cards: [ + { + key: 'oud', + title: 'عود', + subtitle: 'اكتشف عزف العود والمقامات الشرقية', + imageUrl: '/uploads/music-world/oud.jpg', + type: 'search', + endpoint: '/api/v1/search/posts', + query: 'عود', + }, + { + key: 'ney', + title: 'ناي', + subtitle: 'أجواء هادئة وروحانية', + imageUrl: '/uploads/music-world/ney.jpg', + type: 'search', + endpoint: '/api/v1/search/posts', + query: 'ناي', + }, + { + key: 'hijaz', + title: 'مقام حجاز', + subtitle: 'طابع شرقي مؤثر', + imageUrl: '/uploads/music-world/hijaz.jpg', + type: 'search', + endpoint: '/api/v1/search/posts', + query: 'مقام حجاز', + }, + { + key: 'levantine', + title: 'موسيقى شامية', + subtitle: 'اكتشف اللون الشامي', + imageUrl: '/uploads/music-world/levantine.jpg', + type: 'search', + endpoint: '/api/v1/search/posts', + query: 'موسيقى شامية', + }, + { + key: 'artists', + title: 'فنانون', + subtitle: 'اكتشف مبدعين موسيقيين', + imageUrl: '/uploads/music-world/artists.jpg', + type: 'navigation', + endpoint: '/api/v1/users/discover', + }, + { + key: 'explore', + title: 'اكتشف الموسيقى', + subtitle: 'منشورات ومقاطع موسيقية', + imageUrl: '/uploads/music-world/explore.jpg', + type: 'navigation', + endpoint: '/api/v1/feed/explore', + }, + ], + sections: [ + { + key: 'trending', + title: 'الأكثر رواجا', + endpoint: '/api/v1/feed/trending', + }, + { + key: 'explore', + title: 'اكتشف', + endpoint: '/api/v1/feed/explore', + }, + { + key: 'artists', + title: 'مبدعون موسيقيون', + endpoint: '/api/v1/users/discover', + }, + ], + }; + } + + async getExplore(currentUserId: string, query: FeedQueryDto) { + const feed = (await this.feedService.getExplore(currentUserId, query)) as Record< + string, + unknown + >; + const items = Array.isArray(feed.items) + ? feed.items + .filter((item): item is Record => { + return ( + typeof item === 'object' && + item !== null && + (item as Record).feedItemType === 'post' + ); + }) + .map((item) => this.toExploreGridItem(item)) + : []; + + return { + items, + count: items.length, + page: feed.page, + limit: feed.limit, + total: feed.total, + totalPages: feed.totalPages, + nextCursor: feed.nextCursor ?? null, + pagination: feed.pagination, + }; + } + + async search(currentUserId: string, query: SearchQueryDto) { + const posts = await this.searchService.searchPosts(currentUserId, { + ...query, + type: 'posts', + }); + + return { + ...posts, + items: posts.items.map((item) => this.toExploreGridItem(item as Record)), + }; + } + + private toExploreGridItem(item: Record): ExploreGridItem { + const media = this.asRecord(item.media); + const author = this.asRecord(item.authorId ?? item.author); + const engagement = this.asRecord(item.engagement); + const postType = String(item.postType ?? media.mediaType ?? ''); + const displayUrl = this.firstNonEmpty( + media.displayUrl, + item.displayUrl, + this.resolveImageDisplayUrl(item), + item.thumbnailUrl, + media.thumbnailUrl, + ); + const thumbnailUrl = this.firstNonEmpty( + item.thumbnailUrl, + media.thumbnailUrl, + displayUrl, + this.resolveImageDisplayUrl(item), + ); + + return { + id: String(item.id ?? item._id ?? ''), + postType, + content: String(item.content ?? ''), + thumbnailUrl, + displayUrl, + media, + author: { + id: String(author.id ?? author._id ?? ''), + name: String(author.name ?? ''), + username: String(author.username ?? ''), + avatar: String(author.avatar ?? ''), + isVerified: Boolean(author.isVerified), + }, + engagement: { + likesCount: Number(engagement.likesCount ?? item.likesCount ?? 0), + commentsCount: Number(engagement.commentsCount ?? item.commentsCount ?? 0), + viewCount: Number(engagement.viewCount ?? item.viewCount ?? 0), + playCount: Number(engagement.playCount ?? item.playCount ?? 0), + }, + isLiked: Boolean(item.isLiked ?? item.liked ?? item.likedByMe), + isSaved: Boolean(item.isSaved ?? item.saved ?? item.savedByMe), + createdAt: item.createdAt ?? null, + }; + } + + private resolveImageDisplayUrl(item: Record): string { + const imageVariants = Array.isArray(item.imageVariants) ? item.imageVariants : []; + const firstVariant = this.asRecord(imageVariants[0]); + const imageItems = Array.isArray(item.imageItems) ? item.imageItems : []; + const firstImageItem = this.asRecord(imageItems[0]); + const imageUrls = Array.isArray(item.imageUrls) ? item.imageUrls : []; + + return this.firstNonEmpty( + firstVariant.mediumUrl, + firstVariant.highUrl, + firstVariant.lowUrl, + firstVariant.originalUrl, + firstImageItem.url, + imageUrls[0], + ); + } + + private asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; + } + + private firstNonEmpty(...values: unknown[]): string { + return ( + values.find( + (value): value is string => typeof value === 'string' && value.trim().length > 0, + ) ?? '' + ); + } +} diff --git a/src/modules/search/search.module.ts b/src/modules/search/search.module.ts index e438c5f..6372691 100644 --- a/src/modules/search/search.module.ts +++ b/src/modules/search/search.module.ts @@ -16,5 +16,6 @@ import { SearchService } from './search.service'; ], controllers: [SearchController], providers: [SearchService], + exports: [SearchService], }) export class SearchModule {}