Improve AI music and add music world explore

هذا الالتزام موجود في:
boutmoun123
2026-06-09 16:56:52 +03:00
الأصل 1dd5a20846
التزام 16de76d9eb
13 ملفات معدلة مع 1100 إضافات و264 حذوفات

عرض الملف

@@ -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;

عرض الملف

@@ -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');
});
});

عرض الملف

@@ -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;
} { } {

عرض الملف

@@ -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,
});
});
});

عرض الملف

@@ -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);
}
}

عرض الملف

@@ -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 {}

عرض الملف

@@ -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 {}