Skip to content

Commit 666eaa7

Browse files
committed
fix: handle stale event-sound assets and blank upload mime types
1 parent f545ce1 commit 666eaa7

File tree

3 files changed

+77
-9
lines changed

3 files changed

+77
-9
lines changed

src/browser/features/Settings/Sections/SoundsSection.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,42 @@ function updateEventSoundConfig(
6262
};
6363
}
6464

65+
function collectManagedAssetIds(settings: EventSoundSettings): Set<string> {
66+
const managedAssetIds = new Set<string>();
67+
68+
for (const config of Object.values(settings ?? {})) {
69+
if (config?.source?.kind === "managed") {
70+
managedAssetIds.add(config.source.assetId);
71+
}
72+
}
73+
74+
return managedAssetIds;
75+
}
76+
77+
function getUnreferencedManagedAssetIds(
78+
previousSettings: EventSoundSettings,
79+
nextSettings: EventSoundSettings
80+
): string[] {
81+
const previousAssetIds = collectManagedAssetIds(previousSettings);
82+
const nextAssetIds = collectManagedAssetIds(nextSettings);
83+
84+
return [...previousAssetIds].filter((assetId) => !nextAssetIds.has(assetId));
85+
}
86+
6587
export function SoundsSection() {
6688
const { api } = useAPI();
6789
const [eventSoundSettings, setEventSoundSettings] = useState<EventSoundSettings>(undefined);
6890

6991
const loadNonceRef = useRef(0);
7092
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
71-
const pendingSettingsRef = useRef<{ hasPending: boolean; settings: EventSoundSettings }>({
93+
const pendingSettingsRef = useRef<{
94+
hasPending: boolean;
95+
settings: EventSoundSettings;
96+
staleManagedAssetIds: Set<string>;
97+
}>({
7298
hasPending: false,
7399
settings: undefined,
100+
staleManagedAssetIds: new Set<string>(),
74101
});
75102

76103
// Browser mode uses one hidden file input for all rows, so we track which event key opened it.
@@ -98,14 +125,23 @@ export function SoundsSection() {
98125
});
99126
}, [api]);
100127

101-
const queueSettingsSave = (nextSettings: EventSoundSettings) => {
128+
const queueSettingsSave = (nextSettings: EventSoundSettings, staleManagedAssetIds: string[]) => {
102129
if (!api?.config?.updateEventSoundSettings) {
103130
return;
104131
}
105132

106133
pendingSettingsRef.current.hasPending = true;
107134
pendingSettingsRef.current.settings = nextSettings;
108135

136+
for (const staleAssetId of staleManagedAssetIds) {
137+
pendingSettingsRef.current.staleManagedAssetIds.add(staleAssetId);
138+
}
139+
140+
// If a pending save now references an asset again, do not delete it.
141+
for (const referencedAssetId of collectManagedAssetIds(nextSettings)) {
142+
pendingSettingsRef.current.staleManagedAssetIds.delete(referencedAssetId);
143+
}
144+
109145
// Serialize writes so rapid toggles/file selections cannot persist out-of-order settings.
110146
saveChainRef.current = saveChainRef.current
111147
.catch(() => {
@@ -118,11 +154,22 @@ export function SoundsSection() {
118154
}
119155

120156
const pendingSettings = pendingSettingsRef.current.settings;
157+
const staleAssetIds = [...pendingSettingsRef.current.staleManagedAssetIds];
121158
pendingSettingsRef.current.hasPending = false;
159+
pendingSettingsRef.current.staleManagedAssetIds.clear();
122160

123161
try {
124162
await api.config.updateEventSoundSettings({ eventSoundSettings: pendingSettings });
163+
164+
const deleteAsset = api.eventSounds?.deleteAsset;
165+
if (deleteAsset) {
166+
const deletePromises = staleAssetIds.map((assetId) => deleteAsset({ assetId }));
167+
await Promise.allSettled(deletePromises);
168+
}
125169
} catch {
170+
for (const staleAssetId of staleAssetIds) {
171+
pendingSettingsRef.current.staleManagedAssetIds.add(staleAssetId);
172+
}
126173
// Best-effort only.
127174
}
128175
}
@@ -135,7 +182,8 @@ export function SoundsSection() {
135182

136183
setEventSoundSettings((prev) => {
137184
const next = updater(prev);
138-
queueSettingsSave(next);
185+
const staleManagedAssetIds = getUnreferencedManagedAssetIds(prev, next);
186+
queueSettingsSave(next, staleManagedAssetIds);
139187
window.dispatchEvent(
140188
createCustomEvent(CUSTOM_EVENTS.EVENT_SOUND_SETTINGS_CHANGED, {
141189
eventSoundSettings: next,

src/node/services/eventSoundAssetService.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,24 @@ describe("EventSoundAssetService", () => {
114114
const storedBytes = await fsPromises.readFile(storedPath!);
115115
expect(Buffer.compare(storedBytes, sourceBytes)).toBe(0);
116116
});
117+
it("accepts uploads with empty MIME type when the extension is allowed", async () => {
118+
const sourceBytes = Buffer.from("mux-event-sound-empty-mime");
119+
120+
const uploaded = await service.uploadFromData({
121+
base64: sourceBytes.toString("base64"),
122+
originalName: "upload.wav",
123+
mimeType: "",
124+
});
125+
126+
expect(uploaded.assetId).toMatch(/^[0-9a-f-]{36}\.wav$/i);
127+
expect(uploaded.mimeType).toBe("audio/wav");
128+
129+
const storedPath = await service.getAssetFilePath(uploaded.assetId);
130+
expect(storedPath).not.toBeNull();
131+
132+
const storedBytes = await fsPromises.readFile(storedPath!);
133+
expect(Buffer.compare(storedBytes, sourceBytes)).toBe(0);
134+
});
117135

118136
it("rejects uploaded payloads larger than the max size", async () => {
119137
const oversizedPayload = Buffer.alloc(MAX_AUDIO_FILE_SIZE_BYTES + 1, 2).toString("base64");

src/node/services/eventSoundAssetService.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,17 +339,19 @@ export class EventSoundAssetService {
339339

340340
async uploadFromData(input: UploadEventSoundAssetInput): Promise<EventSoundAsset> {
341341
return this.withSerializedMutation(async () => {
342-
const mimeType = this.normalizeMimeType(input.mimeType);
343-
if (!mimeType) {
344-
throw new Error("Unsupported audio MIME type");
345-
}
346-
347342
const extensionFromName = this.getExtensionFromOriginalName(input.originalName);
348-
const extension = extensionFromName ?? MIME_TO_EXTENSION[mimeType];
343+
const mimeTypeFromInput = this.normalizeMimeType(input.mimeType);
344+
const extension =
345+
extensionFromName ?? (mimeTypeFromInput ? MIME_TO_EXTENSION[mimeTypeFromInput] : null);
349346
if (!extension) {
347+
if (input.mimeType.trim().length > 0) {
348+
throw new Error("Unsupported audio MIME type");
349+
}
350350
throw new Error("Unsupported audio file extension");
351351
}
352352

353+
const mimeType = mimeTypeFromInput ?? EXTENSION_TO_MIME[extension];
354+
353355
const bytes = this.decodeBase64Payload(input.base64);
354356
this.validateSize(bytes.byteLength);
355357

0 commit comments

Comments
 (0)