diff --git a/functions/.env.example b/functions/.env.example new file mode 100644 index 0000000..053cb95 --- /dev/null +++ b/functions/.env.example @@ -0,0 +1,6 @@ +YOUTUBE_CLIENT_ID=your-youtube-client-id +YOUTUBE_CLIENT_SECRET=your-youtube-client-secret +YOUTUBE_REFRESH_TOKEN=your-youtube-refresh-token +# YOUTUBE_REDIRECT_URI=http://localhost:8081 +# YOUTUBE_PRIVACY_STATUS=unlisted +# YOUTUBE_CATEGORY_ID=10 diff --git a/functions/index.js b/functions/index.js index 8e28f62..66bd872 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,3 +1,5 @@ +require("dotenv").config(); + const admin = require("firebase-admin"); // Use default credentials/environment provided by Cloud Functions. @@ -45,3 +47,4 @@ exports.algolia = require("./src/algolia"); exports.notifications = require("./src/notifications"); exports.rankings = require("./src/rankings"); exports.payouts = require("./src/payouts"); +exports.youtube = require("./src/youtube"); diff --git a/functions/package-lock.json b/functions/package-lock.json index 729aa04..8e4d24e 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -8,14 +8,17 @@ "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", "@genkit-ai/googleai": "^1.18.0", + "@google-cloud/firestore": "^7.11.6", "@google-cloud/storage": "^7.17.1", "axios": "^1.11.0", + "dotenv": "^17.2.3", "expo-server-sdk": "^4.0.0", "ffmpeg-static": "^5.2.0", "firebase-admin": "^12.6.0", "firebase-functions": "^6.0.1", "fluent-ffmpeg": "^2.1.3", "genkit": "^1.18.0", + "googleapis": "^131.0.0", "lodash": "^4.17.21", "resend": "^6.3.0", "sharp": "^0.33.4" @@ -536,9 +539,6 @@ "version": "7.11.6", "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@opentelemetry/api": "^1.3.0", "fast-deep-equal": "^3.1.1", @@ -633,6 +633,20 @@ "@opentelemetry/sdk-metrics": "^1.0.0" } }, + "node_modules/@google-cloud/opentelemetry-cloud-monitoring-exporter/node_modules/googleapis": { + "version": "137.1.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", + "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@google-cloud/opentelemetry-cloud-trace-exporter": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-cloud-trace-exporter/-/opentelemetry-cloud-trace-exporter-2.4.1.tgz", @@ -784,7 +798,6 @@ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -1250,7 +1263,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -1346,7 +1358,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "1.25.1" }, @@ -2534,7 +2545,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz", "integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "1.25.1", "@opentelemetry/semantic-conventions": "1.25.1" @@ -2577,7 +2587,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz", "integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "1.25.1", "@opentelemetry/resources": "1.25.1", @@ -2631,7 +2640,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz", "integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "1.25.1", "@opentelemetry/resources": "1.25.1", @@ -2940,8 +2948,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/memcached": { "version": "2.2.10", @@ -3143,7 +3150,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3779,6 +3785,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dotprompt": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/dotprompt/-/dotprompt-1.1.2.tgz", @@ -3971,7 +3988,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4466,7 +4482,6 @@ "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz", "integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@fastify/busboy": "^3.0.0", "@firebase/database-compat": "1.0.8", @@ -4662,8 +4677,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/gaxios": { "version": "6.7.1", @@ -4755,7 +4769,6 @@ "resolved": "https://registry.npmjs.org/genkit/-/genkit-1.22.0.tgz", "integrity": "sha512-GoVVO3EnNHrjkMkUPRvgx1MjBHKvOlZAu/ffMIJgLFxrH7rrUbvfHXE6Nk7uh5BNvET7+DApyhbhqz9G8sy+mQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@genkit-ai/ai": "1.22.0", "@genkit-ai/core": "1.22.0", @@ -4906,7 +4919,6 @@ "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@grpc/grpc-js": "^1.10.9", "@grpc/proto-loader": "^0.7.13", @@ -4930,7 +4942,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -4955,7 +4966,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -4970,11 +4980,10 @@ } }, "node_modules/googleapis": { - "version": "137.1.0", - "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", - "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==", + "version": "131.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-131.0.0.tgz", + "integrity": "sha512-fa4kdkY0VwHDw/04ItpQv2tlvlPIwbh6NjHDoWAVrV52GuaZbYCMOC5Y+hRmprp5HHIMRODmyb2YujlbZSRUbQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "google-auth-library": "^9.0.0", "googleapis-common": "^7.0.0" @@ -4988,7 +4997,6 @@ "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", "license": "Apache-2.0", - "optional": true, "dependencies": { "extend": "^3.0.2", "gaxios": "^6.0.3", @@ -5010,7 +5018,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -5923,7 +5930,6 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", - "optional": true, "engines": { "node": ">= 6" } @@ -6241,7 +6247,6 @@ "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "protobufjs": "^7.2.5" }, @@ -7234,8 +7239,7 @@ "version": "2.0.8", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", - "license": "BSD", - "optional": true + "license": "BSD" }, "node_modules/util-deprecate": { "version": "1.0.2", @@ -7338,9 +7342,7 @@ "version": "3.18.3", "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", - "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -7377,7 +7379,6 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT", "optional": true }, "node_modules/word-wrap": { @@ -7505,7 +7506,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/functions/package.json b/functions/package.json index bcab5f4..668ca76 100644 --- a/functions/package.json +++ b/functions/package.json @@ -16,14 +16,17 @@ "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", "@genkit-ai/googleai": "^1.18.0", + "@google-cloud/firestore": "^7.11.6", "@google-cloud/storage": "^7.17.1", "axios": "^1.11.0", + "dotenv": "^17.2.3", "expo-server-sdk": "^4.0.0", "ffmpeg-static": "^5.2.0", "firebase-admin": "^12.6.0", "firebase-functions": "^6.0.1", "fluent-ffmpeg": "^2.1.3", "genkit": "^1.18.0", + "googleapis": "^131.0.0", "lodash": "^4.17.21", "resend": "^6.3.0", "sharp": "^0.33.4" diff --git a/functions/src/youtube.js b/functions/src/youtube.js new file mode 100644 index 0000000..e149a20 --- /dev/null +++ b/functions/src/youtube.js @@ -0,0 +1,276 @@ +const { onCall, HttpsError } = require("firebase-functions/v2/https"); +const functions = require("firebase-functions"); +const logger = require("firebase-functions/logger"); +const admin = require("firebase-admin"); +const axios = require("axios"); +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { google } = require("googleapis"); + +const firestore = admin.firestore(); +const projectsRef = firestore.collection("projects"); + +const YOUTUBE_IN_PROGRESS_STATUSES = [ + "PUBLISHING", + "UPLOADING", + "PROCESSING", + "QUEUED", +]; + +const getEnvYoutubeConfig = () => + Object.fromEntries( + Object.entries({ + client_id: process.env.YOUTUBE_CLIENT_ID, + client_secret: process.env.YOUTUBE_CLIENT_SECRET, + refresh_token: process.env.YOUTUBE_REFRESH_TOKEN, + redirect_uri: process.env.YOUTUBE_REDIRECT_URI, + privacy_status: process.env.YOUTUBE_PRIVACY_STATUS, + category_id: process.env.YOUTUBE_CATEGORY_ID, + }).filter(([, value]) => value !== undefined && value !== "") + ); + +const ensureYoutubeConfig = () => { + const firebaseConfig = functions.config()?.youtube || {}; + const envConfig = getEnvYoutubeConfig(); + const cfg = { ...firebaseConfig, ...envConfig }; + const requiredKeys = ["client_id", "client_secret", "refresh_token"]; + const missing = requiredKeys.filter((key) => !cfg[key]); + if (missing.length) { + throw new HttpsError( + "failed-precondition", + `Configuration YouTube manquante: ${missing.join(", ")}` + ); + } + + return { + clientId: cfg.client_id, + clientSecret: cfg.client_secret, + refreshToken: cfg.refresh_token, + redirectUri: + cfg.redirect_uri || "https://developers.google.com/oauthplayground", + defaultPrivacyStatus: cfg.privacy_status || "unlisted", + defaultCategoryId: cfg.category_id || "10", + }; +}; + +const createYoutubeClient = ({ + clientId, + clientSecret, + refreshToken, + redirectUri, +}) => { + const oauth2Client = new google.auth.OAuth2( + clientId, + clientSecret, + redirectUri + ); + oauth2Client.setCredentials({ refresh_token: refreshToken }); + const youtube = google.youtube({ + version: "v3", + auth: oauth2Client, + }); + return { youtube, oauth2Client }; +}; + +const downloadFile = async (url, destinationPath) => { + if (!/^https?:\/\//i.test(url || "")) { + throw new HttpsError("invalid-argument", `URL non valide: ${url}`); + } + + await fsp.mkdir(path.dirname(destinationPath), { recursive: true }); + + const response = await axios.get(url, { responseType: "stream" }); + + await new Promise((resolve, reject) => { + const writer = fs.createWriteStream(destinationPath); + response.data.pipe(writer); + writer.on("finish", resolve); + writer.on("error", reject); + }); + + return destinationPath; +}; + +const buildVideoMetadata = (project, defaults) => { + const baseTitle = project?.title || "Création MusicLand"; + const youtubeTitle = `${baseTitle} | MusicLand`; + const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`; + const tags = Array.isArray(project?.youtubeTags) + ? project.youtubeTags.filter(Boolean).slice(0, 500) + : undefined; + + const snippet = { + title: youtubeTitle, + description, + categoryId: defaults.defaultCategoryId, + }; + + if (tags && tags.length) { + snippet.tags = tags; + } + + return { + snippet, + status: { + privacyStatus: defaults.defaultPrivacyStatus, + embeddable: true, + selfDeclaredMadeForKids: false, + }, + }; +}; + +exports.publishPlaybackToYoutube = onCall( + { + timeoutSeconds: 540, + memory: "1GiB", + cors: [ + "http://localhost:8081", + "https://musicland-one.vercel.app/", + "https://musicland-d33f9.firebaseapp.com", + ], + }, + async ({ data = {}, auth }) => { + const uid = auth?.uid; + if (!uid) { + throw new HttpsError("unauthenticated", "Authentification requise"); + } + + const projectId = data?.projectId; + if (!projectId || typeof projectId !== "string") { + throw new HttpsError("invalid-argument", "Paramètre projectId requis"); + } + + const projectSnap = await projectsRef.doc(projectId).get(); + if (!projectSnap.exists) { + throw new HttpsError("not-found", "Projet introuvable"); + } + + const project = projectSnap.data(); + if (!project || project.userId !== uid) { + throw new HttpsError( + "permission-denied", + "Vous n'avez pas les droits sur ce projet" + ); + } + + if (!project.playbackUrl) { + throw new HttpsError( + "failed-precondition", + "Aucun playback disponible pour la publication" + ); + } + + if ( + project.youtubeStatus && + YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus) + ) { + throw new HttpsError( + "failed-precondition", + "Une publication est déjà en cours pour ce projet" + ); + } + + let youtubeDefaults; + let youtubeClient; + try { + youtubeDefaults = ensureYoutubeConfig(); + youtubeClient = createYoutubeClient(youtubeDefaults); + await youtubeClient.oauth2Client.getAccessToken(); + } catch (error) { + logger.error("[publishPlaybackToYoutube] configuration invalide", { + error: error?.message, + }); + throw new HttpsError( + "failed-precondition", + "Configuration YouTube invalide ou incomplète" + ); + } + + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "yt-upload-")); + const videoPath = path.join(tmpDir, "playback.mp4"); + + try { + await projectsRef.doc(projectId).set( + { + youtubeStatus: "PUBLISHING", + youtubePublished: false, + youtubeError: null, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true } + ); + + await downloadFile(project.playbackUrl, videoPath); + + const metadata = buildVideoMetadata(project, youtubeDefaults); + + const uploadResponse = await youtubeClient.youtube.videos.insert({ + part: ["snippet", "status"].join(","), + requestBody: metadata, + media: { + body: fs.createReadStream(videoPath), + }, + }); + + const videoId = uploadResponse?.data?.id; + if (!videoId) { + throw new Error("ID de vidéo introuvable dans la réponse YouTube"); + } + + const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`; + + await projectsRef.doc(projectId).set( + { + youtubeStatus: "PUBLISHED", + youtubePublished: true, + youtubeUrl: youtubeLink, + youtubeVideoId: videoId, + youtubePublishedAt: admin.firestore.FieldValue.serverTimestamp(), + youtubeError: null, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true } + ); + + logger.info("[publishPlaybackToYoutube] publication réussie", { + projectId, + videoId, + }); + + return { + videoId, + youtubeUrl: youtubeLink, + }; + } catch (error) { + logger.error("[publishPlaybackToYoutube] échec de publication", { + projectId, + error: error?.message, + }); + + const errorMessage = + error instanceof HttpsError + ? error.message + : error?.message || "Publication YouTube échouée"; + + await projectsRef.doc(projectId).set( + { + youtubeStatus: "FAILED", + youtubePublished: false, + youtubeError: errorMessage, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true } + ); + + if (error instanceof HttpsError) { + throw error; + } + + throw new HttpsError("internal", errorMessage); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + } +);