Improve AI music and add music world explore
هذا الالتزام موجود في:
@@ -23,6 +23,7 @@ import { FollowsModule } from './modules/follows/follows.module';
|
|||||||
import { LikesModule } from './modules/likes/likes.module';
|
import { LikesModule } from './modules/likes/likes.module';
|
||||||
import { MediaModule } from './modules/media/media.module';
|
import { MediaModule } from './modules/media/media.module';
|
||||||
import { MarketplaceModule } from './modules/marketplace/marketplace.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 { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
import { OutboxModule } from './modules/outbox/outbox.module';
|
import { OutboxModule } from './modules/outbox/outbox.module';
|
||||||
import { PostsModule } from './modules/posts/posts.module';
|
import { PostsModule } from './modules/posts/posts.module';
|
||||||
@@ -64,6 +65,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
|||||||
ChatModule,
|
ChatModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
MarketplaceModule,
|
MarketplaceModule,
|
||||||
|
MusicWorldModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
SavesModule,
|
SavesModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
|||||||
@@ -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<void> {
|
async deleteContainingDirectory(fileUrl?: string): Promise<void> {
|
||||||
if (!fileUrl) {
|
if (!fileUrl) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
118
src/modules/media/ai-music-prompt-enhancer.service.spec.ts
Normal file
118
src/modules/media/ai-music-prompt-enhancer.service.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
341
src/modules/media/ai-music-prompt-enhancer.service.ts
Normal file
341
src/modules/media/ai-music-prompt-enhancer.service.ts
Normal file
@@ -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<MusicMaqam, string> = {
|
||||||
|
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<MusicMood, string> = {
|
||||||
|
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<MusicStyle, string> = {
|
||||||
|
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<MusicInstrument, string> = {
|
||||||
|
oud: 'oud',
|
||||||
|
qanun: 'qanun',
|
||||||
|
ney: 'ney flute',
|
||||||
|
violin: 'violin',
|
||||||
|
darbuka: 'darbuka',
|
||||||
|
riq: 'riq frame drum',
|
||||||
|
mixed: 'Arabic ensemble',
|
||||||
|
};
|
||||||
|
|
||||||
|
const instrumentTerms: Record<MusicInstrument, string[]> = {
|
||||||
|
oud: ['عود', 'العود', 'عزف عود', 'oud'],
|
||||||
|
qanun: ['قانون', 'القانون', 'qanun'],
|
||||||
|
ney: ['ناي', 'الناي', 'ney', 'nay'],
|
||||||
|
violin: ['كمان', 'كمنجة', 'violin'],
|
||||||
|
darbuka: ['دربكة', 'طبلة', 'darbuka'],
|
||||||
|
riq: ['رق', 'دف', 'riq'],
|
||||||
|
mixed: ['فرقة', 'تخت', 'ensemble', 'mixed'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const maqamTerms: Record<MusicMaqam, string[]> = {
|
||||||
|
hijaz: ['حجاز', 'مقام حجاز', 'hijaz'],
|
||||||
|
bayati: ['بياتي', 'مقام بياتي', 'bayati'],
|
||||||
|
rast: ['راست', 'مقام راست', 'rast'],
|
||||||
|
nahawand: ['نهاوند', 'مقام نهاوند', 'nahawand'],
|
||||||
|
kurd: ['كرد', 'مقام كرد', 'kurd'],
|
||||||
|
saba: ['صبا', 'مقام صبا', 'saba'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const moodTerms: Record<MusicMood, string[]> = {
|
||||||
|
sad: ['حزين', 'حزن', 'مؤثر', 'عاطفي', 'sad'],
|
||||||
|
calm: ['هادئ', 'هادي', 'رايق', 'ناعم', 'calm', 'soft'],
|
||||||
|
energetic: ['حماسي', 'قوي', 'ملحمي', 'energetic', 'heroic'],
|
||||||
|
romantic: ['رومانسي', 'romantic'],
|
||||||
|
spiritual: ['صوفي', 'روحاني', 'spiritual'],
|
||||||
|
traditional: ['تراثي', 'شعبي', 'traditional', 'folk'],
|
||||||
|
joyful: ['فرح', 'مبهج', 'joyful', 'uplifting'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleTerms: Record<MusicStyle, string[]> = {
|
||||||
|
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<string>();
|
||||||
|
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<T extends string>(
|
||||||
|
normalizedPrompt: string,
|
||||||
|
termMap: Record<T, string[]>,
|
||||||
|
): 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<T extends string>(value: string | undefined, known: Record<T, string>): 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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
@@ -31,6 +32,15 @@ export class TextToMusicDto {
|
|||||||
@Max(2147483647)
|
@Max(2147483647)
|
||||||
seed?: number;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
@@ -59,4 +69,9 @@ export class TextToMusicDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['none', 'light', 'medium'])
|
@IsIn(['none', 'light', 'medium'])
|
||||||
percussion?: 'none' | 'light' | 'medium';
|
percussion?: 'none' | 'light' | 'medium';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
vocals?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MediaController } from './media.controller';
|
import { MediaController } from './media.controller';
|
||||||
|
import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { MediaRepository } from './media.repository';
|
import { MediaRepository } from './media.repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [MediaController],
|
controllers: [MediaController],
|
||||||
providers: [MediaService, MediaRepository],
|
providers: [MediaService, MediaRepository, AiMusicPromptEnhancerService],
|
||||||
exports: [MediaService],
|
exports: [MediaService],
|
||||||
})
|
})
|
||||||
export class MediaModule {}
|
export class MediaModule {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
const createPcmWavBuffer = (frames = 8000) => {
|
const createPcmWavBuffer = (frames = 8000) => {
|
||||||
@@ -32,7 +33,7 @@ const createPcmWavBuffer = (frames = 8000) => {
|
|||||||
return buffer;
|
return buffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('MediaService Google credentials resolution', () => {
|
describe('MediaService', () => {
|
||||||
const createService = (
|
const createService = (
|
||||||
googleCredentialsBase64 = '',
|
googleCredentialsBase64 = '',
|
||||||
overrides: {
|
overrides: {
|
||||||
@@ -57,6 +58,7 @@ describe('MediaService Google credentials resolution', () => {
|
|||||||
configService as any,
|
configService as any,
|
||||||
(overrides.storageService ?? {}) as any,
|
(overrides.storageService ?? {}) as any,
|
||||||
(overrides.mediaProbeService ?? {}) as any,
|
(overrides.mediaProbeService ?? {}) as any,
|
||||||
|
new AiMusicPromptEnhancerService(),
|
||||||
) as any;
|
) as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,8 +107,10 @@ describe('MediaService Google credentials resolution', () => {
|
|||||||
}) as any;
|
}) as any;
|
||||||
const storageService = {
|
const storageService = {
|
||||||
saveFile: jest.fn().mockResolvedValue('/uploads/ai-music/generated.wav'),
|
saveFile: jest.fn().mockResolvedValue('/uploads/ai-music/generated.wav'),
|
||||||
|
resolveLocalFilePath: jest.fn().mockReturnValue(null),
|
||||||
};
|
};
|
||||||
const mediaProbeService = {
|
const mediaProbeService = {
|
||||||
|
extractDurationSeconds: jest.fn(),
|
||||||
extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(32),
|
extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(32),
|
||||||
};
|
};
|
||||||
const service = createService('', {
|
const service = createService('', {
|
||||||
@@ -131,10 +135,18 @@ describe('MediaService Google credentials resolution', () => {
|
|||||||
expect(result.prompt).toBe('test prompt');
|
expect(result.prompt).toBe('test prompt');
|
||||||
expect(result.originalPrompt).toBe('test prompt');
|
expect(result.originalPrompt).toBe('test prompt');
|
||||||
expect(result.enhancedPrompt).toContain('Instrumental only, no vocals');
|
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.audioUrl).toBe('/uploads/ai-music/generated.wav');
|
||||||
expect(result.mimeType).toBe('audio/wav');
|
expect(result.mimeType).toBe('audio/wav');
|
||||||
expect(result.sizeBytes).toBe(audioBuffer.length);
|
expect(result.sizeBytes).toBe(audioBuffer.length);
|
||||||
expect(result.waveformPeaks).toHaveLength(100);
|
expect(result.waveformPeaks).toHaveLength(100);
|
||||||
|
expect(storageService.resolveLocalFilePath).toHaveBeenCalledWith(
|
||||||
|
'/uploads/ai-music/generated.wav',
|
||||||
|
);
|
||||||
|
expect(mediaProbeService.extractDurationSeconds).not.toHaveBeenCalled();
|
||||||
expect(mediaProbeService.extractDurationSecondsFromBuffer).toHaveBeenCalledWith(
|
expect(mediaProbeService.extractDurationSecondsFromBuffer).toHaveBeenCalledWith(
|
||||||
audioBuffer,
|
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 audioBuffer = createPcmWavBuffer();
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
@@ -171,52 +183,97 @@ describe('MediaService Google credentials resolution', () => {
|
|||||||
},
|
},
|
||||||
storageService: {
|
storageService: {
|
||||||
saveFile: jest.fn().mockResolvedValue('/uploads/ai-music/generated.wav'),
|
saveFile: jest.fn().mockResolvedValue('/uploads/ai-music/generated.wav'),
|
||||||
|
resolveLocalFilePath: jest.fn().mockReturnValue(null),
|
||||||
},
|
},
|
||||||
mediaProbeService: {
|
mediaProbeService: {
|
||||||
|
extractDurationSeconds: jest.fn(),
|
||||||
extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(12),
|
extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(12),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const prompt = 'بدي لحن عود حزين مقام حجاز مع إيقاع خفيف';
|
||||||
const result = await service.generateMusicFromText('user-1', {
|
const result = await service.generateMusicFromText('user-1', {
|
||||||
prompt: 'بدي لحن عود حزين مقام حجاز مع إيقاع خفيف',
|
prompt,
|
||||||
durationSeconds: 12,
|
durationSeconds: 12,
|
||||||
});
|
});
|
||||||
const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
||||||
const sentPrompt = fetchBody.instances[0].prompt as string;
|
const sentPrompt = fetchBody.instances[0].prompt as string;
|
||||||
|
|
||||||
expect(result.prompt).toBe('بدي لحن عود حزين مقام حجاز مع إيقاع خفيف');
|
expect(result.prompt).toBe(prompt);
|
||||||
expect(result.originalPrompt).toBe('بدي لحن عود حزين مقام حجاز مع إيقاع خفيف');
|
expect(result.originalPrompt).toBe(prompt);
|
||||||
expect(result.enhancedPrompt).toBe(sentPrompt);
|
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('Instrumental Arabic oud melody');
|
||||||
expect(sentPrompt).toContain('Hijaz maqam, emotional Middle Eastern sound');
|
expect(sentPrompt).toContain('Hijaz maqam, emotional Middle Eastern Arabic sound');
|
||||||
expect(sentPrompt).toContain('sad, emotional, expressive mood');
|
expect(sentPrompt).toContain('sad, emotional, expressive');
|
||||||
expect(sentPrompt).toContain('light riq percussion');
|
expect(sentPrompt).toContain('Light subtle percussion');
|
||||||
expect(sentPrompt).toContain('slow tempo');
|
expect(sentPrompt).toContain('slow tempo');
|
||||||
expect(sentPrompt).toContain(
|
expect(sentPrompt).toContain('Instrumental only, no vocals, no lyrics');
|
||||||
'Instrumental only, no vocals, no lyrics, no artist imitation, clean studio recording, balanced mix.',
|
expect(sentPrompt).toContain('No artist imitation, no copyrighted melody imitation');
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses optional maqam mood instruments and percussion fields in the enhanced prompt', () => {
|
it('probes duration from the saved local audio file when local storage path is available', async () => {
|
||||||
const service = createService();
|
const audioBuffer = createPcmWavBuffer();
|
||||||
const enhancedPrompt = service.buildEnhancedMusicPrompt({
|
const originalFetch = global.fetch;
|
||||||
prompt: 'warm intro',
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
maqam: 'Nahawand',
|
ok: true,
|
||||||
mood: 'romantic',
|
json: jest.fn().mockResolvedValue({
|
||||||
instruments: ['qanun', 'ney'],
|
predictions: [
|
||||||
style: 'cinematic Levantine arrangement',
|
{
|
||||||
tempo: 'medium',
|
bytesBase64Encoded: audioBuffer.toString('base64'),
|
||||||
percussion: 'light',
|
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');
|
try {
|
||||||
expect(enhancedPrompt).toContain('romantic, warm mood');
|
const result = await service.generateMusicFromText('user-1', {
|
||||||
expect(enhancedPrompt).toContain('Instrumental Arabic qanun melody');
|
prompt: 'test prompt',
|
||||||
expect(enhancedPrompt).toContain('light percussion');
|
durationSeconds: 12,
|
||||||
expect(enhancedPrompt).toContain('cinematic Levantine arrangement');
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +1,20 @@
|
|||||||
import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
import { GoogleAuth } from 'google-auth-library';
|
import { GoogleAuth } from 'google-auth-library';
|
||||||
import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util';
|
import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util';
|
||||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||||
import { MediaProbeService } from '../../infrastructure/storage/media-probe.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';
|
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()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly storageService: ManagedStorageService,
|
private readonly storageService: ManagedStorageService,
|
||||||
private readonly mediaProbeService: MediaProbeService,
|
private readonly mediaProbeService: MediaProbeService,
|
||||||
|
private readonly promptEnhancer: AiMusicPromptEnhancerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getMediaHealth() {
|
async getMediaHealth() {
|
||||||
@@ -84,7 +57,9 @@ export class MediaService {
|
|||||||
warnings.push('S3 provider selected but missing required env variables');
|
warnings.push('S3 provider selected but missing required env variables');
|
||||||
}
|
}
|
||||||
if (storageProvider === 's3' && !storageHealth.storagePublicBaseUrlConfigured) {
|
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<string, unknown> | undefined;
|
const s3Health = storageHealth.s3 as Record<string, unknown> | undefined;
|
||||||
if (storageProvider === 's3' && s3Health?.reachable === false) {
|
if (storageProvider === 's3' && s3Health?.reachable === false) {
|
||||||
@@ -178,9 +153,9 @@ export class MediaService {
|
|||||||
authorizationHeader = `Bearer ${accessToken}`;
|
authorizationHeader = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enhancedPrompt = this.buildEnhancedMusicPrompt(dto);
|
const promptEnhancement = this.promptEnhancer.enhance(dto);
|
||||||
const requestBody: Record<string, unknown> = {
|
const requestBody: Record<string, unknown> = {
|
||||||
instances: [{ prompt: enhancedPrompt }],
|
instances: [{ prompt: promptEnhancement.enhancedPrompt }],
|
||||||
parameters: {
|
parameters: {
|
||||||
sampleCount: 1,
|
sampleCount: 1,
|
||||||
durationSeconds: dto.durationSeconds ?? 12,
|
durationSeconds: dto.durationSeconds ?? 12,
|
||||||
@@ -232,26 +207,59 @@ export class MediaService {
|
|||||||
contentType: mimeType,
|
contentType: mimeType,
|
||||||
fileNamePrefix: `ai-${userId}`,
|
fileNamePrefix: `ai-${userId}`,
|
||||||
});
|
});
|
||||||
const actualDurationSeconds = await this.mediaProbeService.extractDurationSecondsFromBuffer(
|
const savedAudioPath = this.storageService.resolveLocalFilePath(audioUrl);
|
||||||
buffer,
|
const [actualDurationSeconds, waveformBuffer] = await Promise.all([
|
||||||
{
|
this.resolveSavedAudioDurationSeconds(savedAudioPath, buffer, extension, mimeType),
|
||||||
originalname: `ai-music.${extension}`,
|
this.resolveSavedAudioBuffer(savedAudioPath, buffer),
|
||||||
mimetype: mimeType,
|
]);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prompt: dto.prompt,
|
prompt: dto.prompt,
|
||||||
originalPrompt: dto.prompt,
|
originalPrompt: promptEnhancement.originalPrompt,
|
||||||
enhancedPrompt,
|
enhancedPrompt: promptEnhancement.enhancedPrompt,
|
||||||
|
detectedMusicIntent: promptEnhancement.detectedMusicIntent,
|
||||||
durationSeconds: actualDurationSeconds ?? dto.durationSeconds ?? 12,
|
durationSeconds: actualDurationSeconds ?? dto.durationSeconds ?? 12,
|
||||||
mimeType,
|
mimeType,
|
||||||
sizeBytes: buffer.length,
|
sizeBytes: buffer.length,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
waveformPeaks: generateWaveformPeaksFromBuffer(buffer),
|
waveformPeaks: generateWaveformPeaksFromBuffer(waveformBuffer),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveSavedAudioDurationSeconds(
|
||||||
|
savedAudioPath: string | null,
|
||||||
|
buffer: Buffer,
|
||||||
|
extension: string,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
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<Buffer> {
|
||||||
|
if (!savedAudioPath) {
|
||||||
|
return fallbackBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await readFile(savedAudioPath);
|
||||||
|
} catch {
|
||||||
|
return fallbackBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private resolveAudioExtension(mimeType: string): string {
|
private resolveAudioExtension(mimeType: string): string {
|
||||||
if (mimeType.includes('mpeg') || mimeType.includes('mp3')) {
|
if (mimeType.includes('mpeg') || mimeType.includes('mp3')) {
|
||||||
return 'mp3';
|
return 'mp3';
|
||||||
@@ -268,200 +276,6 @@ export class MediaService {
|
|||||||
return 'wav';
|
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<string>();
|
|
||||||
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<string>();
|
|
||||||
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(): {
|
private resolveGoogleApplicationCredentials(): {
|
||||||
credentials?: any;
|
credentials?: any;
|
||||||
} {
|
} {
|
||||||
|
|||||||
174
src/modules/music-world/music-world.controller.spec.ts
Normal file
174
src/modules/music-world/music-world.controller.spec.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/modules/music-world/music-world.controller.ts
Normal file
31
src/modules/music-world/music-world.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/music-world/music-world.module.ts
Normal file
13
src/modules/music-world/music-world.module.ts
Normal file
@@ -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 {}
|
||||||
256
src/modules/music-world/music-world.service.ts
Normal file
256
src/modules/music-world/music-world.service.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown> => {
|
||||||
|
return (
|
||||||
|
typeof item === 'object' &&
|
||||||
|
item !== null &&
|
||||||
|
(item as Record<string, unknown>).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<string, unknown>)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toExploreGridItem(item: Record<string, unknown>): 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, unknown>): 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<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstNonEmpty(...values: unknown[]): string {
|
||||||
|
return (
|
||||||
|
values.find(
|
||||||
|
(value): value is string => typeof value === 'string' && value.trim().length > 0,
|
||||||
|
) ?? ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,5 +16,6 @@ import { SearchService } from './search.service';
|
|||||||
],
|
],
|
||||||
controllers: [SearchController],
|
controllers: [SearchController],
|
||||||
providers: [SearchService],
|
providers: [SearchService],
|
||||||
|
exports: [SearchService],
|
||||||
})
|
})
|
||||||
export class SearchModule {}
|
export class SearchModule {}
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم