Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/authentication/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,13 @@ export class AuthenticationService {
expiresIn: ACCESS_TOKEN_EXPIRY,
});

const newRefreshToken = this.jwtService.sign(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: REFRESH_TOKEN_EXPIRY,
});
const newRefreshToken = this.jwtService.sign(
{ ...payload, jti: crypto.randomUUID() },
{
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: REFRESH_TOKEN_EXPIRY,
}
);

const expiresAt = new Date(Date.now() + REFRESH_COOKIE_MAX_AGE_MS);
const tokenHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
Expand Down
32 changes: 10 additions & 22 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,27 +254,6 @@ export class NotificationsService {
}

async notifyNewMessage(recipientId: string, actor: User, chatId: string) {
const notification = await this.notificationsRepository.createNotification(
NotificationType.MESSAGE,
recipientId,
actor.userId
);
const unreadCount = await this.notificationsRepository.getUnreadCount(recipientId);

this.websocketsService.emitToUser(recipientId, 'new_notification', {
notification: {
notificationId: notification.notificationId,
type: NotificationType.MESSAGE,
actor: {
userId: actor.userId,
username: actor.username,
avatarUrl: actor.avatarUrl,
},
createdAt: notification.createdAt,
},
unreadCount,
});

await this.sendPushNotification(
recipientId,
'New Message',
Expand Down Expand Up @@ -333,7 +312,6 @@ export class NotificationsService {
data?: any
) {
try {
// 1. Permission Check
const settings = await this.settingsService.getNotificationSettings(userId);
const deviceSettings = settings?.data?.device as Record<string, any>;

Expand Down Expand Up @@ -362,6 +340,16 @@ export class NotificationsService {
notification: { title, body },
data: fcmData,
tokens,
android: {
priority: 'high',
},
apns: {
payload: {
aps: {
'content-available': 1,
},
},
},
});
} catch (error: any) {
console.error(`Push error: ${error?.message}`);
Expand Down
4 changes: 2 additions & 2 deletions src/playlist/playlist.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ export class PlaylistService {
durationSeconds: pt.track.durationSeconds,
coverImage: pt.track.coverImage,
audioUrl: isBlocked ? null : resolveAudioUrl(pt.track, plan),
waveformUrl: isBlocked ? null : (pt.track.waveformUrl ?? null),
waveformUrl: pt.track.waveformUrl ?? null,
playCount: pt.track.playCount,
likesCount: pt.track.likesCount,
repostsCount: pt.track.repostsCount,
Expand Down Expand Up @@ -916,7 +916,7 @@ export class PlaylistService {
durationSeconds: pt.track.durationSeconds,
coverImage: pt.track.coverImage,
audioUrl: isBlocked ? null : (pt.track.audioUrl ?? null),
waveformUrl: isBlocked ? null : (pt.track.waveformUrl ?? null),
waveformUrl: pt.track.waveformUrl ?? null,
playCount: pt.track.playCount,
likesCount: pt.track.likesCount,
repostsCount: pt.track.repostsCount,
Expand Down
12 changes: 6 additions & 6 deletions src/playlist/playlist.swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ export function ApiGetPublicPlaylist() {
summary: 'Get playlist details',
description:
'Retrieves a public playlist by ID, including its owner, tracks (ordered), and tags. ' +
"For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.",
"For tracks blocked in the requester's region, `audioUrl` is returned as `null`",
}),
ApiParam({
name: 'playlistId',
Expand All @@ -918,7 +918,7 @@ export function ApiGetPublicPlaylist() {
ApiResponse({
status: 200,
description:
"Playlist retrieved successfully. `audioUrl` and `waveformUrl` per track are `null` when blocked in the requester's region.",
"Playlist retrieved successfully. Only `audioUrl` per track is `null` when blocked in the requester's region",
schema: {
example: {
status: 'success',
Expand Down Expand Up @@ -973,7 +973,7 @@ export function ApiGetPublicPlaylist() {
durationSeconds: 195,
coverImage: 'https://cdn.harmonica.com/covers/locked.jpg',
audioUrl: null,
waveformUrl: null,
waveformUrl: 'https://cdn.harmonica.com/waveforms/locked.json',
playCount: 500,
likesCount: 20,
repostsCount: 3,
Expand Down Expand Up @@ -1009,7 +1009,7 @@ export function ApiGetSecretPlaylist() {
summary: 'Get a private playlist via secret token',
description:
'Allows users with the secret link to view a private playlist. Used for SoundCloud-style sharing. ' +
"For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.",
"For tracks blocked in the requester's region, `audioUrl` is returned as `null`.",
}),
ApiParam({
name: 'secretToken',
Expand All @@ -1020,7 +1020,7 @@ export function ApiGetSecretPlaylist() {
ApiResponse({
status: 200,
description:
"Secret playlist retrieved successfully. `audioUrl` and `waveformUrl` per track are `null` when blocked in the requester's region.",
"Secret playlist retrieved successfully. `audioUrl` per track is `null` when blocked in the requester's region.",
schema: {
example: {
status: 'success',
Expand Down Expand Up @@ -1072,7 +1072,7 @@ export function ApiGetSecretPlaylist() {
durationSeconds: 210,
coverImage: 'https://cdn.harmonica.com/covers/locked.jpg',
audioUrl: null,
waveformUrl: null,
waveformUrl: 'https://cdn.harmonica.com/waveforms/locked.json',
playCount: 0,
likesCount: 0,
repostsCount: 0,
Expand Down
1 change: 1 addition & 0 deletions src/track/tests/track.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export const mockTrackRepository = () => ({
getTrackPlaylists: jest.fn(),
deleteTrack: jest.fn(),
findTrackByTitleAndArtist: jest.fn(),
findByTitleAndUser: jest.fn(),
getTrackTopFansIds: jest.fn(),
findRelatedTracks: jest.fn(),
findAllTimeStats: jest.fn(),
Expand Down
15 changes: 15 additions & 0 deletions src/track/track.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ export class TrackRepository {
});
}

async findByTitleAndUser(
title: string,
userId: string,
excludeTrackId?: string
): Promise<Track | null> {
const qb = this.trackRepository
.createQueryBuilder('track')
.where('LOWER(track.title) = LOWER(:title)', { title })
.andWhere('track.userId = :userId', { userId });
if (excludeTrackId) {
qb.andWhere('track.trackId != :excludeTrackId', { excludeTrackId });
}
return qb.getOne();
}

async findByIdWithRelations(trackId: string): Promise<Track | null> {
return this.trackRepository
.createQueryBuilder('track')
Expand Down
54 changes: 48 additions & 6 deletions src/track/track.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1415,9 +1415,26 @@ describe('TrackService', () => {
);
});

it('should return message and processing trackStatus', async () => {
it('should throw ConflictException when title already exists for this user', async () => {
ffmpegService.getDuration.mockResolvedValue(120);
userRepo.findById.mockResolvedValue(mockPublicUser());
ffmpegService.getDuration.mockResolvedValue(213);
trackRepo.getUserUploadedSeconds.mockResolvedValue(0);
(trackRepo as any).findByTitleAndUser.mockResolvedValue(
mockOwnTrack({ title: 'Midnight Drive' })
);

await expect(service.uploadTrack(MOCK_USER_ID, dto as any, mockAudioFile)).rejects.toThrow(
ConflictException
);

expect((trackRepo as any).createTrack).not.toHaveBeenCalled();
});

it('should allow upload when title does not exist for this user', async () => {
userRepo.findById.mockResolvedValue(mockPublicUser());
ffmpegService.getDuration.mockResolvedValue(120);
trackRepo.getUserUploadedSeconds.mockResolvedValue(0);
(trackRepo as any).findByTitleAndUser.mockResolvedValue(null);
const savedTrack = {
trackId: MOCK_TRACK_ID,
title: 'Midnight Drive',
Expand All @@ -1429,8 +1446,7 @@ describe('TrackService', () => {

const result = await service.uploadTrack(MOCK_USER_ID, dto as any, mockAudioFile);

expect(result.message).toBe('Track upload started. Processing in background.');
expect(result.data.trackStatus).toBe('processing');
expect(result.status).toBe('success');
});
});

Expand Down Expand Up @@ -1525,6 +1541,32 @@ describe('TrackService', () => {
expect.any(Object)
);
});

it('should throw ConflictException when new title already exists for this user', async () => {
trackRepo.findById.mockResolvedValue(mockOwnTrack({ title: 'Old Title' }));
(trackRepo as any).findByTitleAndUser.mockResolvedValue(
mockOwnTrack({ title: 'Taken Title' })
);

await expect(
service.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, { title: 'Taken Title' } as any)
).rejects.toThrow(ConflictException);

expect(trackRepo.updateTrack).not.toHaveBeenCalled();
});

it('should skip title uniqueness check when title is unchanged', async () => {
const ownTrack = mockOwnTrack({ title: 'Same Title', trackStatus: 'finished' });
trackRepo.findById.mockResolvedValue(ownTrack);
trackRepo.updateTrack.mockResolvedValue({ ...ownTrack, previewStartTime: null });

const result = await service.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, {
title: 'Same Title',
} as any);

expect((trackRepo as any).findByTitleAndUser).not.toHaveBeenCalled();
expect(result.status).toBe('success');
});
});

// ─── reuploadTrackAudio ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -2169,8 +2211,8 @@ describe('TrackService', () => {

const result = await service.getUserSpotlightTacks(MOCK_USER_ID, MOCK_IP);

expect(result[0]).not.toHaveProperty('audioUrl');
expect(result[0]).not.toHaveProperty('audioUrlHq');
expect(result[0].audioUrl).toBeNull();
expect(result[0].audioUrlHq).toBeNull();
});

it('should return empty array when spotlight is empty', async () => {
Expand Down
22 changes: 17 additions & 5 deletions src/track/track.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,16 @@ export class TrackService {
throw new ForbiddenException('Insufficient quota');
}

// Step 3 — create track record in DB with PROCESSING status
// Step 3 — enforce unique title per artist
if (dto.title) {
const existing = await this.trackRepository.findByTitleAndUser(dto.title, userId);
if (existing) {
fs.unlinkSync(tempFilePath);
throw new ConflictException('You already have a track with this title');
}
}

// Step 4 — create track record in DB with PROCESSING status
const savedTrack = await this.trackRepository.createTrack(userId, dto, coverImageUrl);

// Step 4 — queue the background job; jobId === trackId for SSE keying
Expand Down Expand Up @@ -174,6 +183,12 @@ export class TrackService {
if (!track) throw new NotFoundException('Track not found');
if (track.userId !== userId) throw new ForbiddenException('You do not own this track');

// Enforce unique title per artist when title is being changed
if (dto.title && dto.title !== track.title) {
const existing = await this.trackRepository.findByTitleAndUser(dto.title, userId, trackId);
if (existing) throw new ConflictException('You already have a track with this title');
}

// Upload new cover and delete old one atomically
let coverImageUrl: string | undefined;
if (coverFile) {
Expand Down Expand Up @@ -981,7 +996,6 @@ export class TrackService {
const isBlocked = country && t.blockedRegions?.includes(country);
if (isBlocked) {
dto.audioUrl = null;
dto.waveformUrl = null;
} else {
dto.audioUrl = resolveAudioUrl(t, plan);
}
Expand Down Expand Up @@ -1137,9 +1151,7 @@ export class TrackService {
return tracks.map((track) => {
const isBlocked = track.blockedRegions.includes(country!);
if (isBlocked) {
const { audioUrl, audioUrlHq, ...rest } = track;
console.log(audioUrl, audioUrlHq);
return rest;
return { ...track, audioUrl: null, audioUrlHq: null };
}
return track;
});
Expand Down
6 changes: 3 additions & 3 deletions src/track/track.swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1705,7 +1705,7 @@ export function ApiGetRelatedTracks() {
'Returns a paginated list of tracks that are related to the given track, ' +
'based on the listening history of its top fans. ' +
'The track must be public. Results are cached for 3 days. ' +
"For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.",
"For tracks blocked in the requester's region, `audioUrl` is returned as `null`",
}),
ApiParam({
name: 'artistUsername',
Expand Down Expand Up @@ -1736,7 +1736,7 @@ export function ApiGetRelatedTracks() {
ApiResponse({
status: 200,
description:
"Paginated list of related tracks. `audioUrl` and `waveformUrl` are `null` for tracks blocked in the requester's region.",
"Paginated list of related tracks. `audioUrl` per track is `null` when blocked in the requester's region.",
schema: {
example: {
status: 'success',
Expand Down Expand Up @@ -1768,7 +1768,7 @@ export function ApiGetRelatedTracks() {
description: 'Not available in all regions.',
coverImage: 'https://s3.amazonaws.com/covers/locked.jpg',
audioUrl: null,
waveformUrl: null,
waveformUrl: 'https://s3.amazonaws.com/waveforms/locked.json',
durationSeconds: 210,
playCount: 800,
likesCount: 90,
Expand Down
Loading