feat(mukke): add month/day fields and auto-set date from file timestamp

Adds month and day columns to songs schema. On upload, extracts the
file's lastModified date to pre-fill year/month/day. Upload form and
SongEditor now show all three date fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 15:31:50 +01:00
parent 8a88838300
commit c1f632693d
9 changed files with 119 additions and 24 deletions

View file

@ -19,6 +19,8 @@ export function createMockSong(overrides?: Partial<Song>): Song {
genre: 'Rock',
trackNumber: 1,
year: 2024,
month: null,
day: null,
duration: 240.5,
storagePath: 'users/test-user-123/audio.mp3',
coverArtPath: null,

View file

@ -22,6 +22,8 @@ export const songs = pgTable(
genre: varchar('genre', { length: 100 }),
trackNumber: integer('track_number'),
year: integer('year'),
month: integer('month'),
day: integer('day'),
duration: real('duration'),
storagePath: text('storage_path').notNull(),
coverArtPath: text('cover_art_path'),

View file

@ -39,6 +39,18 @@ export class CreateSongDto {
@IsOptional()
year?: number;
@IsInt()
@IsOptional()
@Min(1)
@Max(12)
month?: number;
@IsInt()
@IsOptional()
@Min(1)
@Max(31)
day?: number;
@IsNumber()
@IsOptional()
@Min(1)
@ -81,6 +93,18 @@ export class UpdateSongDto {
@IsOptional()
year?: number;
@IsInt()
@IsOptional()
@Min(1)
@Max(12)
month?: number;
@IsInt()
@IsOptional()
@Min(1)
@Max(31)
day?: number;
@IsNumber()
@IsOptional()
@Min(0)
@ -103,4 +127,8 @@ export class SongUploadDto {
@IsNotEmpty()
@MaxLength(255)
filename!: string;
@IsNumber()
@IsOptional()
fileLastModified?: number;
}

View file

@ -21,7 +21,11 @@ export class SongController {
@Post('upload')
async createUploadUrl(@CurrentUser() user: CurrentUserData, @Body() dto: SongUploadDto) {
const result = await this.songService.createUploadUrl(user.userId, dto.filename);
const result = await this.songService.createUploadUrl(
user.userId,
dto.filename,
dto.fileLastModified
);
return { song: result.song, uploadUrl: result.uploadUrl };
}

View file

@ -23,7 +23,8 @@ export class SongService {
async createUploadUrl(
userId: string,
filename: string
filename: string,
fileLastModified?: number
): Promise<{ song: Song; uploadUrl: string }> {
const key = generateUserFileKey(userId, filename);
const contentType = getContentType(filename);
@ -32,13 +33,22 @@ export class SongService {
throw new BadRequestException('Invalid file type. Only audio files are allowed.');
}
const values: Record<string, unknown> = {
userId,
title: filename.replace(/\.[^/.]+$/, ''),
storagePath: key,
};
if (fileLastModified) {
const date = new Date(fileLastModified);
values.year = date.getFullYear();
values.month = date.getMonth() + 1;
values.day = date.getDate();
}
const [song] = await this.db
.insert(songs)
.values({
userId,
title: filename.replace(/\.[^/.]+$/, ''),
storagePath: key,
})
.values(values as typeof songs.$inferInsert)
.returning();
const uploadUrl = await this.storage.getUploadUrl(key, {

View file

@ -17,6 +17,8 @@
let genre = $state('');
let trackNumber = $state('');
let year = $state('');
let month = $state('');
let day = $state('');
let bpm = $state('');
let coverUrl = $state<string | null>(null);
let saving = $state(false);
@ -35,6 +37,8 @@
genre = song.genre ?? '';
trackNumber = song.trackNumber ? String(song.trackNumber) : '';
year = song.year ? String(song.year) : '';
month = song.month ? String(song.month) : '';
day = song.day ? String(song.day) : '';
bpm = song.bpm ? String(song.bpm) : '';
error = null;
success = null;
@ -66,6 +70,8 @@
genre: genre || undefined,
trackNumber: trackNumber ? parseInt(trackNumber) : undefined,
year: year ? parseInt(year) : undefined,
month: month ? parseInt(month) : undefined,
day: day ? parseInt(day) : undefined,
bpm: bpm ? parseFloat(bpm) : undefined,
});
success = 'Metadata saved';
@ -211,15 +217,29 @@
<div>
<label
for="edit-year"
class="block text-xs font-medium text-foreground-secondary mb-1">Year</label
class="block text-xs font-medium text-foreground-secondary mb-1">Date</label
>
<input
id="edit-year"
type="text"
bind:value={year}
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
placeholder="Year"
/>
<div class="grid grid-cols-3 gap-1">
<input
id="edit-year"
type="text"
bind:value={year}
class="w-full px-2 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
placeholder="Year"
/>
<input
type="text"
bind:value={month}
class="w-full px-2 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
placeholder="Mo"
/>
<input
type="text"
bind:value={day}
class="w-full px-2 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
placeholder="Day"
/>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">

View file

@ -229,7 +229,10 @@ function createLibraryStore() {
async uploadSong(file: File) {
const uploadData = await fetchApi<{ song: Song; uploadUrl: string }>('/songs/upload', {
method: 'POST',
body: JSON.stringify({ filename: file.name }),
body: JSON.stringify({
filename: file.name,
fileLastModified: file.lastModified || undefined,
}),
});
await fetch(uploadData.uploadUrl, {

View file

@ -14,6 +14,8 @@
album: string;
genre: string;
year: string;
month: string;
day: string;
};
}
@ -65,6 +67,8 @@
album: '',
genre: '',
year: '',
month: '',
day: '',
},
}));
files = [...files, ...newFiles];
@ -95,6 +99,8 @@
if (extracted.album) files[index].metadata.album = extracted.album ?? '';
if (extracted.genre) files[index].metadata.genre = extracted.genre ?? '';
if (extracted.year) files[index].metadata.year = String(extracted.year);
if (extracted.month) files[index].metadata.month = String(extracted.month);
if (extracted.day) files[index].metadata.day = String(extracted.day);
} catch {
// Non-fatal: user can still edit metadata manually
}
@ -115,6 +121,8 @@
album: uf.metadata.album || undefined,
genre: uf.metadata.genre || undefined,
year: uf.metadata.year ? parseInt(uf.metadata.year) : undefined,
month: uf.metadata.month ? parseInt(uf.metadata.month) : undefined,
day: uf.metadata.day ? parseInt(uf.metadata.day) : undefined,
};
await libraryStore.updateSongMetadata(uf.songId, data);
} catch (e) {
@ -307,15 +315,29 @@
<div>
<label
for="year-{uf.id}"
class="block text-xs font-medium text-foreground-secondary mb-1">Year</label
class="block text-xs font-medium text-foreground-secondary mb-1">Date</label
>
<input
id="year-{uf.id}"
type="text"
bind:value={uf.metadata.year}
class="w-full px-3 py-1.5 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
placeholder="Year"
/>
<div class="grid grid-cols-3 gap-1">
<input
id="year-{uf.id}"
type="text"
bind:value={uf.metadata.year}
class="w-full px-2 py-1.5 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
placeholder="Year"
/>
<input
type="text"
bind:value={uf.metadata.month}
class="w-full px-2 py-1.5 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
placeholder="Mo"
/>
<input
type="text"
bind:value={uf.metadata.day}
class="w-full px-2 py-1.5 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
placeholder="Day"
/>
</div>
</div>
</div>
</div>

View file

@ -8,6 +8,8 @@ export interface Song {
genre: string | null;
trackNumber: number | null;
year: number | null;
month: number | null;
day: number | null;
duration: number | null;
storagePath: string;
coverArtPath: string | null;
@ -59,6 +61,8 @@ export interface CreateSongDto {
genre?: string;
trackNumber?: number;
year?: number;
month?: number;
day?: number;
bpm?: number;
}