Welcome to the official documentation for Harmonia, a music streaming platform made of three projects: a Flutter mobile app for Android and iOS, a NestJS backend API, and a Next.js admin panel. Follow this guide to set everything up, brand it and publish it.
A plain-language tour of what the app does, so you can find your way around before touching any code. Nothing in this section needs programming knowledge — the technical setup starts at section 1.
| Tab | What the listener does there |
|---|---|
| Home | Banner carousel, then the sections you arrange in the admin panel: top hits, playlists, just arrived, artists, moods, genres, shorts, recently played and continue listening. Free listeners also see a banner ad. |
| Explore | Search by text or voice, with trending searches and suggestions. Browse all, moods, genres and fresh picks. |
| Premium | Premium plans with store prices, the free trial offer, the current plan status, and Restore purchases. |
| Library | Playlists, saved albums and artists, liked songs, history and downloads, with filters, search, and a grid or list view. Listeners create their own playlists here. |
You decide which songs, albums and playlists are Premium. Free listeners see Premium content locked and hear ads; Premium listeners get ad-free listening, downloads and Premium-only content. Premium is sold as a subscription through Google Play Billing (see section 11).
Profile and photo, your devices (sign any device out remotely), music taste, manage subscription, playback options, audio quality, push notifications, theme (light, dark or system), language (English or Bangla), clear cache, legal pages, credits, sign out and delete account.
Almost everything listeners see is managed in the browser, without rebuilding the app: songs, albums, artists, playlists, shorts, home sections, banners, moods and genres, Premium plans and the Premium screen text, ads, push notifications, app version and maintenance mode, legal links, app users and staff accounts. The full list is in section 14.
Harmonia is self-hosted: you receive the full source code, with no licence keys and nothing payable to us. Some features connect to outside services that are not included in the purchase.
firebase_core, firebase_auth and firebase_messaging in the app, and firebase-admin in the backend). You create and maintain it, accept Google's Firebase Terms of Service, and pay any usage beyond the free tier. Setup: section 4.googleapis package), so the backend can verify Premium purchases. You accept the Google APIs Terms of Service. Setup: section 13, step 7.flutter_facebook_auth, which uses Meta's Facebook SDK / FBSDKLoginKit). Meta's Platform Terms require every app to register its own App ID. Setup: section 5.| Service | Required? | Used for | Who bills you |
|---|---|---|---|
| VPS or cloud server | Required | Runs the backend (Node.js 20+, PostgreSQL 14+) and the admin panel | Your hosting provider |
| Firebase (your own project) | Required | App sign-in (Authentication) and push notifications (Cloud Messaging). The free Spark plan covers typical use. Subject to Google's Firebase terms | Google, beyond the free tier |
| Google Play Developer API (your own service account) | Required for Premium | The backend checks every Google Play purchase with Google | Free API; Google Cloud project needed |
| Cloudflare R2 | Required | Stores audio, covers and images uploaded from the admin panel | Cloudflare, beyond the free tier |
| Google Play Developer account | Required for Android | Publishing the app; USD 25 one-time | |
| Apple Developer Program | Required for iOS | Publishing the app and Sign in with Apple; USD 99 per year | Apple |
| Google Play Billing | Optional | Premium subscriptions. The only payment method included. Google takes a commission on each sale, typically 15–30% | Google (deducted from sales) |
| Google Cloud Pub/Sub | Optional | Real-time notifications about renewals and refunds from Google Play | Google, beyond the free tier |
| Apple In-App Purchase, Stripe, PayPal, other gateways | Not included | No checkout integration ships for these. Premium cannot be bought on iOS in this version | — |
| Google AdMob (your own account) | Optional | Banner and interstitial ads for free listeners. You must follow AdMob's policies (section 10) | Free to join; you are responsible for policy compliance and any costs related to your account or traffic |
| Meta for Developers (your own Facebook App ID) | Optional | Facebook sign-in | Free |
| Cloudflare Workers | Optional | Expiring audio links (the included worker script) | Cloudflare, beyond the free tier |
| Music licensing | Required for real content | You must own or license every track you publish. No music is included in the package | Rights holders / licensing bodies |
Harmonia is three projects that talk to one backend. Only the backend talks to the database and holds secrets; the app and the admin panel never connect to PostgreSQL or R2 credentials directly.
| Connection | What happens |
|---|---|
| App → Firebase | The listener signs in (email, Google, Facebook or Apple). Firebase returns an ID token. |
| App → Backend | Every request goes to https://your-api/api/v1/... with the Firebase ID token in Authorization: Bearer (guests send none). The backend verifies the token with the Firebase Admin SDK. |
| Admin panel → Backend | Staff sign in with email and password; the backend returns a JWT that the panel sends on every /admin/... request. Roles: viewer, editor, super admin. |
| Backend → PostgreSQL | All data lives here. Only the backend connects. |
| Backend → R2, App → R2 | The admin uploads files through the backend, which stores them in R2 and makes the low/normal/high audio versions. The app streams audio and images straight from the R2 public URL (or an expiring signed URL if you enable it). |
| Backend → Firebase Cloud Messaging | Push notifications composed in the admin panel are sent by the backend, to the broadcast topic or to registered devices. |
| App ↔ Google Play → Backend | The app buys a subscription with Google Play Billing and sends the purchase token to the backend, which checks it with Google. Google then reports renewals and cancellations to the backend through real-time developer notifications (Pub/Sub). |
| App → AdMob | Free listeners see AdMob ads. The ad unit IDs come from the backend (GET /ads/config), set on the admin panel's Ads page. |
Use these versions for the smoothest build. The app was built and tested with the versions marked "tested".
The backend, PostgreSQL and the admin panel can run together on one Linux server (Ubuntu 22.04 or 24.04 LTS recommended). Audio and images are stored in Cloudflare R2, not on the server, so the disk only needs room for the code, the database and temporary files during uploads.
| CPU | RAM | Disk | Good for | |
|---|---|---|---|---|
| Minimum | 1 vCPU | 2 GB (plus 2 GB swap) | 20 GB SSD | Testing and a small catalogue; uploads convert slowly |
| Recommended | 2 vCPU | 4 GB | 40 GB SSD | Launch, thousands of listeners, regular uploads |
| Growing | 4 vCPU or more | 8 GB or more | 80 GB SSD | Large catalogue and many daily listeners; consider a managed PostgreSQL |
CPU matters most when you upload songs, because FFmpeg converts each one into three stream qualities. Building the admin panel (npm run build) needs about 1.5 GB of free RAM; on a 2 GB server add swap first. A shared hosting plan (cPanel without SSH and Node.js) is not enough; you need a VPS or cloud server with SSH access.
java -version shows something newer, point Flutter at Android Studio's JDK: flutter config --jdk-dir "<Android Studio>/jbr".
Select your operating system to see the setup steps.
C:\flutter.C:\flutter\bin to the Path environment variable (This PC > Properties > Advanced system settings > Environment Variables).flutter doctor. Fix anything it reports.tar xf flutter_macos_<version>.tar.xzflutter/bin to your PATH in ~/.zshrc, then open a new terminal and run flutter doctor.sudo xcodebuild -runFirstLaunch.sudo gem install cocoapods (or brew install cocoapods).tar -xvzf android-studio-*-linux.tar.gz) and run bin/studio.sh.tar xf flutter_linux_<version>.tar.xzflutter/bin to your PATH, then run flutter doctor.cd Flutter-App
flutter pub get
flutter run
Generated code (models and translations) is already included. Regenerate it only after you change models or translation files:
dart run build_runner build --delete-conflicting-outputs
flutter gen-l10n
Every app needs a unique identifier, for example com.yourcompany.music. Change it before you set up Firebase, because Firebase apps are registered by this identifier.
| File | Change |
|---|---|
android/app/build.gradle.kts | namespace and applicationId (currently com.harmonia.music.player) |
android/app/src/main/AndroidManifest.xml | the package attribute on <manifest> |
android/app/src/main/kotlin/.../MainActivity.kt.../NowPlayingWidgetProvider.kt | the package line at the top of both files. You may also move both files into a folder that matches the new package (optional; the build works either way). |
ios/Runner.xcworkspace in Xcode.<your bundle id>.RunnerTests).pubspec.yaml (harmonia) is internal and never shown to users. You can leave it as is; renaming it means updating every package:harmonia/ import.
Firebase provides sign-in and push notifications.
YOUR_ANDROID_API_KEY and your-firebase-project-id. They let the project compile, but sign-in and push notifications will not work until you replace them:
Flutter-App/lib/firebase_options.dartFlutter-App/android/app/google-services.jsonFlutter-App/ios/Runner/GoogleService-Info.plist
The FlutterFire CLI registers your Android and iOS apps and writes all config files for you.
npm install -g firebase-tools
firebase login
dart pub global activate flutterfire_cli
cd Flutter-App
flutterfire configure --project=<your-firebase-project-id>
Select android and ios, and confirm the package name and bundle ID from section 3. The command updates:
lib/firebase_options.dartandroid/app/google-services.jsonios/Runner/GoogleService-Info.plistfirebase.jsongoogle-services.json and place it in android/app/.GoogleService-Info.plist and drag it into Runner/ in Xcode (tick "Copy items if needed").flutterfire configure once anyway, or update the values in lib/firebase_options.dart by hand — the app reads its Firebase settings from that file.
The backend verifies sign-ins and sends push notifications with a Firebase service account: Project settings > Service accounts > Generate new private key. You will paste this JSON into the backend's .env in section 13.
| Where | File | Key |
|---|---|---|
| Android launcher name | android/app/src/main/AndroidManifest.xml | android:label |
| Android widget and app strings | android/app/src/main/res/values/strings.xml | app_name, app_display_name |
| iOS home-screen name | ios/Runner/Info.plist | CFBundleDisplayName, CFBundleName |
| Name inside the app | lib/l10n/app_en.arb, lib/l10n/app_bn.arb | appName (then run flutter gen-l10n) |
| Splash wordmark and tagline | lib/feature/splash/presentation/splash_screen.dart, ARB key splashTagline | 'HARMONIA' |
| Other on-screen brand text | lib/feature/auth/presentation/pages/login_page.dart, home_app_bar.dart, lyric_card_page.dart, local_notification_service.dart | search for Harmonia |
Tip: search the lib/ folder for Harmonia to find every occurrence.
android/app/src/main/res/mipmap-*/ (adaptive icon: mipmap-anydpi-v26/ic_launcher.xml). The easiest way is Android Studio: right-click res > New > Image Asset.ios/Runner/Assets.xcassets/AppIcon.appiconset/, or drop a 1024×1024 image onto the AppIcon set in Xcode.splash_background in android/app/src/main/res/values/colors.xml (and LaunchImage on iOS).lib/feature/splash/presentation/splash_screen.dart.The brand colours are in lib/core/theme/src/theme_extension/src/colors/semantic.dart. Flutter writes colours as 0xAARRGGBB: FF (fully opaque) followed by the normal hex code. The default brand green is #1DDA63:
// lib/core/theme/src/theme_extension/src/colors/semantic.dart
static const Color primary = Color(0xFF1DDA63); // brand green #1DDA63
static const Color darkBackground = Color(0xFF020E28); // dark theme page #020E28
static const Color darkSurface = Color(0xFF0B1B3D); // dark cards #0B1B3D
Example: to use orange #FF6B00 as your brand colour, change the first line to Color(0xFFFF6B00), then stop the app and run it again (hot reload does not update const colours).
lib/core/theme/theme.dart.lib/core/theme/palette.dart. Dark is the default theme; listeners can switch in Settings.Open lib/core/network/constants/api_endpoint.dart and set your backend's public address:
static const String apiHost = 'https://your-domain.com'; // change to e.g. https://api.yourdomain.com
/api/v1; the app adds that itself.http://10.0.2.2:7001 (Android emulator) or your computer's LAN IP (real device).http:// only works in debug builds. Release builds are HTTPS-only: Android allows cleartext traffic only in android/app/src/debug/AndroidManifest.xml, and iOS allows only local-network addresses (NSAllowsLocalNetworking). Your production apiHost and media URLs (R2) must use https://.Privacy policy, terms, support e-mail, store links, minimum app version and maintenance mode are not in the app code. They come from the backend and are edited on the admin panel's App config page.
A privacy policy template that matches the app's features is included at Flutter-App/docs/privacy-policy.md. Replace the [PLACEHOLDERS] with your details, remove the parts for features you turned off, have it checked for your countries, publish it at a public URL and paste that URL into App config. Google Play, the App Store and Meta all require one.
The app ships with English and Bangla. By default it follows the phone's language when it is supported, otherwise English. Listeners can choose in Settings > Appearance > Language.
lib/l10n/app_en.arb (the template) and lib/l10n/app_bn.arb.lib/l10n/gen/ (configured in l10n.yaml).Each language is one JSON file of "key": "text" pairs. The keys are the same in every file; only the text changes. Entries starting with @ describe placeholders and exist only in the English template. Here is part of the existing English file and its Bangla counterpart:
// lib/l10n/app_en.arb (template)
{
"@@locale": "en",
"appName": "Harmonia",
"settingsTitle": "Settings",
"settingsLanguage": "Language",
"settingsClearCacheSubtitle": "{size} used by images and temporary files",
"@settingsClearCacheSubtitle": {
"placeholders": { "size": { "type": "String" } }
}
}
// lib/l10n/app_bn.arb (existing Bangla)
{
"@@locale": "bn",
"appName": "হারমোনিয়া",
"settingsTitle": "সেটিংস",
"settingsLanguage": "ভাষা",
"settingsClearCacheSubtitle": "ছবি আর অস্থায়ী ফাইলে {size} জায়গা নিচ্ছে"
}
A new Spanish file lib/l10n/app_es.arb should contain the same keys, "@@locale": "es", and keep every {placeholder} exactly as in English:
// lib/l10n/app_es.arb (new)
{
"@@locale": "es",
"appName": "Harmonia",
"settingsTitle": "Ajustes",
"settingsLanguage": "Idioma",
"settingsClearCacheSubtitle": "{size} usados por imágenes y archivos temporales"
}
Check: flutter gen-l10n finishes without errors and lists no untranslated keys for es. Any key you leave out falls back to English.
app_en.arb to app_xx.arb (for example app_es.arb), set "@@locale": "xx", delete the @ description entries and translate the values.'xx' to supportedLanguages in lib/core/settings/app_settings_cubit.dart:
static const supportedLanguages = ['en', 'bn', 'es'];lib/feature/profile/presentation/profile/pages/profile_page.dart:
final options = <String?, String>{null: l10n.settingsLanguageSystem, 'en': 'English', 'bn': 'বাংলা', 'es': 'Español'};flutter gen-l10n.lib/core/theme/app_fonts.dart.Delete its ARB file, remove it from supportedLanguages and the language tile, then run flutter gen-l10n.
Catalogue content (song titles, playlist names and so on) is shown as entered in the admin panel.
The package contains only Google's official AdMob test IDs (publisher ca-app-pub-3940256099942544, listed in Google's test ads guide). Ads show as "Test Ad" and earn nothing. No publisher ID of ours is included anywhere.
| Default test value | Where | Replace with |
|---|---|---|
ca-app-pub-3940256099942544~3347511713 | Android app ID, android/app/build.gradle.kts | your Android AdMob app ID |
ca-app-pub-3940256099942544~1458002511 | iOS app ID, ios/Runner/Info.plist | your iOS AdMob app ID |
ca-app-pub-3940256099942544/6300978111 | Android banner unit, admin panel Ads page | your Android banner unit ID |
ca-app-pub-3940256099942544/1033173712 | Android interstitial unit, admin panel Ads page | your Android interstitial unit ID |
ca-app-pub-3940256099942544/2934735716 | iOS banner unit, admin panel Ads page | your iOS banner unit ID |
ca-app-pub-3940256099942544/4411468910 | iOS interstitial unit, admin panel Ads page | your iOS interstitial unit ID |
The four ad unit test IDs are written to the database the first time the backend starts (Backend/src/modules/subscriptions/premium.defaults.ts). After that, change them on the admin panel's Ads page; editing the defaults file later has no effect on an existing database.
| Platform | File | Key |
|---|---|---|
| Android | android/app/build.gradle.kts | manifestPlaceholders["admobAppId"] in the release block (keep the test ID in the debug block) |
| iOS | ios/Runner/Info.plist | GADApplicationIdentifier |
Banner and interstitial unit IDs are not in the app. Set them on the admin panel's Ads page, separately for Android and iOS, together with how often interstitials appear (every N songs, minimum interval). The app reads them from GET /api/v1/ads/config at launch, so you can change them without a new release.
SKAdNetworkItems list and, if you use personalised ads, an App Tracking Transparency prompt (NSUserTrackingUsageDescription). See Google's AdMob Flutter quick start.
Premium listeners never see ads.
Android reads versionCode and versionName from android/app/build.gradle.kts (these override pubspec.yaml). Increase versionCode for every upload. iOS uses version in pubspec.yaml.
macOS / Linux:
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
Windows:
keytool -genkey -v -keystore %userprofile%\upload-keystore.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias upload
Create android/key.properties:
storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=upload
storeFile=<full path to upload-keystore.jks>
android/app/build.gradle.kts already reads this file in its signingConfigs block. If key.properties is missing, release builds are signed with the debug key so they still build — Google Play will not accept those.
flutter build apk --release # APK for direct install / testing
flutter build appbundle --release # App Bundle for Google Play
The App Bundle is created at build/app/outputs/bundle/release/app-release.aab. Upload it in Play Console.
cd ios && pod install && cd ..ios/Runner.xcworkspace, select your Team and bundle ID (section 3) and the capabilities from section 5.flutter build ipa --release, then upload build/ios/ipa/*.ipa with Xcode's Organizer or the Transporter app.flutter run --release.The backend lives in Backend/. It is a NestJS 11 + TypeORM API on PostgreSQL. The app and the admin panel talk only to this API.
cd Backend
npm ci
FFmpeg creates the low, normal and high stream qualities and measures loudness. It is not bundled with the package; install it with your system's package manager (free):
sudo apt update && sudo apt install -y ffmpeg # Ubuntu / Debian
brew install ffmpeg # macOS (local testing)
ffmpeg -version # check it works
The backend finds ffmpeg on the system PATH. If it is installed somewhere else, set its full path in .env: FFMPEG_PATH=/usr/local/bin/ffmpeg. Without FFmpeg the backend still runs and plays the original uploaded files; it just skips the extra stream qualities and loudness analysis and logs a warning.
createdb harmonia # or create it in pgAdmin / your host's panel
cp .env.example .env
Every variable is explained in .env.example. The important ones:
| Variable | What to set |
|---|---|
NODE_ENV | development on your computer, production on the server |
PORT | API port, default 7001 |
DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME | Your PostgreSQL connection |
JWT_SECRET | Signs admin logins. At least 32 characters: openssl rand -hex 48 |
ADMIN_EMAIL, ADMIN_PASSWORD | Your first super admin (see step 4) |
FIREBASE_SERVICE_ACCOUNT_JSON | The Firebase service-account JSON from section 4, on one line or base64-encoded |
CORS_ORIGINS | Your admin panel address, e.g. https://admin.yourdomain.com |
CLOUDFLARE_R2_* | Media storage (step 6) |
GOOGLE_PLAY_* | Premium verification (step 7) |
MEDIA_URL_SIGNING_SECRET | Optional expiring audio links (step 8) |
FCM_BROADCAST_TOPIC | Push topic for "Everyone" notifications, default harmonia.Must be exactly the same as broadcastTopic in the Flutter app (Flutter-App/lib/core/service/push_notification/push_notification_service.dart). If the two differ, "Everyone" notifications are sent but no phone receives them. See section 5. |
TRUST_PROXY | 1 behind nginx or Cloudflare, so rate limits see real client IPs |
To encode the Firebase JSON as base64 on macOS/Linux: base64 -i service-account.json | tr -d '\n'
FCM_BROADCAST_TOPIC in this .env and broadcastTopic in the Flutter app must be the same text. Both default to harmonia; if you change one, change the other and rebuild the app.
NODE_ENV=production the server refuses to start while unsafe values remain, and tells you which variable to change: a short or example JWT_SECRET, ALLOW_DEV_USER_HEADER=true, a default ADMIN_PASSWORD, an ADMIN_EMAIL at your-domain.com, a missing Firebase service account, an empty CORS_ORIGINS, or a missing GOOGLE_PLAY_RTDN_TOKEN when Google Play is configured.
npm run start:dev # development, with auto-reload; API at http://localhost:7001/api/v1
This is the real output of npm run start:dev on an empty database, with only the database, JWT_SECRET and admin account set in .env (Firebase, R2 and Google Play not configured yet). About 200 lines that list modules and routes are shortened in the middle.
FIREBASE_SERVICE_ACCOUNT_JSON (section 4, step 4). App sign-in is not verified until then.ADMIN_EMAIL account was created. Sign in to the admin panel with it.DeprecationWarning comes from the Nest CLI's file watcher, not from Harmonia, and can be ignored.If the output stops with a red error before line 7, compare it with the troubleshooting list below. The most common causes are a wrong database password or port, and a database that does not exist yet.
$ curl -s "http://localhost:7001/api/v1/app/config?platform=android"
{"data":{"min_version":"1.0.0","latest_version":"1.0.0","store_url":null,"force_update_message":"Please update to the latest version to keep listening.","maintenance_mode":false,"maintenance_message":"We are doing some maintenance. Back shortly.","privacy_url":null,"terms_url":null,"support_email":null},"isArray":false,"path":"/api/v1/app/config","duration":"5ms","method":"GET"}
A JSON reply like this (wrapped in data) means the API, the database and the default settings all work. In development, open http://localhost:7001/api in your browser to see the interactive Swagger API docs:
http://localhost:7001/api after a successful first run (development only; it is switched off in production).db/migrations/ on every start.admin_users table is empty, the server creates a super admin from ADMIN_EMAIL and ADMIN_PASSWORD. Use these to sign in to the admin panel.ADMIN_EMAIL and ADMIN_PASSWORD from .env; they are only used while no admin exists.http://localhost:7001/api when NODE_ENV is not production.npm ci
npm run build
npm install -g pm2
# edit cwd in prod.config.js, then:
pm2 start prod.config.js # sets NODE_ENV=production
pm2 save && pm2 startup # restart automatically after a reboot
Put nginx in front with HTTPS (for example with Let's Encrypt / certbot):
server {
server_name api.yourdomain.com;
client_max_body_size 250m; # admin uploads: audio up to 60 MB, video up to 250 MB
location / {
proxy_pass http://127.0.0.1:7001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
To update later: upload the new files, then npm ci && npm run build && pm2 restart harmonia-api. New migrations run automatically on start.
cdn.yourdomain.com) under the bucket's settings, or enable its public r2.dev URL.CLOUDFLARE_R2_ENDPOINT (https://<account-id>.r2.cloudflarestorage.com), CLOUDFLARE_R2_ACCESS_KEY_ID, CLOUDFLARE_R2_SECRET_ACCESS_KEY, CLOUDFLARE_R2_BUCKET, CLOUDFLARE_R2_PUBLIC_URL (your public domain) and CLOUDFLARE_R2_PREFIX (a folder such as harmonia/).Uploads from the admin panel are stored under <prefix>image/, <prefix>audio/ and <prefix>video/. Limits: images 10 MB, audio 60 MB, video 250 MB. Stream qualities created by ffmpeg go to <prefix>audio-variants/. Without R2 the upload endpoint answers 503 "Media storage is not configured"; you can still paste links to files hosted elsewhere (https only).
A. Service account (purchase verification)
GOOGLE_PLAY_PACKAGE_NAME (your app's package name) and GOOGLE_PLAY_SERVICE_ACCOUNT_JSON (JSON or base64). Until this is done, purchase verification answers 503.B. Real-time developer notifications (recommended) — tell the backend about renewals, cancellations and refunds.
openssl rand -hex 24 and set it as GOOGLE_PLAY_RTDN_TOKEN.play-billing) and add google-play-developer-notifications@system.gserviceaccount.com as Pub/Sub Publisher.https://api.yourdomain.com/api/v1/purchases/google/rtdn?token=<GOOGLE_PLAY_RTDN_TOKEN>GOOGLE_PLAY_RTDN_AUDIENCE to the audience you configured.Without notifications, subscription changes are picked up when the app re-verifies and by an hourly expiry job. Refunds and revocations are always re-checked with Google before Premium is removed.
By default, audio URLs on your R2 domain are permanent. To make them expire, deploy the included worker docs/media-signing-worker.js:
MEDIA_URL_SIGNING_SECRET (a long random value) and the variable PROTECTED_PREFIXES, for example harmonia/audio/,harmonia/audio-variants/.cdn.yourdomain.com/* (your R2 public domain).MEDIA_URL_SIGNING_SECRET in the backend .env (and optionally MEDIA_URL_TTL_SEC, default 43200 = 12 hours) and restart.Images and videos stay public; protected audio needs a valid signature, which the backend adds automatically.
| Problem | Fix |
|---|---|
| Server stops at start with a configuration error | Read the message; it names the variable to change (see the production safety check above). |
ECONNREFUSED or "password authentication failed" | Check DB_* values and that PostgreSQL is running and accepts connections. |
| "Server cannot verify tokens: FIREBASE_SERVICE_ACCOUNT_JSON is not configured" | Set the Firebase service account (step 3). It must belong to the same Firebase project as the app. |
| "database … does not exist" | Create it first (step 2), and check DB_NAME. |
Configuration error about DB_PASS on a local PostgreSQL without a password | DB_PASS may not be empty. Set any placeholder text; PostgreSQL ignores it when it trusts local connections. |
| "FFmpeg not found" warning | Install FFmpeg (step 1) or set FFMPEG_PATH, then restart. |
| Port 7001 already in use | Change PORT in .env and in your nginx proxy_pass. |
| Uploads fail with 413 | Raise client_max_body_size in nginx. |
| Uploads fail with 503 | R2 variables are missing or wrong (step 6). |
| Purchase verification fails with 503 | Google Play service account not configured (step 7). |
The admin panel lives in Admin-Panel/. It is a Next.js 15 web app that talks to the backend.
cd Admin-Panel
npm ci
cp .env.example .env.local
Set NEXT_PUBLIC_API_URL to your backend including /api/v1:
NEXT_PUBLIC_API_URL=http://localhost:7001/api/v1 # local
NEXT_PUBLIC_API_URL=https://api.yourdomain.com/api/v1 # production
NEXT_PUBLIC_API_URL is built into the browser code. Never put a secret there, and run npm run build again after changing it.
npm run dev # http://localhost:3000
Start the backend first. A successful start looks like this (real output):
http://localhost:3000.http://localhost:7001/api/v1.Open http://localhost:3000. You should see the sign-in page:
ADMIN_EMAIL and ADMIN_PASSWORD.After signing in you land on the Dashboard (see the admin panel tour below). On a new installation the numbers are 0 until you add songs. If sign-in fails with a network or CORS error, check that the backend is running and that CORS_ORIGINS in the backend's .env contains http://localhost:3000.
npm ci
npm run build
# edit cwd in prod.config.js, then:
pm2 start prod.config.js # runs "next start" on port 3000
Put nginx in front with HTTPS:
server {
server_name admin.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Then add https://admin.yourdomain.com to the backend's CORS_ORIGINS and restart the backend. Vercel also works: import the project, set NEXT_PUBLIC_API_URL in the project settings and deploy.
| Role | Can do |
|---|---|
| viewer | Read the dashboard and content |
| editor | Everything a viewer can, plus create, edit and delete content, banners, notifications, plans, ads and app config |
| super_admin | Everything, plus staff accounts, the audit log, granting or revoking Premium and erasing app users |
| Page | What it does |
|---|---|
| Dashboard | Users, active listeners, Premium users, plays, likes, downloads, catalogue totals, plays per day, top songs |
| Search analytics | Top and zero-result searches, clicks; pin or hide trending searches |
| Home sections | Create, order and schedule Home sections: layout, content source (manual, new releases, trending, for you, recently played) and items |
| Banners | Home banner carousel linking to an album, playlist, artist, Premium or a URL |
| Songs | Upload audio and cover, lyrics, credits and licence, Premium flag, stream qualities (64/128/256 kbps), retry failed conversions, bulk upload from Excel (.xlsx) or CSV |
| Albums / Playlists / Artists | Create and edit with track pickers, Premium flags and bulk upload from Excel (.xlsx) or CSV |
| Moods & genres | Categories shown on Explore and in onboarding |
| Shorts | Short music clips |
| App users | Search, view details, ban, grant or revoke Premium, erase a user |
| Subscriptions | Subscribers, estimated monthly revenue, plan mix, CSV export |
| Premium plans | Plans with Google Play IDs, and the Premium screen texts |
| Ads | AdMob unit IDs per platform and interstitial frequency |
| App config | Minimum and latest app version (force update), maintenance mode, store links, privacy policy, terms, support e-mail |
| Push notifications | Compose, target (all, Premium, free or one user), schedule, test and send |
| Admin users / Audit log | Staff accounts and a log of every change (super admin only) |
What each main page looks like, so you know your way around before you add content. Screenshots are from the demo with sample music.
| Problem | Fix |
|---|---|
| Every request returns 404 | NEXT_PUBLIC_API_URL is missing /api/v1 |
| Login fails with a network or CORS error | Add the panel's address to the backend's CORS_ORIGINS and restart the backend |
A changed .env.local value has no effect | Run npm run build again |
| Port 3000 is in use | Change -p 3000 in the start script in package.json |
| Forgot the admin password | Another super admin can set a new password through the API (PATCH /api/v1/admin/users/:id with password). If you are the only admin, delete your row from admin_users and restart the backend with new ADMIN_EMAIL/ADMIN_PASSWORD |
All endpoints live under https://api.yourdomain.com/api/v1. The full, always-current reference is the Swagger UI at /api on a development server. This section covers the conventions and the most important calls.
multipart/form-data with a field named file).RATE_LIMIT_PER_MIN, RATE_LIMIT_IP_PER_MIN); admin login and password changes allow 10 per minute. Over the limit you get 429.Successful responses are wrapped like this:
{
"data": { ... }, // the actual result
"isArray": false,
"path": "/api/v1/songs/3f2c...",
"duration": "12ms",
"method": "GET"
}
Some older endpoints put their own { "status", "message", "data" } object inside data; the app and admin panel handle both forms.
// 400 validation error
{ "statusCode": 400, "timestamp": "2026-01-15T10:00:00.000Z", "path": "/api/v1/songs/create",
"message": ["thumbnail must be an https URL", "title should not be empty"], "error": "Bad Request" }
// 404
{ "statusCode": 404, "timestamp": "...", "path": "/api/v1/songs/...", "message": "Song not found", "error": "Not Found" }
// 401 when a device was signed out remotely
{ "statusCode": 401, "timestamp": "...", "path": "...", "code": "DEVICE_REVOKED",
"message": "This device was signed out from another device" }
| Code | Meaning |
|---|---|
| 200 / 201 / 202 / 204 | Success (202 for play and search events, 204 for deletes without a body) |
| 400 | Invalid input |
| 401 | Missing, invalid or expired token; device signed out |
| 403 | Admin role not allowed, or the app user is banned |
| 404 | Not found |
| 409 | Conflict, e.g. an admin with this e-mail exists, or a purchase belongs to another account |
| 429 | Too many requests |
| 500 | Unexpected server error ("Internal server error"; details are only in the server log) |
| 503 | A required service is not configured (R2, Google Play, RTDN token) |
App users sign in with Firebase in the app. Every request sends the Firebase ID token and the install's device ID:
Authorization: Bearer <Firebase ID token>
x-device-id: <stable id per app install>
There is no separate register endpoint: the user record is created on the first authenticated request. Many read endpoints also work without a token (guest mode).
Admins log in with e-mail and password:
POST /api/v1/admin/auth/login
{ "email": "admin@yourdomain.com", "password": "your-password" }
// response (inside "data")
{ "access_token": "<JWT>", "expires_in": "24h",
"admin": { "id": "uuid", "email": "admin@yourdomain.com", "name": "Super Admin",
"role": "super_admin", "is_active": true, "last_login_at": "..." } }
Send the token as Authorization: Bearer <JWT> on admin requests. Other admin auth calls: GET /admin/auth/me and POST /admin/auth/change-password with { "current_password", "new_password" }.
GET /api/v1/home?page=1&limit=10 (token optional)
{ "status": true, "message": "Home data fetched successfully",
"pagination": { "page": 1, "limit": 10, "total": 7, "totalPages": 1, "isLastPage": true },
"data": [
{ "title": "Banners", "type": "banner_carousel", "id": "banners",
"items": [ { "id": "uuid", "title": "...", "image": "https://...", "redirectType": "playlist", "target_id": "uuid" } ] },
{ "title": "Top hits", "type": "song_horizontal_cards", "id": "uuid",
"items": [ { "id": "uuid", "title": "...", "image": "...", "redirectType": "song", "is_premium": true,
"songs": [ { "id": "uuid", "title": "...", "artist": "...", "duration": 180,
"file_path": null, "audio_variants": null, "is_premium": true, "is_locked": true } ] } ] }
] }
Section types: banner_carousel, play_list_horizontal_cards, full_playlist_songs_horizontal_cards, song_horizontal_cards, artist_songs_horizontal_cards, horizontal_artist_list, shorts_horizontal_cards, recent_played_horizontal_cards, mood_chips_horizontal, genre_horizontal_cards.
GET /api/v1/songs/{id} (token optional)
{ "id": "uuid", "title": "...", "artist": "Artist name", "featured": "Feat A",
"thumbnail": "https://cdn.../cover.jpg", "duration": 215,
"file_path": "https://cdn.../harmonia/audio/2026-10/x.mp3?exp=...&sig=...",
"audio_qualities": ["low", "normal", "high"],
"audio_variants": { "low": "https://...m4a", "normal": "...", "high": "..." },
"variants_status": "ready",
"lyrics": [ { "time": 0, "text": "First line" } ],
"is_premium": false, "is_locked": false }
When a song is Premium and the caller is not, the response has "is_locked": true, "file_path": null and "audio_variants": null.
GET /api/v1/search?q=love&limit=20&type=song
[ { "id": "uuid", "name": "Love Song", "type": "SONG", "image": "...", "artist": "...",
"artist_id": "uuid", "duration": 200, "is_premium": false } ]
Also: GET /search/page?q=&offset=&limit= returns { "items": [...], "next_offset": 20 }, GET /search/suggest?q= and GET /search/trending.
POST /api/v1/plays (token optional, x-device-id recommended) → 202
{ "song_id": "uuid", "source_type": "playlist", "source_id": "uuid",
"position_ms": 45000, "duration_ms": 215000, "completed": false, "platform": "android" }
A play counts after 30 seconds or when the song finishes. POST /plays/batch accepts { "events": [...] } with up to 200 events.
POST /api/v1/purchases/google/verify (token required)
{ "purchase_token": "<from Google Play>", "product_id": "premium_single", "backend_plan_id": 1 }
// response
{ "is_premium": true, "status": "active", "platform": "android", "plan_id": 1,
"product_id": "premium_single", "base_plan_id": "monthly",
"expires_at": "2026-11-16T10:00:00.000Z", "auto_renew": true }
GET /me/entitlement returns the same object for the signed-in user.
POST /api/v1/songs/create (admin token, editor or super_admin)
{ "title": "My Song", "thumbnail": "https://cdn.yourdomain.com/.../cover.jpg",
"file_path": "https://cdn.yourdomain.com/.../audio.mp3", "duration": 215,
"artist_id": "uuid", "is_premium": false, "tags": "pop,summer",
"lyric": [ { "time": 0, "text": "First line" } ],
"audio_qualities": ["low", "normal", "high"],
"credit": "Artist - Title", "license": "CC BY 4.0", "license_url": "https://...", "source_url": "https://..." }
title, thumbnail and file_path are required; thumbnail and file_path must be https URLs. Upload files first with POST /admin/uploads?kind=image|audio|video (returns { url, key, kind, mime, size }).
Every path below is relative to https://your-api/api/v1. The full, always-current list with request bodies is in Swagger at /api when NODE_ENV is not production.
Access: public (no token), optional (works for guests, richer when signed in), user (Firebase token), admin (any admin role), editor (editor or super admin), super (super admin).
| Group | Method and path (prefix /api/v1) | Access |
|---|---|---|
| Health | GET / | public |
| Home | GET /home | optional |
| Songs | GET /songs/:idGET /songs/:id/radioGET /credits | optional / public |
| Songs (admin) | POST /songs/createGET /songs/listGET /songs/optionsPATCH /songs/update/:idDELETE /songs/:idPOST /songs/qualitiesPOST /songs/qualities/retry-failedPOST /songs/bulk-upload | admin read, editor write |
| Artists | GET /artistsGET /artists/:idPOST /artistsPATCH /artists/:idDELETE /artists/:idPOST /artists/bulk-upload | public / optional; editor write |
| Albums | GET /albumGET /album/:idGET /album/by-category/:categoryIdPOST /albumPUT /album/:idDELETE /album/:idPOST /album/bulk-upload | admin read, editor write |
| Catalogue playlists | GET /playlistGET /playlist/:idGET /playlist/by-category/:categoryIdPOST /playlistPUT /playlist/:idDELETE /playlist/:id | admin read, editor write |
| Collections | GET /category/collections/:categoryIdGET /category/collections/album-playlist-by-id/:idGET /collections/:id/related | optional |
| Categories (home sections, moods, genres) | GET /categoryGET /category/:idPOST /categoryPUT /category/:idDELETE /category/:idPUT /category/reorderGET /category/admin/sectionsGET /category/admin/:id/contentsPUT /category/admin/:id/contents | public read; admin / editor |
| Shorts | GET /shorts/get-all-with-includesGET /shorts/get-allGET /shorts/:idPOST /shorts/:id/likeDELETE /shorts/:id/likePOST /shorts/createPUT /shorts/:idDELETE /shorts/:id | optional / user; editor write |
| User playlists | GET /playlists/meGET /playlists/containingPOST /playlistsGET /playlists/:idPATCH /playlists/:idDELETE /playlists/:idPOST /playlists/:id/songsDELETE /playlists/:id/songs/:songIdPUT /playlists/:id/songs/order | user |
| Library | GET /library/itemsGET /library/liked-songsGET /library/liked-songs/statsPOST /library/liked-songs/:songIdDELETE /library/liked-songs/:songIdGET /library/saved/idsPOST /library/savedDELETE /library/saved/:kind/:entityId | user |
| Plays and history | POST /playsPOST /plays/batchGET /me/continue-listeningDELETE /me/continue-listening/:songIdGET /me/historyDELETE /me/history | optional / user |
| Search | GET /searchGET /search/pageGET /search/suggestGET /search/trendingPOST /search/events | public / optional |
| Profile | GET /users/mePATCH /users/mePOST /users/me/photoDELETE /users/me | user |
| Music taste | GET /onboarding/optionsGET /me/preferencesPUT /me/preferences | public / user |
| Devices | POST /me/devicesGET /me/devicesDELETE /me/devices/session/:idDELETE /me/devices/:token | optional / user |
| Premium and ads | GET /premium/config?platform=android|iosGET /ads/configGET /me/entitlementPOST /purchases/google/verifyPOST /purchases/google/rtdn | public / user / RTDN secret token |
| Health | GET /health | public; returns status: ok when the API and database are up (503 otherwise). Use it for uptime monitors and load balancers |
| App config | GET /app/config?platform=android|iosGET /admin/app-configPUT /admin/app-config | public; admin read, editor write |
| Admin: sign-in | POST /admin/auth/loginGET /admin/auth/mePOST /admin/auth/change-password | public / admin |
| Admin: stats | GET /admin/stats/overviewGET /admin/stats/top-songsGET /admin/stats/daily-playsGET /admin/stats/search | admin |
| Admin: banners | GET /admin/bannersPOST /admin/bannersPATCH /admin/banners/:idPUT /admin/banners/reorderDELETE /admin/banners/:id | admin read, editor write |
| Admin: uploads | POST /admin/uploads?kind=image|audio|video | editor |
| Admin: push notifications | GET /admin/notificationsGET /admin/notifications/audienceGET /admin/notifications/:idPOST /admin/notificationsPATCH /admin/notifications/:idPOST /admin/notifications/testPOST /admin/notifications/:id/sendPOST /admin/notifications/:id/cancelPOST /admin/notifications/:id/duplicateDELETE /admin/notifications/:id | admin read, editor write |
| Admin: trending searches | GET /admin/search/trendingPOST /admin/search/trending/overridesDELETE /admin/search/trending/overrides/:id | admin read, editor write |
| Admin: Premium and ads | GET /admin/premium/plansPOST /admin/premium/plansPATCH /admin/premium/plans/:idDELETE /admin/premium/plans/:idGET /admin/premium/page-settingsPUT /admin/premium/page-settingsGET /admin/ads/settingsPUT /admin/ads/settings | admin read, editor write |
| Admin: subscriptions | GET /admin/subscriptionsGET /admin/subscriptions/statsGET /admin/subscriptions/export.csv | admin |
| Admin: app users | GET /admin/app-usersGET /admin/app-users/:idPATCH /admin/app-users/:id/banPOST /admin/app-users/:id/grant-premiumPOST /admin/app-users/:id/revoke-premiumDELETE /admin/app-users/:id | admin read; super admin for Premium, ban and erase |
| Admin: staff and audit | GET /admin/usersPOST /admin/usersPATCH /admin/users/:idDELETE /admin/users/:idGET /admin/audit-logsGET /admin/audit-logs/actions | super admin |
PostgreSQL, managed with TypeORM. Tables are defined in src/modules/*/entities/*.entity.ts; production changes ship as migrations in db/migrations/ and run automatically when the server starts.
id (uuid), createdAt, updatedAt and isDeleted (soft delete).varchar(128)).jsonb.pg_trgm extension, created automatically at start when the database user is allowed to; otherwise search falls back to simple matching.| Area | Tables |
|---|---|
| Catalogue | songs, song_artists, artists, album, album_songs, album_artists, album_categories, playlist, playlist_songs, playlist_categories, shorts, shorts_categories, shorts_likes, categories, category_artists, category_songs, banners |
| Users and library | users, user_liked_songs, user_saved_library_items, user_preferences, user_devices |
| Listening | play_events, song_stats, user_listening, search_events, search_trending_overrides |
| Billing and ads | subscription_plans, user_subscriptions, premium_page_settings, ads_settings |
| Operations | app_config, push_notifications, admin_users, admin_audit_logs, migrations |
| Table | Important columns |
|---|---|
songs | title, thumbnail, file_path, duration (seconds), lyric (jsonb [{time,text}]), artist_id, is_premium, audio_qualities, audio_variants (jsonb), variants_status, loudness_lufs, credit, license, license_url, source_url |
artists | name, bio, image_url, dominant_color |
album | name, description, cover_url, is_premium, duration |
playlist | name, cover_url, owner_id (Firebase UID; empty for catalogue playlists), creator_type, is_public, is_premium |
categories | Home sections, moods and genres: name, kind, type (layout), source, item_order, position, is_active, starts_at, ends_at, show_on_explore |
users | id (Firebase UID), email, name, photo_url, provider, is_banned, last_seen_at |
user_subscriptions | user_id, platform, purchase_token (unique), plan_id, product_id, status, expires_at, auto_renew |
subscription_plans | name, platforms, payment_providers, google_product_id, google_base_plan_id, google_offer_id, has_trial, trial_days, features, is_active, sort_order |
play_events | One row per listening session: user_id (empty for guests), song_id, source_type, position_ms, duration_ms, completed, counted |
user_devices | token (FCM), user_id, platform, device_id, device_name, push_enabled, revoked_at |
admin_users | email (unique), password_hash (bcrypt, never returned), name, role, is_active |
app_config | Single row: min/latest versions per platform, force-update and maintenance messages, store URLs, privacy and terms URLs, support e-mail |
| Enum | Values |
|---|---|
| Admin role | super_admin, editor, viewer |
| Category kind | section, mood, genre |
| Category source | manual, new_releases, trending, for_you, recently_played |
| Banner / notification target | none (notifications only), album, playlist, artist, premium, url |
| Notification audience | all, premium, free, user |
| Notification status | draft, scheduled, sending, sent, failed |
| Library saved kind | playlist, album, artist, download |
| Play source | album, playlist, artist, shorts, search, library, autoplay, other |
| Subscription status | pending, active, canceled, in_grace_period, on_hold, paused, expired, revoked, unknown (Premium = active, canceled or in_grace_period with a future expiry) |
| Subscription platform | android, ios, manual |
| Payment provider | google_play, app_store (app_store is rejected while App Store purchases are switched off, see section 11) |
| Audio variant status | none, pending, processing, ready, failed |
npm run migration:show # list applied and pending migrations
npm run migration:run # apply pending migrations manually
npm run migration:revert # undo the last migration
A fresh production database is created entirely by the migrations on first start: 1793149601049-InitialSchema creates every table, index and foreign key of version 1.0.0. This has been tested on an empty PostgreSQL database with NODE_ENV=production.
NODE_ENV=development) creates tables directly from the code. If you point a production server at a database that development already filled, the first migration stops with relation "users" already exists. Either create a new empty database for production (recommended), or, if the development schema is already up to date and you want to keep its data, mark the first migration as applied once:
CREATE TABLE IF NOT EXISTS migrations (id serial PRIMARY KEY, "timestamp" bigint NOT NULL, name varchar NOT NULL);
INSERT INTO migrations ("timestamp", name) VALUES (1793149601049, 'InitialSchema1793149601049');
pg_dump -Fc -d harmonia -f harmonia-$(date +%F).dump # back up
pg_restore -d harmonia --clean harmonia-2026-01-15.dump # restore
Schedule a daily backup (for example with cron) and keep copies off the server. Media files are in R2, so back up the bucket separately if needed.
flutter doctor and fix every item it reports.flutter clean then flutter pub get, and try again.| Error or problem | Fix |
|---|---|
| "Unsupported class file major version" / Gradle fails to start | Your JDK is too new. Use JDK 17 or 21: flutter config --jdk-dir "<Android Studio>/jbr" |
| "Your project's Gradle / Android Gradle Plugin / Kotlin version is lower than Flutter's minimum" | Use Flutter 3.47.x as tested. On a newer Flutter, raise the versions in android/settings.gradle.kts and android/gradle/wrapper/gradle-wrapper.properties to the values in the message |
| "No matching client found for package name" | google-services.json does not match applicationId. Download it again for your package name |
| Google sign-in fails on Android (error 10 / DEVELOPER_ERROR) | Add the SHA-1 and SHA-256 of the key you are running with (debug, upload or Play app signing) in Firebase, then download google-services.json again |
| Google sign-in crashes on iOS | Add the REVERSED_CLIENT_ID URL scheme (section 5) |
| Nothing loads; every screen is empty | Check apiHost (section 8). On an Android emulator use http://10.0.2.2:7001, not localhost |
| Sign-in works but API calls return 401 | The backend's Firebase service account belongs to a different Firebase project than the app |
| Songs show a lock | They are Premium (or inside a Premium album). Sign in with a Premium account, or grant Premium from the admin panel |
CocoaPods errors (pod install fails) | cd ios && pod repo update && pod install. If it still fails, delete ios/Pods and ios/Podfile.lock and run pod install again |
| iOS "No signing certificate" / "requires a development team" | Select your Team in Xcode (Runner > Signing & Capabilities) |
| iOS minimum version errors from pods | Set the Runner target's Minimum Deployments to 15.0 to match the Podfile |
| Push notifications do not arrive on iOS | Add the Push Notifications capability and upload an APNs key in Firebase (section 5). Test on a real device |
| Premium plans do not appear | The app is not installed from Google Play (use a testing track), the plan IDs do not match Play Console, or the plan is inactive in the admin panel |
| Ads show "Test Ad" | Expected with the default test IDs. Set your own App ID (section 10) and unit IDs (admin panel > Ads) |
See the troubleshooting tables in section 13 and section 14.
| Question | Answer |
|---|---|
| Is music included? | No. The demo uses Creative Commons tracks for demonstration only. You must own or license every track you publish. |
| Can I sell Premium on iOS? | Not in this version: Apple In-App Purchase verification is not included. iOS listeners use the free tier, and you can grant Premium manually from the admin panel. |
| Can I add another payment gateway? | Yes, as custom development. Premium is stored in user_subscriptions, so a new provider only needs to create rows there after a verified payment. |
| Do I need Cloudflare R2? | Yes for uploads from the admin panel. You can also paste https links to files hosted elsewhere. |
| Can I host the backend and admin panel on one server? | Yes. Use two nginx server blocks (for example api. and admin. subdomains). |
| Are any files minified or obfuscated? | No. All three projects ship as readable source code. Third-party libraries are installed from pubspec.yaml and package.json. |
Questions before or after purchase? Please use the Support tab on the CodeCanyon item page. All support is handled through the official Envato item support system.
We answer through the item's Support tab. Support covers bugs and questions about the included features; customisation and installation on your server are not part of item support.
A complete first message lets us answer in one reply instead of several:
| Information | How to get it |
|---|---|
| Your purchase code | CodeCanyon > Downloads > Harmonia > License certificate and purchase code |
| Which part | Flutter app, backend or admin panel, and the documentation section you were following |
| Platform and versions | Android or iOS and the device or emulator; your computer's OS; for the app the output of flutter doctor -v; for the server the output of node -v and psql --version and the server's OS |
| The exact error message | Copy and paste the text, not a summary. A screenshot of the full screen or terminal helps too |
| Log output | App: the red error lines from flutter run. Backend: pm2 logs harmonia-api --lines 100. Admin panel: pm2 logs harmonia-admin --lines 100 and the browser console (F12 > Console) |
| Steps to reproduce | What you did, what you expected, and what happened instead |
JWT_SECRET, service-account JSON, API keys and keystore passwords from anything you paste. We will never ask for them.
AI disclosure: AI tools were used to assist with parts of this item (source code, documentation and preview graphics). All AI-assisted work was reviewed, edited and tested by the author.
When a new version of Harmonia is released, download it from your CodeCanyon Downloads page and read its changelog first. Your songs, users, playlists, subscriptions and settings live in your PostgreSQL database and your R2 bucket, not in the code, so updating the code does not delete them.
pg_dump -Fc -d harmonia -f harmonia-before-upgrade.dump| Project | Keep your copy of |
|---|---|
| Backend | .env, prod.config.js if you changed the PM2 name or port, docs/media-signing-worker.js if you deployed a changed copy |
| Admin panel | .env.local (or .env), prod.config.js if you changed it |
| Flutter app | lib/firebase_options.dart, android/app/google-services.json, ios/Runner/GoogleService-Info.plist, android/key.properties and your keystore, lib/core/network/constants/api_endpoint.dart, app icons, strings.xml, Info.plist changes, AdMob app IDs |
.env.example with your .env and add any new variables.If you edited source files in the backend or admin panel (not only settings), do not copy the new version straight over them. The safest way is Git:
# one time, before your first customisation
cd Backend # or Admin-Panel
git init && git add -A && git commit -m "Harmonia 1.0.0 original"
# ...make your changes and commit them as usual...
# when a new version arrives
git checkout -b upgrade
# copy the new package files over the folder (keep the files listed above)
git add -A && git commit -m "Harmonia 1.x.x original"
git checkout main && git merge upgrade # Git combines both; fix any conflicts it lists
Files buyers commonly customise, and what to check after merging:
| Project | Commonly customised | After upgrading |
|---|---|---|
| Backend | src/modules/subscriptions/premium.defaults.ts (default plans, ad settings), src/modules/*/ services you extended, docs/media-signing-worker.js | Run npm run build and npm test; new migrations run on start. Never edit an existing file in db/migrations/; add a new migration instead (npm run migration:generate) |
| Admin panel | tailwind.config.ts (brand colours), app/layout.tsx (page title), components/sidebar.tsx (logo and name), pages in app/(admin)/ | Run npm run lint and npm run build |
Without Git, compare your old folder and the new one with a diff tool (VS Code: right-click two files > Compare Selected) and re-apply your edits to the new files.
cd Backend
# copy the new files over your old folder, keeping your .env
npm ci
npm run build
pm2 restart harmonia-api
NODE_ENV=production) the server runs every new migration in db/migrations/ when it starts. Each migration runs once; TypeORM records it in the migrations table.npm run migration:show (applied ones are marked [X]). Run pending ones by hand with npm run migration:run.npm run migration:revert undoes the last migration, or restore the backup with pg_restore -d harmonia --clean harmonia-before-upgrade.dump, then go back to the old code.NODE_ENV=development) update their tables from the code automatically and need no migration step.cd Admin-Panel
# copy the new files over your old folder, keeping .env.local
npm ci
npm run build
pm2 restart harmonia-admin
The admin panel stores nothing itself; all data comes from the backend. Upgrade the backend first.
lib/, pubspec.yaml and assets/ over your project, then restore the files you customised (list above). If you use Git, commit first and review the diff.flutter clean
flutter pub get
cd ios && pod install && cd ..
flutter runversion: in pubspec.yaml (for example 1.0.1+2) and publish the update as in section 12. Listeners keep their downloads, likes and playlists because these are stored on the server and on their phone.
5. Social Sign-In and Push Notifications
The login screen shows Google, Facebook and Apple buttons on both platforms. Configure the ones you want; to hide a button, remove it from
lib/feature/auth/presentation/widgets/social_media_buttons.dart.Google
cd android && ./gradlew signingReport. Downloadgoogle-services.jsonagain afterwards.GoogleService-Info.plistagain (it now containsCLIENT_IDandREVERSED_CLIENT_ID). In Xcode, open Runner > Info > URL Types, add a URL type and paste theREVERSED_CLIENT_IDvalue as the URL scheme.Facebook
YOUR_FACEBOOK_APP_IDandYOUR_FACEBOOK_CLIENT_TOKEN. Facebook sign-in stays disabled until you replace them with your own values. If you do not want Facebook sign-in, remove the button (see the top of this section).android/app/src/main/res/values/strings.xmlreplaceYOUR_FACEBOOK_APP_IDandYOUR_FACEBOOK_CLIENT_TOKEN(Meta app > Settings > Basic / Advanced). Add your package name and key hashes in the Meta app's Android settings.ios/Runner/Info.plist:FacebookAppID,FacebookClientToken,FacebookDisplayName, a URL schemefb<APP_ID>underCFBundleURLTypes, andLSApplicationQueriesSchemeswithfbapiandfb-messenger-share-api. See the flutter_facebook_auth iOS guide.Apple
Runner.entitlementsfor you.lib/feature/auth/data/datasources/auth_remote_data_source.dart(clientIdandredirectUri). Follow the sign_in_with_apple Android setup. If you do not need Apple sign-in on Android, hide the button there instead.Push notifications
google-services.jsonis in place.Info.plist). Create an APNs key in your Apple Developer account and upload it in Firebase: Project settings > Cloud Messaging > Apple app configuration.harmonia. Give your deployment its own topic name and set it in both places:broadcastTopicinlib/core/service/push_notification/push_notification_service.dartFCM_BROADCAST_TOPICin.env