diff --git a/README.md b/README.md index 6091b3c..c3bedd0 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,37 @@ This repository contains tools to personalize and expand your Faba+ (and MyFaba) experience. -## Upload Custom .wav Audio Using Faba Me Cloud +## Upload Custom Audio Using Faba Me Cloud -Using the Faba Me sharing functionality, you can upload a .wav file directly to the MyFaba cloud and associate it with your Faba Me robots (white, blue, or red). +Using the Faba Me sharing functionality, you can upload a .wav or .mp3 file directly to the MyFaba cloud and associate it with your Faba Me robots (white, blue, or red). -To upload audio, you’ll need an “Invite to Record” URL link. To generate one from the MyFaba mobile app: +To upload audio, you'll need an "Invite to Record" URL link. To generate one from the MyFaba mobile app: 1. Select: _Faba Me_ > _FABA Me White_ (or Red/Blue) 2. Tap _+ Add New Track_ and select _Invite to Record_ -3. Copy the last 10 characters of the URL generated (e.g., `8K3TzYl2WB` for `https://studio.myfaba.com/record/8K3TzYl2WB`) as `share_id` +3. Copy the full URL generated (e.g., `https://studio.myfaba.com/it/invites/b92b4a71-19c7-486a-8114-9bdd1e6fe886?token=eyJhbGc...`) -Use the following Python command to upload the file: +Use the following Python command to upload the file, passing the full invite URL: ```bash -python3 myfaba_upload.py [-h] <wav_file> +python3 myfaba_upload.py [-h] [-t TOKEN] <invite_url_or_id> <author> <title> <wav_or_mp3_file> ``` e.g.: ```bash -python3 myfaba_upload.py 8K3TzYl2WB "Author Name" "Audio Title" ./audio/test.wav +python3 myfaba_upload.py "https://studio.myfaba.com/it/invites/b92b4a71-19c7-486a-8114-9bdd1e6fe886?token=eyJhbGc..." "Author Name" "Audio Title" ./audio/test.wav ``` +Alternatively, pass the bare `invitePublicId` (the UUID in the URL path) together with `-t TOKEN`: +```bash +python3 myfaba_upload.py b92b4a71-19c7-486a-8114-9bdd1e6fe886 "Author Name" "Audio Title" ./audio/test.wav -t eyJhbGc... +``` + > [!NOTE] > The upload may take a few minutes, depending on your internet speed and file size. Once completed, you should receive a notification on the MyFaba mobile app. +> [!IMPORTANT] +> The invite token expires 90 minutes after it's generated, and the API caps uploads at **100MB**. For long recordings, prefer .mp3 over .wav to stay under the limit (a 100kbps stereo mp3 uses roughly 45MB/hour, vs. ~660MB/hour for uncompressed 48kHz .wav). + -### Converting .mp3 Files to .wav +### Converting/Concatenating .mp3 Files To convert .mp3 files to .wav format, you can use these commands: - Using VLC: ```bash @@ -35,6 +43,12 @@ vlc.exe --sout "#transcode{acodec=s16l,channels=2,samplerate=44100}:std{access=f ffmpeg -i ./audio/test.mp3 -acodec pcm_s16le -ac 2 -ar 44100 ./audio/test.wav ``` +To concatenate multiple .mp3 files (e.g., numbered `01.mp3`, `02.mp3`, ...) into a single .mp3 without re-encoding, using FFmpeg: +```bash +for f in *.mp3; do echo "file '$PWD/$f'"; done | sort > list.txt +ffmpeg -f concat -safe 0 -i list.txt -c copy combined.mp3 +``` + ## Manually Adding Audio Files to Faba+ You can manually add .mp3 files to an existing playlist (character) or create a new one using a customized character (new NFC tag). This requires: diff --git a/myfaba_upload.py b/myfaba_upload.py index 943d052..897bbee 100644 --- a/myfaba_upload.py +++ b/myfaba_upload.py @@ -1,150 +1,108 @@ -# Script Name: myfaba_upload.py -# Description: This script allows you to upload custom .wav audio to Faba+ using the Faba Me sharing functionality. -# The share_id is the string of the last 10 characters of the invite to record link. -# To generate the invite link from the mobile app: -# Faba Me > FABA Me White (or Red/Blue), + Add new track > Invite to record -# e.g.: 8K3TzYl2WB for https://studio.myfaba.com/record/8K3TzYl2WB -# -# Note: Uploaded audio will be stored and processed by the MyFaba cloud. -# .mp3 files can be converted to .wav as follow: -# vlc.exe --sout "#transcode{acodec=s16l,channels=2,samplerate=44100}:std{access=file,mux=wav,dst=audio\test.wav}" audio\test.mp3 -# ffmpeg -i ./audio/test.mp3 -acodec pcm_s16le -ac 2 -ar 44100 ./audio/test.wav -# -# Usage: python3 myfaba_upload.py [-h] <share_id> <author> <title> <wav_file> -# e.g.: python3 myfaba_upload.py 8K3TzYl2WB "Author Name" "Audio Title" ./audio/test.wav -# -# Author: 60ne https://github.com/60ne/ -# Date: 2025-03-16 -# Version: 1.0 -# -# This script is provided "as is" without warranty of any kind. -# - -import re -import wave -import logging -import argparse -import requests -from bs4 import BeautifulSoup -from datetime import datetime -from urllib.parse import urlparse, parse_qs - -BASE_URL = "https://studio.myfaba.com/record/" - -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - - -def check_share_id(share_id): - match = re.search(r"([A-Za-z0-9]{10})$", share_id) - return match.group(1) if match else None - - -def load_page(session, share_id): - url = f"{BASE_URL}{share_id}" - try: - response = session.get(url, allow_redirects=False) - response.raise_for_status() - - if response.status_code == 302: - xsrf_token = session.cookies.get("XSRF-TOKEN") - myfaba_session = session.cookies.get("myfaba_cms_session") - location_url = response.headers.get("Location") - - if xsrf_token and myfaba_session and location_url: - logging.info("Loading page") - return xsrf_token, myfaba_session, location_url - - logging.error(f"Unexpected response status: {response.status_code}") - except requests.RequestException as e: - logging.error(f"Failed to fetch parameters: {e}") - return None, None, None - - -def fetch_parameters(session, xsrf_token, myfaba_session, location_url): - headers = {"Cookie": f"XSRF-TOKEN={xsrf_token}; myfaba_cms_session={myfaba_session}"} - try: - response = session.get(location_url, headers=headers) - response.raise_for_status() - - soup = BeautifulSoup(response.text, "html.parser") - form = soup.find("form", {"id": "form"}) - - if form: - action_url = form["action"] - query_params = parse_qs(urlparse(action_url).query) - _token = soup.find("input", {"name": "_token"}) - - if _token and action_url: - logging.info("Parameters extracted successfully") - return action_url, query_params.get("expires", [None])[0], query_params.get("signature", [None])[0], _token["value"] - - logging.error("Form or token not found") - except requests.RequestException as e: - logging.error(f"Failed to fetch form parameters: {e}") - return None, None, None, None - - -def get_wav_duration(wav_path): - try: - with wave.open(wav_path, "rb") as wav_file: - return int(wav_file.getnframes() / float(wav_file.getframerate())) - except (wave.Error, FileNotFoundError) as e: - logging.error(f"Error reading WAV file: {e}") - return None - - -def upload_wav(session, action_url, xsrf_token, myfaba_session, _token, wav_path, author, title): - duration = get_wav_duration(wav_path) - if duration is None: - logging.error("Invalid .wav file duration. Check .wav file") - return False - - headers = {"Cookie": f"XSRF-TOKEN={xsrf_token}; myfaba_cms_session={myfaba_session}"} - data = {"_token": _token, "duration": str(duration), "creator": author, "title": title} - - try: - with open(wav_path, "rb") as audio_file: - files = {"userAudio": ("recorded.wav", audio_file, "audio/wav")} - response = session.post(action_url, headers=headers, files=files, data=data) - response.raise_for_status() - logging.info("Upload successfully completed!") - logging.info("Check Faba mobile app") - return True - except (requests.RequestException, IOError) as e: - logging.error(f"Upload failed: {e}") - return False - - -def main(): - parser = argparse.ArgumentParser(description="Upload custom .wav audio to Faba+ using the Faba Me sharing functionality") - parser.add_argument("share_id", help="The share_id is the string of the last 10 characters of the invite to record link") - parser.add_argument("author", help="Author name") - parser.add_argument("title", help="Audio title") - parser.add_argument("wav_path", help="Path of .wav file to upload") - args = parser.parse_args() - - share_id = check_share_id(args.share_id) - if not share_id: - logging.error("Invalid share_id format") - exit(1) - - session = requests.Session() - xsrf_token, myfaba_session, location_url = load_page(session, args.share_id) - - if xsrf_token and myfaba_session and location_url: - parsed_url = urlparse(location_url) - query_params = parse_qs(parsed_url.query) - expires_timestamp = int(query_params.get('expires', [0])[0]) - expires_datetime = datetime.utcfromtimestamp(expires_timestamp) - logging.info("share_id valid until: %s", expires_datetime.strftime('%Y-%m-%d %H:%M:%S UTC')) - - action_url, expires, signature, _token = fetch_parameters(session, xsrf_token, myfaba_session, location_url) - - if action_url and _token: - success = upload_wav(session, action_url, xsrf_token, myfaba_session, _token, args.wav_path, args.author, args.title) - if not success: - logging.error("Upload failed, try again") - - -if __name__ == "__main__": - main() \ No newline at end of file +# Script Name: myfaba_upload.py +# Description: This script allows you to upload custom .wav audio to Faba+ using the Faba Me +# "Invite to Record" sharing functionality. +# +# MyFaba Studio invite links now look like: +# https://studio.myfaba.com/<lang>/invites/<invitePublicId>?token=<jwt> +# e.g.: https://studio.myfaba.com/it/invites/b92b4a71-19c7-486a-8114-9bdd1e6fe886?token=eyJ... +# +# Pass either the full URL or just the <invitePublicId> and <token> separately. +# +# Note: Uploaded audio will be stored and processed by the MyFaba cloud. +# .mp3 files can be converted to .wav as follow: +# vlc.exe --sout "#transcode{acodec=s16l,channels=2,samplerate=44100}:std{access=file,mux=wav,dst=audio\test.wav}" audio\test.mp3 +# ffmpeg -i ./audio/test.mp3 -acodec pcm_s16le -ac 2 -ar 44100 ./audio/test.wav +# +# Usage: python3 myfaba_upload.py [-h] <invite_url_or_id> <author> <title> <wav_file> [-t TOKEN] +# e.g.: python3 myfaba_upload.py "https://studio.myfaba.com/it/invites/<id>?token=<jwt>" "Author Name" "Audio Title" ./audio/test.wav +# +# Author: 60ne https://github.com/60ne/ +# Date: 2025-03-16 +# Version: 2.0 +# +# This script is provided "as is" without warranty of any kind. +# + +import re +import logging +import argparse +import requests +from urllib.parse import urlparse, parse_qs + +API_URL = "https://api.myfaba.com/api/v3/studio/invites/{invite_id}/recordings" + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + + +def parse_invite(invite_arg, token_arg): + """Accepts either a full invite URL (with ?token=...) or a bare invitePublicId + separate token.""" + if invite_arg.startswith("http"): + parsed = urlparse(invite_arg) + match = UUID_RE.search(parsed.path) + if not match: + return None, None + invite_id = match.group(0) + token = parse_qs(parsed.query).get("token", [None])[0] + return invite_id, token + + invite_id = invite_arg if UUID_RE.fullmatch(invite_arg) else None + return invite_id, token_arg + + +def upload_wav(invite_id, token, wav_path, author, title): + url = API_URL.format(invite_id=invite_id) + params = {"token": token} + ext = wav_path.rsplit(".", 1)[-1].lower() + mime = {"wav": "audio/wav", "mp3": "audio/mpeg"}.get(ext, "application/octet-stream") + + try: + with open(wav_path, "rb") as audio_file: + files = {"audio": (f"recording.{ext}", audio_file, mime)} + data = {"title": title} + if author: + data["creator"] = author + + response = requests.post(url, params=params, files=files, data=data) + except (requests.RequestException, IOError) as e: + logging.error(f"Upload failed: {e}") + return False + + try: + payload = response.json() + except ValueError: + logging.error(f"Upload failed: unexpected response ({response.status_code}): {response.text[:300]}") + return False + + if response.status_code >= 200 and response.status_code < 300 and payload.get("success"): + logging.info("Upload successfully completed!") + logging.info("Check Faba mobile app") + return True + + logging.error(f"Upload failed: {payload.get('error')} - {payload.get('message')}") + return False + + +def main(): + parser = argparse.ArgumentParser(description="Upload custom .wav audio to Faba+ using the Faba Me sharing functionality") + parser.add_argument("invite", help="Full invite URL (https://studio.myfaba.com/<lang>/invites/<id>?token=<jwt>) or bare invitePublicId") + parser.add_argument("author", help="Author name") + parser.add_argument("title", help="Audio title") + parser.add_argument("wav_path", help="Path of .wav file to upload") + parser.add_argument("-t", "--token", help="JWT token, required if 'invite' is a bare invitePublicId rather than a full URL") + args = parser.parse_args() + + invite_id, token = parse_invite(args.invite, args.token) + if not invite_id or not token: + logging.error("Could not determine invitePublicId/token. Pass the full invite URL, or invitePublicId with -t TOKEN.") + exit(1) + + logging.info(f"Uploading to invite {invite_id}") + success = upload_wav(invite_id, token, args.wav_path, args.author, args.title) + if not success: + exit(1) + + +if __name__ == "__main__": + main()