monthly money
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
"associatedDomains": ["applinks:minuit.starter"],
|
"associatedDomains": ["applinks:minuit.starter"],
|
||||||
"bundleIdentifier": "com.omedis.musicland.ai",
|
"bundleIdentifier": "com.omedis.musicland.ai",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
|
"NSMicrophoneUsageDescription": "MusicLand uses the microphone to record your voice and audio so you can create and collaborate on music projects.",
|
||||||
"UISupportedInterfaceOrientations": [
|
"UISupportedInterfaceOrientations": [
|
||||||
"UIInterfaceOrientationPortrait",
|
"UIInterfaceOrientationPortrait",
|
||||||
"UIInterfaceOrientationPortraitUpsideDown"
|
"UIInterfaceOrientationPortraitUpsideDown"
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
|
|||||||
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
|
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
|
||||||
<div style="padding:32px 28px">
|
<div style="padding:32px 28px">
|
||||||
<div style="text-align:center;margin-bottom:24px">
|
<div style="text-align:center;margin-bottom:24px">
|
||||||
<img src="${musicLandLogoSrc}" alt="MusicLand" style="height:48px" />
|
<img
|
||||||
|
src="${musicLandLogoSrc}"
|
||||||
|
alt="MusicLand"
|
||||||
|
style="display:block;margin:0 auto;width:180px;max-width:100%;height:auto"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p>
|
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p>
|
||||||
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
|
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ exports.upload = require("./src/upload");
|
|||||||
exports.algolia = require("./src/algolia");
|
exports.algolia = require("./src/algolia");
|
||||||
exports.notifications = require("./src/notifications");
|
exports.notifications = require("./src/notifications");
|
||||||
exports.rankings = require("./src/rankings");
|
exports.rankings = require("./src/rankings");
|
||||||
|
exports.payouts = require("./src/payouts");
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||||
|
const { refList } = require("../index");
|
||||||
|
|
||||||
|
const firestore = refList.projects.firestore;
|
||||||
|
|
||||||
|
const DISTRIBUTION_REVENUE_BASELINE = 1000;
|
||||||
|
const DISTRIBUTION_RATIO = 0.3;
|
||||||
|
const MAX_RECIPIENTS = 10;
|
||||||
|
const WEIGHTS = Array.from({ length: MAX_RECIPIENTS }, (_, idx) => MAX_RECIPIENTS - idx);
|
||||||
|
|
||||||
|
const buildPreviousMonthContext = (referenceDate) => {
|
||||||
|
const current = referenceDate ? new Date(referenceDate) : new Date();
|
||||||
|
current.setHours(1, 0, 0, 0);
|
||||||
|
current.setDate(1);
|
||||||
|
|
||||||
|
const target = new Date(current);
|
||||||
|
target.setMonth(target.getMonth() - 1);
|
||||||
|
|
||||||
|
const month = target.getMonth();
|
||||||
|
const year = target.getFullYear();
|
||||||
|
const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`;
|
||||||
|
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0);
|
||||||
|
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month: month + 1,
|
||||||
|
monthKey,
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.distributeMonthlyPayouts = onSchedule(
|
||||||
|
{
|
||||||
|
schedule: "0 1 1 * *",
|
||||||
|
timeZone: "Europe/Paris",
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
const { scheduleTime } = event;
|
||||||
|
const context = buildPreviousMonthContext(
|
||||||
|
scheduleTime ? new Date(scheduleTime) : new Date()
|
||||||
|
);
|
||||||
|
const monthKey = context.monthKey;
|
||||||
|
const now = admin.firestore.Timestamp.now();
|
||||||
|
|
||||||
|
const statsSnapshot = await firestore
|
||||||
|
.collectionGroup("monthly")
|
||||||
|
.where("monthKey", "==", monthKey)
|
||||||
|
.orderBy("streams", "desc")
|
||||||
|
.limit(MAX_RECIPIENTS)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const topEntries = statsSnapshot.docs
|
||||||
|
.map((doc, index) => {
|
||||||
|
const data = doc.data() || {};
|
||||||
|
return {
|
||||||
|
rank: index + 1,
|
||||||
|
projectId: data.projectId || doc.ref.parent.parent?.id || null,
|
||||||
|
userId: data.userId || null,
|
||||||
|
streams: typeof data.streams === "number" ? data.streams : 0,
|
||||||
|
statsDocPath: doc.ref.path,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.projectId && entry.streams > 0);
|
||||||
|
|
||||||
|
const payoutPool = Number(
|
||||||
|
(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO).toFixed(2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const weights = WEIGHTS.slice(0, topEntries.length);
|
||||||
|
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
|
||||||
|
let allocations = topEntries.map((entry, idx) => {
|
||||||
|
if (!weightSum) return 0;
|
||||||
|
const rawAmount = (payoutPool * weights[idx]) / weightSum;
|
||||||
|
return Number(rawAmount.toFixed(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allocations.length > 0) {
|
||||||
|
const allocatedTotal = Number(
|
||||||
|
allocations.reduce((sum, amount) => sum + amount, 0).toFixed(2)
|
||||||
|
);
|
||||||
|
const remainder = Number((payoutPool - allocatedTotal).toFixed(2));
|
||||||
|
if (remainder !== 0) {
|
||||||
|
allocations[0] = Number((allocations[0] + remainder).toFixed(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payouts = topEntries.map((entry, idx) => ({
|
||||||
|
rank: entry.rank,
|
||||||
|
projectId: entry.projectId,
|
||||||
|
userId: entry.userId,
|
||||||
|
streams: entry.streams,
|
||||||
|
weight: weights[idx],
|
||||||
|
amount: allocations[idx],
|
||||||
|
statsDocPath: entry.statsDocPath,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
monthKey,
|
||||||
|
month: context.month,
|
||||||
|
year: context.year,
|
||||||
|
range: {
|
||||||
|
start: admin.firestore.Timestamp.fromDate(context.rangeStart),
|
||||||
|
end: admin.firestore.Timestamp.fromDate(context.rangeEnd),
|
||||||
|
},
|
||||||
|
revenueBaseline: DISTRIBUTION_REVENUE_BASELINE,
|
||||||
|
payoutRatio: DISTRIBUTION_RATIO,
|
||||||
|
payoutPool,
|
||||||
|
totalStreams: topEntries.reduce((sum, entry) => sum + entry.streams, 0),
|
||||||
|
totalRecipients: payouts.length,
|
||||||
|
totalAllocated: payouts.reduce(
|
||||||
|
(sum, payout) => Number((sum + payout.amount).toFixed(2)),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
payouts,
|
||||||
|
status: payouts.length ? "computed" : "no-data",
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
computedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const docRef = firestore.collection("monthlyPayouts").doc(monthKey);
|
||||||
|
await docRef.set(summary, { merge: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -4,6 +4,15 @@ const { refList } = require("../index");
|
|||||||
const { getSunoTimestamps } = require("./lyrics");
|
const { getSunoTimestamps } = require("./lyrics");
|
||||||
const { deleteFolder } = require("../helpers/firebase");
|
const { deleteFolder } = require("../helpers/firebase");
|
||||||
|
|
||||||
|
const firestore = admin.firestore();
|
||||||
|
|
||||||
|
const buildMonthKey = (timestamp) => {
|
||||||
|
const date = timestamp.toDate();
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
return `${year}-${month}`;
|
||||||
|
};
|
||||||
|
|
||||||
exports.onProjectUpdate = onDocumentWritten(
|
exports.onProjectUpdate = onDocumentWritten(
|
||||||
"projects/{projectId}",
|
"projects/{projectId}",
|
||||||
async (projectSnap) => {
|
async (projectSnap) => {
|
||||||
@@ -53,3 +62,69 @@ exports.onProjectUpdate = onDocumentWritten(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
exports.onProjectViewsIncrement = onDocumentWritten(
|
||||||
|
"projects/{projectId}",
|
||||||
|
async (projectSnap) => {
|
||||||
|
try {
|
||||||
|
const { projectId } = projectSnap.params;
|
||||||
|
const beforeData = projectSnap?.data?.before?.data() || null;
|
||||||
|
const afterData = projectSnap?.data?.after?.data() || null;
|
||||||
|
|
||||||
|
if (!afterData) return null;
|
||||||
|
|
||||||
|
const beforeViews =
|
||||||
|
typeof beforeData?.views === "number" && Number.isFinite(beforeData.views)
|
||||||
|
? beforeData.views
|
||||||
|
: 0;
|
||||||
|
const afterViews =
|
||||||
|
typeof afterData?.views === "number" && Number.isFinite(afterData.views)
|
||||||
|
? afterData.views
|
||||||
|
: 0;
|
||||||
|
const delta = afterViews - beforeViews;
|
||||||
|
|
||||||
|
if (delta <= 0) return null;
|
||||||
|
|
||||||
|
const userId =
|
||||||
|
typeof afterData?.userId === "string" && afterData.userId.trim().length
|
||||||
|
? afterData.userId.trim()
|
||||||
|
: null;
|
||||||
|
const now = admin.firestore.Timestamp.now();
|
||||||
|
const monthKey = buildMonthKey(now);
|
||||||
|
|
||||||
|
const statsDocRef = firestore
|
||||||
|
.collection("projectStreamStats")
|
||||||
|
.doc(projectId)
|
||||||
|
.collection("monthly")
|
||||||
|
.doc(monthKey);
|
||||||
|
|
||||||
|
await firestore.runTransaction(async (transaction) => {
|
||||||
|
const statsSnapshot = await transaction.get(statsDocRef);
|
||||||
|
const updatePayload = {
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
monthKey,
|
||||||
|
updatedAt: now,
|
||||||
|
lastStreamAt: now,
|
||||||
|
lastDelta: delta,
|
||||||
|
streams: admin.firestore.FieldValue.increment(delta),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFirstStream =
|
||||||
|
statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt;
|
||||||
|
if (!hasFirstStream) {
|
||||||
|
updatePayload.firstStreamAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.set(statsDocRef, updatePayload, { merge: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("onProjectViewsIncrement error", {
|
||||||
|
message: error?.message || String(error || ""),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -8,12 +8,44 @@ const { deleteFolder } = require("../helpers/firebase");
|
|||||||
const { Resend } = require("resend");
|
const { Resend } = require("resend");
|
||||||
const { welcomeTemplate } = require("../helpers/email");
|
const { welcomeTemplate } = require("../helpers/email");
|
||||||
const { RESEND_API_KEY } = require("../config/keys");
|
const { RESEND_API_KEY } = require("../config/keys");
|
||||||
|
const { onRequest } = require("firebase-functions/https");
|
||||||
|
|
||||||
const resendClient = new Resend(RESEND_API_KEY);
|
const resendClient = new Resend(RESEND_API_KEY);
|
||||||
const WELCOME_EMAIL_FROM =
|
const WELCOME_EMAIL_FROM =
|
||||||
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@minuit.app>";
|
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@minuit.app>";
|
||||||
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
||||||
|
|
||||||
|
exports.testWelcomMail = onRequest(async (req, res) => {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
res.set("Allow", "GET");
|
||||||
|
return res.status(405).json({ success: false, error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const targetEmail = req.query.email || "tdtomthomas@gmail.com";
|
||||||
|
const firstName = req.query.firstName || "Toto";
|
||||||
|
const lastName = req.query.lastName || "Test";
|
||||||
|
const { data, error } = await resendClient.emails.send({
|
||||||
|
from: WELCOME_EMAIL_FROM,
|
||||||
|
to: [targetEmail],
|
||||||
|
subject: WELCOME_EMAIL_SUBJECT,
|
||||||
|
html: welcomeTemplate({ firstName, lastName }),
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
console.log("Failed to send welcome email:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, error: error.message || error.toString() });
|
||||||
|
}
|
||||||
|
console.log("Welcome email sent:", data);
|
||||||
|
return res.status(200).json({ success: true, data });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, error: e.message || e.toString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
||||||
try {
|
try {
|
||||||
const { email = "", firstName = "", lastName = "" } = event?.data?.data();
|
const { email = "", firstName = "", lastName = "" } = event?.data?.data();
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { registerRootComponent } from 'expo';
|
|||||||
|
|
||||||
// Inject minimal global CSS and a small setNativeProps polyfill for web
|
// Inject minimal global CSS and a small setNativeProps polyfill for web
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement?.setAttribute('translate', 'no');
|
||||||
|
document.body?.setAttribute('translate', 'no');
|
||||||
|
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.setAttribute('data-inline-global', 'true');
|
style.setAttribute('data-inline-global', 'true');
|
||||||
style.innerHTML = `
|
style.innerHTML = `
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Pressable, Text, View } from "react-native";
|
|||||||
import { SheetManager } from "react-native-actions-sheet";
|
import { SheetManager } from "react-native-actions-sheet";
|
||||||
import { Palette } from "../../styles";
|
import { Palette } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
|
import { openShareSheet } from "../../utils/shareSheet";
|
||||||
import AppActionSheet from "../AppActionSheet";
|
import AppActionSheet from "../AppActionSheet";
|
||||||
|
|
||||||
const OptionButton = ({ label, onPress, disabled = false }) => (
|
const OptionButton = ({ label, onPress, disabled = false }) => (
|
||||||
@@ -40,6 +41,9 @@ const MusicOptionsModal = ({ payload }) => {
|
|||||||
projectId = null,
|
projectId = null,
|
||||||
ownerId = null,
|
ownerId = null,
|
||||||
title = null,
|
title = null,
|
||||||
|
onShare,
|
||||||
|
sharePayload = null,
|
||||||
|
shareDisabled = false,
|
||||||
} = payload || {};
|
} = payload || {};
|
||||||
|
|
||||||
const trigger = useCallback((action) => {
|
const trigger = useCallback((action) => {
|
||||||
@@ -97,6 +101,23 @@ const MusicOptionsModal = ({ payload }) => {
|
|||||||
trigger(run);
|
trigger(run);
|
||||||
}, [downloadDisabled, onDownload, trigger]);
|
}, [downloadDisabled, onDownload, trigger]);
|
||||||
|
|
||||||
|
const handleShare = useCallback(() => {
|
||||||
|
if (shareDisabled) return;
|
||||||
|
const run = () => {
|
||||||
|
if (typeof onShare === "function") {
|
||||||
|
onShare();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sharePayload) {
|
||||||
|
openShareSheet(sharePayload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trigger(run);
|
||||||
|
}, [onShare, shareDisabled, sharePayload, trigger]);
|
||||||
|
|
||||||
|
const shouldShowShareOption =
|
||||||
|
typeof onShare === "function" || !!sharePayload;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppActionSheet id="MusicOptions">
|
<AppActionSheet id="MusicOptions">
|
||||||
<View style={{ gap: 20 }}>
|
<View style={{ gap: 20 }}>
|
||||||
@@ -116,6 +137,13 @@ const MusicOptionsModal = ({ payload }) => {
|
|||||||
label="Ajouter à une playlist"
|
label="Ajouter à une playlist"
|
||||||
onPress={handleAddToPlaylist}
|
onPress={handleAddToPlaylist}
|
||||||
/>
|
/>
|
||||||
|
{shouldShowShareOption && (
|
||||||
|
<OptionButton
|
||||||
|
label="Partager la musique"
|
||||||
|
onPress={handleShare}
|
||||||
|
disabled={shareDisabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<OptionButton
|
<OptionButton
|
||||||
label="Télécharger"
|
label="Télécharger"
|
||||||
onPress={handleDownload}
|
onPress={handleDownload}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
getSegmentMeta,
|
getSegmentMeta,
|
||||||
normalizeStructureType,
|
normalizeStructureType,
|
||||||
} from "../../utils/songStructure";
|
} from "../../utils/songStructure";
|
||||||
|
import {
|
||||||
|
createMusicSharePayload,
|
||||||
|
openShareSheet,
|
||||||
|
} from "../../utils/shareSheet";
|
||||||
|
|
||||||
// 20 secondes
|
// 20 secondes
|
||||||
const timeBeforeIncrement = 20000;
|
const timeBeforeIncrement = 20000;
|
||||||
@@ -108,6 +112,19 @@ const MusicDetails = ({ route }) => {
|
|||||||
};
|
};
|
||||||
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
|
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
|
||||||
|
|
||||||
|
const sharePayload = useMemo(() => {
|
||||||
|
return createMusicSharePayload({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
});
|
||||||
|
}, [artist, projectId, title]);
|
||||||
|
|
||||||
|
const handleShare = useCallback(() => {
|
||||||
|
if (!sharePayload) return;
|
||||||
|
openShareSheet(sharePayload);
|
||||||
|
}, [sharePayload]);
|
||||||
|
|
||||||
const handleReport = useCallback(() => {
|
const handleReport = useCallback(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
SheetManager.show("Report", {
|
SheetManager.show("Report", {
|
||||||
@@ -147,15 +164,20 @@ const MusicDetails = ({ route }) => {
|
|||||||
onAddToPlaylist: handleAddToPlaylist,
|
onAddToPlaylist: handleAddToPlaylist,
|
||||||
onDownload: handleDownload,
|
onDownload: handleDownload,
|
||||||
downloadDisabled: !songUrl,
|
downloadDisabled: !songUrl,
|
||||||
|
onShare: sharePayload ? handleShare : null,
|
||||||
|
sharePayload,
|
||||||
|
shareDisabled: !sharePayload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
handleAddToPlaylist,
|
handleAddToPlaylist,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
handleReport,
|
handleReport,
|
||||||
|
handleShare,
|
||||||
owner?.id,
|
owner?.id,
|
||||||
project?.userId,
|
project?.userId,
|
||||||
projectId,
|
projectId,
|
||||||
|
sharePayload,
|
||||||
songUrl,
|
songUrl,
|
||||||
title,
|
title,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -243,16 +243,21 @@ const MusicDetails = ({ route }) => {
|
|||||||
onAddToPlaylist: handleAddToPlaylist,
|
onAddToPlaylist: handleAddToPlaylist,
|
||||||
onDownload: handleDownload,
|
onDownload: handleDownload,
|
||||||
downloadDisabled: isDownloading || !songUrl,
|
downloadDisabled: isDownloading || !songUrl,
|
||||||
|
onShare: sharePayload ? handleShare : null,
|
||||||
|
sharePayload,
|
||||||
|
shareDisabled: !sharePayload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
handleAddToPlaylist,
|
handleAddToPlaylist,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
handleReport,
|
handleReport,
|
||||||
|
handleShare,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
owner?.id,
|
owner?.id,
|
||||||
project?.userId,
|
project?.userId,
|
||||||
projectId,
|
projectId,
|
||||||
|
sharePayload,
|
||||||
songUrl,
|
songUrl,
|
||||||
title,
|
title,
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user