diff --git a/functions/index.js b/functions/index.js index 26dd9b9..b281aa8 100644 --- a/functions/index.js +++ b/functions/index.js @@ -17,3 +17,4 @@ exports.lyrics = require("./src/lyrics"); exports.cover = require("./src/cover"); exports.projects = require("./src/project"); exports.thumbnail = require("./src/thumbnail"); +exports.upload = require("./src/upload"); diff --git a/functions/src/upload.js b/functions/src/upload.js new file mode 100644 index 0000000..38000f0 --- /dev/null +++ b/functions/src/upload.js @@ -0,0 +1,371 @@ +const { onCall, HttpsError } = require("firebase-functions/v2/https"); +const admin = require("firebase-admin"); +const logger = require("firebase-functions/logger"); +const axios = require("axios"); +const ffmpeg = require("fluent-ffmpeg"); +const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { Buffer } = require("node:buffer"); + +ffmpeg.setFfmpegPath(ffmpegInstaller.path); + +const ALLOWED_PROJECT_FIELDS = new Set([ + "songUrl", + "playbackUrl", + "recordingUrl", +]); + +function parseDataUri(uri) { + const match = /^data:([^;]+);base64,(.*)$/.exec(uri || ""); + if (!match) { + throw new Error("URI data: invalide ou non supportée"); + } + const [, contentType, base64Data] = match; + const buffer = Buffer.from(base64Data, "base64"); + return { buffer, contentType: contentType || "" }; +} + +async function downloadFromHttp(uri) { + const response = await axios.get(uri, { responseType: "arraybuffer" }); + const buffer = Buffer.from(response.data); + const contentType = response.headers?.["content-type"] || ""; + return { buffer, contentType }; +} + +function isLikelyWebm(contentType = "", uri = "", storagePath = "") { + if (/webm/i.test(contentType)) return true; + if (/\.webm($|\?)/i.test(uri || "")) return true; + if (/\.webm$/i.test(storagePath || "")) return true; + return false; +} + +function guessExtension(contentType = "") { + if (!contentType) return ".bin"; + if (/mp4/i.test(contentType)) return ".mp4"; + if (/webm/i.test(contentType)) return ".webm"; + if (/mpeg/i.test(contentType)) return ".mp3"; + if (/ogg/i.test(contentType)) return ".ogg"; + if (/wav/i.test(contentType)) return ".wav"; + return ".bin"; +} + +function sanitizeMetadata(metadata = {}) { + const output = {}; + if (!metadata || typeof metadata !== "object") return output; + for (const [key, value] of Object.entries(metadata)) { + if (!key) continue; + if (value == null) continue; + output[key] = typeof value === "string" ? value : JSON.stringify(value); + } + return output; +} + +async function convertWebmToMp4(inputPath, outputPath) { + await new Promise((resolve, reject) => { + ffmpeg(inputPath) + .outputOptions([ + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-c:a", + "aac", + "-b:a", + "192k", + "-movflags", + "+faststart", + "-pix_fmt", + "yuv420p", + ]) + .on("end", resolve) + .on("error", reject) + .save(outputPath); + }); +} + +async function ensureProjectOwnership(uid, projectId) { + const snap = await admin + .firestore() + .collection("projects") + .doc(projectId) + .get(); + if (!snap.exists) { + throw new HttpsError("not-found", "Projet introuvable"); + } + const data = snap.data() || {}; + if (data.userId && data.userId !== uid) { + throw new HttpsError("permission-denied", "Accès non autorisé à ce projet"); + } + return { snap, data }; +} + +exports.uploadProjectMedia = onCall( + { + timeoutSeconds: 540, + memory: "2GiB", + }, + async ({ data = {}, auth = {} }) => { + const uid = auth?.uid; + if (!uid) { + throw new HttpsError("unauthenticated", "Authentification requise"); + } + + const { + uri = "", + sourcePath = "", + deleteSource = true, + projectId, + storagePath, + contentType: providedContentType = "", + projectField = "", + metadata = {}, + } = data || {}; + + const hasUri = typeof uri === "string" && uri.trim() !== ""; + const hasSourcePath = + typeof sourcePath === "string" && sourcePath.trim() !== ""; + + if (!hasUri && !hasSourcePath) { + throw new HttpsError( + "invalid-argument", + "Paramètre uri ou sourcePath est requis" + ); + } + + if (!projectId || typeof projectId !== "string") { + throw new HttpsError("invalid-argument", "projectId est requis"); + } + if (!storagePath || typeof storagePath !== "string") { + throw new HttpsError("invalid-argument", "storagePath est requis"); + } + + const expectedPrefix = `users/${uid}/projects/${projectId}/`; + if (!storagePath.startsWith(expectedPrefix)) { + throw new HttpsError( + "permission-denied", + "storagePath doit être dans le dossier du projet utilisateur" + ); + } + + if (projectField) { + if (!ALLOWED_PROJECT_FIELDS.has(projectField)) { + throw new HttpsError("invalid-argument", "projectField non supporté"); + } + } + + if (hasSourcePath && !sourcePath.startsWith(expectedPrefix)) { + throw new HttpsError( + "permission-denied", + "sourcePath doit être dans le dossier du projet utilisateur" + ); + } + + logger.info("[uploadProjectMedia] Call received", { + uid, + projectId, + storagePath, + projectField: projectField || null, + providedContentType, + sourcePath: hasSourcePath ? sourcePath : null, + payloadKind: hasUri + ? /^data:/i.test(uri) + ? "data-uri" + : /^https?:\/\//i.test(uri) + ? "remote-url" + : "raw-string" + : hasSourcePath + ? "storage-object" + : "none", + metadataKeys: metadata ? Object.keys(metadata) : [], + }); + + const { snap: projectSnap } = await ensureProjectOwnership(uid, projectId); + + const bucket = admin.storage().bucket(); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "upload-")); + const baseInputPath = path.join(tmpDir, "input"); + let inputPath = baseInputPath; + let detectedContentType = providedContentType; + const sourceFile = hasSourcePath ? bucket.file(sourcePath) : null; + + try { + if (hasSourcePath) { + if (!detectedContentType) { + try { + const [meta] = await sourceFile.getMetadata(); + detectedContentType = meta?.contentType || ""; + } catch {} + } + const ext = path.extname(sourcePath) || guessExtension(detectedContentType); + inputPath = `${baseInputPath}${ext || ""}`; + await sourceFile.download({ destination: inputPath }); + const { size } = await fs.stat(inputPath); + logger.info("[uploadProjectMedia] Source downloaded", { + projectId, + sourcePath, + inputPath, + size, + detectedContentType, + }); + } else { + let buffer; + if (/^data:/i.test(uri)) { + const parsed = parseDataUri(uri); + buffer = parsed.buffer; + if (!detectedContentType) detectedContentType = parsed.contentType; + const ext = guessExtension(parsed.contentType); + inputPath = `${baseInputPath}${ext}`; + logger.info("[uploadProjectMedia] Parsed data URI", { + projectId, + approxSize: buffer.length, + detectedContentType, + }); + } else if (/^https?:\/\//i.test(uri)) { + const downloaded = await downloadFromHttp(uri); + buffer = downloaded.buffer; + if (!detectedContentType) detectedContentType = downloaded.contentType; + const ext = guessExtension(downloaded.contentType); + inputPath = `${baseInputPath}${ext}`; + logger.info("[uploadProjectMedia] Downloaded remote resource", { + projectId, + approxSize: buffer.length, + detectedContentType, + }); + } else { + throw new HttpsError( + "invalid-argument", + "URI non supportée. Utiliser une data URI ou une URL HTTP(S)" + ); + } + + await fs.writeFile(inputPath, buffer); + const { size: inputSize } = await fs.stat(inputPath); + logger.info("[uploadProjectMedia] Local file written", { + projectId, + inputPath, + inputSize, + detectedContentType, + }); + } + + const conversionHint = hasUri ? uri : sourcePath; + const shouldConvert = isLikelyWebm( + detectedContentType, + conversionHint, + storagePath + ); + + logger.info("[uploadProjectMedia] Conversion decision", { + projectId, + shouldConvert, + detectedContentType, + }); + + let finalPath = inputPath; + let finalContentType = detectedContentType || "application/octet-stream"; + + if (shouldConvert) { + const targetPath = path.join(tmpDir, "output.mp4"); + logger.info("[uploadProjectMedia] Conversion WebM → MP4", { + projectId, + storagePath, + }); + await convertWebmToMp4(inputPath, targetPath); + finalPath = targetPath; + finalContentType = "video/mp4"; + const { size: convertedSize } = await fs.stat(finalPath); + logger.info("[uploadProjectMedia] Conversion done", { + projectId, + convertedSize, + finalContentType, + }); + } + + const downloadToken = crypto.randomUUID(); + const sanitizedMetadata = sanitizeMetadata(metadata); + const uploadMetadata = { + contentType: finalContentType, + cacheControl: "public,max-age=86400", + metadata: { + ...sanitizedMetadata, + originalContentType: detectedContentType || "", + firebaseStorageDownloadTokens: downloadToken, + }, + }; + + const { size: finalSize } = await fs.stat(finalPath); + logger.info("[uploadProjectMedia] Uploading to bucket", { + projectId, + storagePath, + finalContentType, + finalSize, + }); + + await bucket.upload(finalPath, { + destination: storagePath, + metadata: uploadMetadata, + }); + + if (hasSourcePath && deleteSource) { + try { + await sourceFile.delete({ ignoreNotFound: true }); + logger.info("[uploadProjectMedia] Source deleted", { + projectId, + sourcePath, + }); + } catch (cleanupErr) { + logger.warn("[uploadProjectMedia] Failed to delete source", { + projectId, + sourcePath, + error: cleanupErr?.message || String(cleanupErr), + }); + } + } + + const encodedPath = encodeURIComponent(storagePath); + const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodedPath}?alt=media&token=${downloadToken}`; + + const updatePayload = { + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }; + if (projectField) { + updatePayload[projectField] = fileUrl; + } + await projectSnap.ref.set(updatePayload, { merge: true }); + + logger.info("✅ [uploadProjectMedia] Upload terminé", { + projectId, + storagePath, + contentType: finalContentType, + }); + + return { + success: true, + url: fileUrl, + contentType: finalContentType, + storagePath, + downloadToken, + sourcePath: hasSourcePath ? sourcePath : null, + sourceDeleted: hasSourcePath ? deleteSource : false, + }; + } catch (error) { + logger.error("❌ [uploadProjectMedia] Échec", { + projectId, + sourcePath: hasSourcePath ? sourcePath : null, + error: error?.message || String(error), + }); + if (error instanceof HttpsError) throw error; + throw new HttpsError( + "internal", + error?.message || "Échec du traitement de l'upload" + ); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + } +); diff --git a/src/screens/Production/DownloadSongs.js b/src/screens/Production/DownloadSongs.js index a519da1..4f1f640 100644 --- a/src/screens/Production/DownloadSongs.js +++ b/src/screens/Production/DownloadSongs.js @@ -12,7 +12,7 @@ import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { background, img } from "../../assets"; import MusicLandHeader from "../../components/MusicLandHeader"; -import { projectsRef, serverTimestamp } from "../../config/firebase"; +import firebase, { projectsRef, serverTimestamp } from "../../config/firebase"; import { uploadFileToFirebase } from "../../helpers/uploadToFirebase"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; @@ -23,6 +23,31 @@ import { FONT_FAMILY } from "../../styles/Fonts"; import { size } from "../../styles/Style"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; +const guessExtension = (inputUri = "") => { + const cleaned = inputUri.split("?")[0] || ""; + const match = cleaned.match(/\.([a-z0-9]+)$/i); + if (match && match[1]) return match[1].toLowerCase(); + if (Platform.OS === "web") return "webm"; + return "mp4"; +}; + +const uploadSourceRecording = async ({ uri, uid, projectId }) => { + if (!uri) throw new Error("Aucune vidéo trouvée"); + if (!uid) throw new Error("Utilisateur non authentifié"); + + const extension = guessExtension(uri); + const sourcePath = `users/${uid}/projects/${projectId}/recordings/source-${Date.now()}.${extension}`; + + await uploadFileToFirebase({ + uri, + path: sourcePath, + shouldCompress: false, + fileType: "VIDEO", + }); + + return { sourcePath }; +}; + const DownloadSongs = ({ route }) => { const { currentUID } = useUserData(); const { action, uri, project } = route.params || {}; @@ -33,16 +58,30 @@ const DownloadSongs = ({ route }) => { if (action === "playback" && project?.id) { // Publication du playback console.log("project id : ", project.id); + let tempSourcePath = null; try { setIsLoading(true); - const { resultURI = null } = await uploadFileToFirebase({ - uri: uri, - path: `users/${currentUID}/projects/${project.id}/playback.mp4`, - shouldCompress: true, - fileType: "VIDEO", + const { sourcePath } = await uploadSourceRecording({ + uri, + uid: currentUID, + projectId: project.id, + }); + tempSourcePath = sourcePath; + + const callable = firebase + .functions() + .httpsCallable("upload-uploadProjectMedia"); + + const { data: result } = await callable({ + sourcePath, + projectId: project.id, + storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`, + projectField: "playbackUrl", + deleteSource: true, + metadata: { source: "downloadSongs" }, }); - if (!resultURI) throw new Error("Téléversement de l'image impossible"); + const resultURI = result?.url || null; if (resultURI) { await projectsRef.doc(project.id).set( @@ -63,10 +102,15 @@ const DownloadSongs = ({ route }) => { }); } } catch (error) { - console.log("error upload playback", error); + console.log("[DownloadSongs] error upload playback", error); + if (tempSourcePath) { + try { + await firebase.storage().ref(tempSourcePath).delete(); + } catch {} + } setTooltip({ type: "error", - text: "Erreur lors de la publication du playback", + text: String(error?.message || "Erreur lors de la publication du playback"), }); } finally { setIsLoading(false); @@ -109,17 +153,11 @@ const DownloadSongs = ({ route }) => { { - console.log("test"); - handleDownloadUri(); - } - - // navigate(Routes.DownloadPrices, { - // action, - // uri, - // }) - } + onPress={handleDownloadUri} + // navigate(Routes.DownloadPrices, { + // action, + // uri, + // }) >