bash

set -e
cp '/home/sebas/work/projects/ballbox/public/machine-assets/2601070188/VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4' '/home/sebas/work/projects/ballbox/public/machine-assets/2601070188/ImageScreen/See You Again (Cover)--音悦Tai.mp4'
rm -f '/home/sebas/work/projects/ballbox/public/machine-assets/2601070188/ImageScreen/流程图.jpg'
find '/home/sebas/work/projects/ballbox/public/machine-assets/2601070188' -maxdepth 2 -type f -printf '%P\n' | sort


ImageScreen/See You Again (Cover)--音悦Tai.mp4
VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4


edit

        val manifest = manifestPayload.getJSONObject("manifest")
        val restartPolicy = manifest.optString("restartPolicy", "app")
        val files = manifest.getJSONArray("files")
        var appliedCount = 0

        for (index in 0 until files.length()) {

        val manifest = manifestPayload.getJSONObject("manifest")
        val restartPolicy = manifest.optString("restartPolicy", "app")
        val files = manifest.getJSONArray("files")
        val managedTargets = mutableSetOf<String>()
        var appliedCount = 0

        for (index in 0 until files.length()) {

            val target = file.getString("target")
            val url = file.getString("url")
            val expectedSha = file.getString("sha256")
            val outputFile = File(targetRoot, target)

            val target = file.getString("target")
            managedTargets += normalizeRelativePath(target)
            val url = file.getString("url")
            val expectedSha = file.getString("sha256")
            val outputFile = File(targetRoot, target)

        return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount)
    }

        val prunedCount = pruneManagedDirectories(targetRoot, managedTargets, logger)
        return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount, prunedCount = prunedCount)
    }

    private fun pruneManagedDirectories(targetRoot: String, managedTargets: Set<String>, logger: (String) -> Unit): Int {
        val managedDirs = managedTargets
            .mapNotNull { target -> target.substringBeforeLast('/', missingDelimiterValue = "").takeIf { it.isNotBlank() } }
            .toSortedSet()

        var prunedCount = 0

        for (dir in managedDirs) {
            val dirFile = File(targetRoot, dir)
            if (!dirFile.exists() || !dirFile.isDirectory) {
                logger("PRUNE SKIP missing dir ${dirFile.absolutePath}")
                continue
            }

            logger("PRUNE DIR ${dirFile.absolutePath}")
            dirFile.listFiles()?.forEach { child ->
                if (!child.isFile) return@forEach
                val relativePath = normalizeRelativePath(child.absolutePath.removePrefix(File(targetRoot).absolutePath).trimStart('/'))
                if (relativePath in managedTargets) return@forEach
                logger("PRUNE DELETE ${child.absolutePath}")
                if (!child.delete()) {
                    throw IllegalStateException("prune delete failed for ${child.absolutePath}")
                }
                prunedCount += 1
            }
        }

        logger("PRUNE DONE $prunedCount files")
        return prunedCount
    }

    private fun normalizeRelativePath(value: String): String = value.replace('\\', '/').trimStart('/')

    private fun sha256(file: File): String {

data class SyncResult(
    val restartPolicy: String,
    val appliedCount: Int,
)

data class SyncResult(
    val restartPolicy: String,
    val appliedCount: Int,
    val prunedCount: Int,
)


Successfully replaced 5 block(s) in /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MachineAdsSyncEngine.kt.

     ...
  20
  21         val manifest = manifestPayload.getJSONObject("manifest")
  22         val restartPolicy = manifest.optString("restartPolicy", "app")
  23         val files = manifest.getJSONArray("files")
+ 24         val managedTargets = mutableSetOf<String>()
  24         var appliedCount = 0
  25
  26         for (index in 0 until files.length()) {
  27             val file = files.getJSONObject(index)
  28             val target = file.getString("target")
+ 30             managedTargets += normalizeRelativePath(target)
  29             val url = file.getString("url")
  30             val expectedSha = file.getString("sha256")
  31             val outputFile = File(targetRoot, target)
  32             logger("TARGET $target")
     ...
  73             appliedCount += 1
  74             logger("WROTE $target (${bytes.size} bytes)")
  75         }
  76
- 77         return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount)
+ 79         val prunedCount = pruneManagedDirectories(targetRoot, managedTargets, logger)
+ 80         return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount, prunedCount = prunedCount)
  78     }
  79
  80     private fun fetchJson(url: String): JSONObject {
  81         val text = fetchText(url)
     ...
 130         }
 131         return connection
 132     }
 133
+137     private fun pruneManagedDirectories(targetRoot: String, managedTargets: Set<String>, logger: (String) -> Unit): Int {
+138         val managedDirs = managedTargets
+139             .mapNotNull { target -> target.substringBeforeLast('/', missingDelimiterValue = "").takeIf { it.isNotBlank() } }
+140             .toSortedSet()
+141
+142         var prunedCount = 0
+143
+144         for (dir in managedDirs) {
+145             val dirFile = File(targetRoot, dir)
+146             if (!dirFile.exists() || !dirFile.isDirectory) {
+147                 logger("PRUNE SKIP missing dir ${dirFile.absolutePath}")
+148                 continue
+149             }
+150
+151             logger("PRUNE DIR ${dirFile.absolutePath}")
+152             dirFile.listFiles()?.forEach { child ->
+153                 if (!child.isFile) return@forEach
+154                 val relativePath = normalizeRelativePath(child.absolutePath.removePrefix(File(targetRoot).absolutePath).trimStart('/'))
+155                 if (relativePath in managedTargets) return@forEach
+156                 logger("PRUNE DELETE ${child.absolutePath}")
+157                 if (!child.delete()) {
+158                     throw IllegalStateException("prune delete failed for ${child.absolutePath}")
+159                 }
+160                 prunedCount += 1
+161             }
+162         }
+163
+164         logger("PRUNE DONE $prunedCount files")
+165         return prunedCount
+166     }
+167
+168     private fun normalizeRelativePath(value: String): String = value.replace('\\', '/').trimStart('/')
+169
 134     private fun sha256(file: File): String {
 135         val digest = MessageDigest.getInstance("SHA-256")
 136         FileInputStream(file).use { input ->
 137             val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
     ...
 152
 153 data class SyncResult(
 154     val restartPolicy: String,
 155     val appliedCount: Int,
+192     val prunedCount: Int,
 156 )

--- /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MachineAdsSyncEngine.kt
+++ /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MachineAdsSyncEngine.kt
@@ -20,13 +20,15 @@

         val manifest = manifestPayload.getJSONObject("manifest")
         val restartPolicy = manifest.optString("restartPolicy", "app")
         val files = manifest.getJSONArray("files")
+        val managedTargets = mutableSetOf<String>()
         var appliedCount = 0

         for (index in 0 until files.length()) {
             val file = files.getJSONObject(index)
             val target = file.getString("target")
+            managedTargets += normalizeRelativePath(target)
             val url = file.getString("url")
             val expectedSha = file.getString("sha256")
             val outputFile = File(targetRoot, target)
             logger("TARGET $target")
@@ -73,9 +75,10 @@
             appliedCount += 1
             logger("WROTE $target (${bytes.size} bytes)")
         }

-        return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount)
+        val prunedCount = pruneManagedDirectories(targetRoot, managedTargets, logger)
+        return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount, prunedCount = prunedCount)
     }

     private fun fetchJson(url: String): JSONObject {
         val text = fetchText(url)
@@ -130,8 +133,41 @@
         }
         return connection
     }

+    private fun pruneManagedDirectories(targetRoot: String, managedTargets: Set<String>, logger: (String) -> Unit): Int {
+        val managedDirs = managedTargets
+            .mapNotNull { target -> target.substringBeforeLast('/', missingDelimiterValue = "").takeIf { it.isNotBlank() } }
+            .toSortedSet()
+
+        var prunedCount = 0
+
+        for (dir in managedDirs) {
+            val dirFile = File(targetRoot, dir)
+            if (!dirFile.exists() || !dirFile.isDirectory) {
+                logger("PRUNE SKIP missing dir ${dirFile.absolutePath}")
+                continue
+            }
+
+            logger("PRUNE DIR ${dirFile.absolutePath}")
+            dirFile.listFiles()?.forEach { child ->
+                if (!child.isFile) return@forEach
+                val relativePath = normalizeRelativePath(child.absolutePath.removePrefix(File(targetRoot).absolutePath).trimStart('/'))
+                if (relativePath in managedTargets) return@forEach
+                logger("PRUNE DELETE ${child.absolutePath}")
+                if (!child.delete()) {
+                    throw IllegalStateException("prune delete failed for ${child.absolutePath}")
+                }
+                prunedCount += 1
+            }
+        }
+
+        logger("PRUNE DONE $prunedCount files")
+        return prunedCount
+    }
+
+    private fun normalizeRelativePath(value: String): String = value.replace('\\', '/').trimStart('/')
+
     private fun sha256(file: File): String {
         val digest = MessageDigest.getInstance("SHA-256")
         FileInputStream(file).use { input ->
             val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
@@ -152,5 +188,6 @@

 data class SyncResult(
     val restartPolicy: String,
     val appliedCount: Int,
+    val prunedCount: Int,
 )


                    binding.statusText.text = "Sync OK · restart policy ${it.restartPolicy}"
                    appendLog("Done. Applied ${it.appliedCount} changed files.")

                    binding.statusText.text = "Sync OK · restart policy ${it.restartPolicy}"
                    appendLog("Done. Applied ${it.appliedCount} changed files. Pruned ${it.prunedCount} stale files.")


Successfully replaced 1 block(s) in /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MainActivity.kt.

     ...
  79             runOnUiThread {
  80                 binding.syncButton.isEnabled = true
  81                 result.onSuccess {
  82                     binding.statusText.text = "Sync OK · restart policy ${it.restartPolicy}"
- 83                     appendLog("Done. Applied ${it.appliedCount} changed files.")
+ 83                     appendLog("Done. Applied ${it.appliedCount} changed files. Pruned ${it.prunedCount} stale files.")
  84                 }.onFailure {
  85                     binding.statusText.text = "Sync failed"
  86                     appendLog("ERROR ${it.javaClass.simpleName}: ${it.message}")
  87                 }
     ...

--- /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MainActivity.kt
+++ /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MainActivity.kt
@@ -79,9 +79,9 @@
             runOnUiThread {
                 binding.syncButton.isEnabled = true
                 result.onSuccess {
                     binding.statusText.text = "Sync OK · restart policy ${it.restartPolicy}"
-                    appendLog("Done. Applied ${it.appliedCount} changed files.")
+                    appendLog("Done. Applied ${it.appliedCount} changed files. Pruned ${it.prunedCount} stale files.")
                 }.onFailure {
                     binding.statusText.text = "Sync failed"
                     appendLog("ERROR ${it.javaClass.simpleName}: ${it.message}")
                 }


        versionCode = 5
        versionName = "0.1.4"

        versionCode = 6
        versionName = "0.1.5"


Successfully replaced 1 block(s) in /home/sebas/work/projects/ballbox-machine-ads-sync/app/build.gradle.kts.

    ...
 10     defaultConfig {
 11         applicationId = "app.ballbox.machineadssync"
 12         minSdk = 25
 13         targetSdk = 34
-14         versionCode = 5
-15         versionName = "0.1.4"
+14         versionCode = 6
+15         versionName = "0.1.5"
 16     }
 17
 18     buildTypes {
 19         release {
    ...

--- /home/sebas/work/projects/ballbox-machine-ads-sync/app/build.gradle.kts
+++ /home/sebas/work/projects/ballbox-machine-ads-sync/app/build.gradle.kts
@@ -10,10 +10,10 @@
     defaultConfig {
         applicationId = "app.ballbox.machineadssync"
         minSdk = 25
         targetSdk = 34
-        versionCode = 5
-        versionName = "0.1.4"
+        versionCode = 6
+        versionName = "0.1.5"
     }

     buildTypes {
         release {


bash

cd /home/sebas/work/projects/ballbox && node -e "import('./lib/machine-ads-manifest.ts').then(async m => { const x = await m.getMachineAdsManifest('2601070188','https://ballbox.app'); console.log(JSON.stringify(x,null,2)); }).catch(e => { console.error(e); process.exit(1); })"

git -C /home/sebas/work/projects/ballbox status --short && echo '---' && git -C /home/sebas/work/projects/ballbox-machine-ads-sync diff --stat


(node:197834) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///home/sebas/work/projects/ballbox/lib/machine-ads-manifest.ts is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /home/sebas/work/projects/ballbox/package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
{
  "machineId": "2601070188",
  "version": "VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4:37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea|ImageScreen/See You Again (Cover)--音悦Tai.mp4:37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea",
  "generatedAt": "2026-06-08T18:41:25.033Z",
  "restartPolicy": "app",
  "files": [
    {
      "target": "VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4",
      "url": "https://ballbox.app/machine-assets/2601070188/VideoAndImageAd/See%20You%20Again%20(Cover)--%E9%9F%B3%E6%82%A6Tai.mp4",
      "sha256": "37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea",
      "bytes": 2836068,
      "restartRequired": "app"
    },
    {
      "target": "ImageScreen/See You Again (Cover)--音悦Tai.mp4",
      "url": "https://ballbox.app/machine-assets/2601070188/ImageScreen/See%20You%20Again%20(Cover)--%E9%9F%B3%E6%82%A6Tai.mp4",
      "sha256": "37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea",
      "bytes": 2836068,
      "restartRequired": "app"
    }
  ]
}


 M lib/machine-ads-manifest.ts
 D "public/machine-assets/2601070188/ImageScreen/\346\265\201\347\250\213\345\233\276.jpg"
?? android/
?? "public/machine-assets/2601070188/ImageScreen/See You Again (Cover)--\351\237\263\346\202\246Tai.mp4"
---
 app/build.gradle.kts                               |   6 +-
 app/src/main/AndroidManifest.xml                   |   2 +
 .../ballbox/machineadssync/MachineAdsSyncEngine.kt | 112 +++++++++++++++++++--
 .../app/ballbox/machineadssync/MainActivity.kt     |  93 +++++++++++++----
 app/src/main/res/layout/activity_main.xml          |  20 +++-
 5 files changed, 196 insertions(+), 37 deletions(-)


**Considering app sync options**

I’m wondering if I need to build a sync app locally. It seems like that’s probably a yes, but maybe it’s not necessary until the next push. The user only asked about changing the same file, so I need to confirm whether to push to the main branch and consider building a new APK later. I should definitely note that local changes are ready.

Listo local.

Preparé dos cambios:

1. Manifest Ballbox
- mismo mp4 en:
  - `ImageScreen/See You Again (Cover)--音悦Tai.mp4`
  - `VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4`
- borré la imagen vieja de `ImageScreen`

2. Sidecar APK
- ahora hace prune de archivos sobrantes en carpetas manejadas
- o sea no suma basura: reemplaza
- próxima versión: `0.1.5`

Validé el manifest local:
- ambos slots apuntan al mismo mp4
- ambos pesan `2836068` bytes

Falta tu confirmación para el paso irreversible:
- push a `main` de Ballbox
- y si querés, después rebuild/link de APK `0.1.5`

Decime:
- `push main`
- o `solo apk primero`


solo apk


**Figuring out APK building**

I need to build the APK from only my local changes without pushing to the main branch. I might need to sync the app's prune behavior, but the manifest on production is still outdated, which may not be helpful. Maybe the user asked specifically about generating a solo APK? In any case, I'll proceed with that approach to focus on building the APK, ensuring it reflects my changes.

rsync -av /home/sebas/work/projects/ballbox-machine-ads-sync/ brag-pipe-fur-kneel.emperor-ratio.ts.net:/Users/sebas/Code/ballbox-machine-ads-sync/ -e 'ssh -p 2222 -o BatchMode=yes -o IdentitiesOnly=yes -i /home/sebas/.ssh/id_ed25519_laptop_agent'


building file list ... done
app/build.gradle.kts
app/src/main/java/app/ballbox/machineadssync/MachineAdsSyncEngine.kt
app/src/main/java/app/ballbox/machineadssync/MainActivity.kt

sent 8,868 bytes  received 202 bytes  18,140.00 bytes/sec
total size is 54,551  speedup is 6.01


laptop-run 'set -e
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
cd /Users/sebas/Code/ballbox-machine-ads-sync
./gradlew assembleDebug --stacktrace
APK=/Users/sebas/Code/ballbox-machine-ads-sync/app/build/outputs/apk/debug/app-debug.apk
AAPT=$(find "$HOME/Library/Android/sdk/build-tools" -name aapt | sort | tail -n 1)
"$AAPT" dump badging "$APK" | egrep "package:|sdkVersion|targetSdkVersion" || true
'


> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:dataBindingMergeDependencyArtifactsDebug UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:dataBindingGenBaseClassesDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest
> Task :app:processDebugManifest
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:mergeDebugShaders UP-TO-DATE
> Task :app:compileDebugShaders NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:mergeDebugStartupProfile UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs NO-SOURCE
> Task :app:stripDebugDebugSymbols NO-SOURCE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:processDebugManifestForPackage
> Task :app:processDebugResources
> Task :app:compileDebugKotlin
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:dexBuilderDebug
> Task :app:mergeDebugGlobalSynthetics UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:mergeProjectDexDebug
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect
> Task :app:assembleDebug

BUILD SUCCESSFUL in 5s
38 actionable tasks: 11 executed, 27 up-to-date
package: name='app.ballbox.machineadssync' versionCode='6' versionName='0.1.5' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
sdkVersion:'25'
targetSdkVersion:'34'


receiving file list ... 1 file to consider
app-debug.apk

              0   0%    0.00kB/s    0:00:00
      5,599,634 100%   15.75MB/s    0:00:00 (xfr#1, to-chk=0/1)

sent 42 bytes  received 5,601,851 bytes  3,734,595.33 bytes/sec
total size is 5,599,634  speedup is 1.00


todo

Built sidecar APK 0.1.5 with prune behavior; Ballbox web manifest changes not pushed to main.


Updated #5

Make Ballbox machine ads sync APK compatible with Android 7.1.2

Adjust the Android sidecar project from Saturday to install on API 25 vending machine, verify build compatibility, and identify any remaining blockers beyond minSdk.

Project builds an installable APK targeting minSdk 25 or lower without obvious parser blockers.

Lowered minSdk from 26 to 25, bumped version to 0.1.1, built debug APK successfully on laptop, and verified resulting APK reports sdkVersion 25 / targetSdkVersion 34. Local copy: /home/sebas/outputs/ballbox-machine-ads-sync/ballbox-machine-ads-sync-debug-0.1.1-api25.apk

Add back button to Ballbox machine ads sync app

Add a visible back button so the user can leave the sidecar and return to the previous/main app more easily, then rebuild APK.

APK rebuilt with visible back button wired to finish the activity.

Added visible Back button that calls finish(), bumped app to 0.1.2, rebuilt debug APK on laptop, verified sdkVersion 25. APK copied to /home/sebas/outputs/ballbox-machine-ads-sync/ballbox-machine-ads-sync-debug-0.1.2-api25.apk

Removed API-26+ File.toPath-dependent Kotlin file helpers by replacing them with manual stream reads. Rebuilt as 0.1.3 and copied APK to /home/sebas/outputs/ballbox-machine-ads-sync/ballbox-machine-ads-sync-debug-0.1.3-api25.apk

Fix storage permission and add download progress logs in Ballbox machine ads sync

Make the sidecar work on Android 7.1 by handling external storage write permission for /sdcard/TcnFoldercopy and add useful progress logging during downloads/writes.

APK rebuilt with runtime storage permission handling and progress logs that help remote testing.

Added READ/WRITE external storage permissions in manifest, runtime permission request for Android 7, richer error text, and download/path/sha/progress logs. Rebuilt APK 0.1.4 API25 at /home/sebas/outputs/ballbox-machine-ads-sync/ballbox-machine-ads-sync-debug-0.1.4-api25.apk

Check Ballbox test video size and reduce if too large

Inspect the current machine asset referenced by the manifest, determine file size, and reduce/compress it if it is unnecessarily large for vending-machine sync testing.

Know current asset size and either justify it or replace it with a smaller variant for faster testing.

Current test video in manifest is 19,932,032 bytes (~19.0 MB), 204s, 768x432 H.264 + AAC. Created smaller local replacement at 2,836,068 bytes (~2.8 MB), 204s, 640x360 H.264, no audio, and replaced the local repo asset at /home/sebas/work/projects/ballbox/public/machine-assets/2601070188/VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4. Not deployed yet.

Make ads sync replace folder contents and test same MP4 in both machine slots

Update Ballbox machine ads flow so sync prunes stale files in managed target folders and change current machine assets/manifest to use the same MP4 in both ImageScreen and VideoAndImageAd for testing.

updating replace behavior and dual-slot test

Code updated locally for prune behavior and machine asset tree prepared with same MP4 in both folders.


Listo.

APK nueva:
https://ballbox-first.emperor-ratio.ts.net/files/outputs/ballbox-machine-ads-sync/ballbox-machine-ads-sync-debug-0.1.5-api25.apk

Trae:
- prune de archivos viejos en carpetas manejadas
- logs de prune
- versión `0.1.5`

No pushée `main`.
O sea:
- la APK ya reemplaza mejor
- pero prod sigue con el manifest viejo hasta que me digas push

Si la probás ahora, el prune sirve igual para limpiar, pero el contenido servido por `ballbox.app` no cambió todavía.


intenta subir el jpg esta bien? falto push?

Sync OK • restart policy app
Starting sync.
TARGET ROOT/sdcard/TenFoldercopy
GET https://ballbox.app/api/machines/2601070188/ads-manifest
TARGET VideoAndImageAd/See You Again (Cover) --#Tai.mp4
DEST/sdcard/TcnFoldercopy/VideoAndImageAd/See You Again (Cover) (Cover)--=iTai.mp4
SKIP VideoAndImageAd/See You Again (Cover)-äiЯTai.mp4 already current
TARGET ImageScreen/流程图.jpg
DEST /sdcard/TcnFoldercopy/ImageScreen/流程图.jpgSKIP ImageScreen/流程图.jpg already current
PRUNE DIR /sdcard/TcFoldercopy/ImageScreen
PRUNE DIR/sdcard/TcnFoldercopy/VideoAndImageAd
PRUNE DONE 0 files
Done. Applied 0 changed files. Pruned 0 stale files.


Sí. Falta push.

Lo que pasó:
- la APK `0.1.5` quedó bien
- pero **`ballbox.app` sigue sirviendo el manifest viejo**
- por eso todavía intenta:
  - `VideoAndImageAd/...mp4`
  - `ImageScreen/流程图.jpg`

Tu log lo confirma:
- hizo `SKIP` de esos 2
- no aplicó nada nuevo
- no pruneó nada porque el manifest actual todavía considera válidos esos archivos

O sea:
- **no es bug de la APK**
- **falta publicar el cambio del manifest/assets en Ballbox**

Si querés que pruebe el mismo mp4 en ambos slots y que desaparezca el jpg:
- tengo que push a `main`

Decime:
- `push main`

y lo hago.


push main


bash

set -e
cd /home/sebas/work/projects/ballbox
git status --short

git add -- lib/machine-ads-manifest.ts 'public/machine-assets/2601070188/ImageScreen/See You Again (Cover)--音悦Tai.mp4' 'public/machine-assets/2601070188/ImageScreen/流程图.jpg'
git commit -m 'Use same test video in both machine ad slots'
git push origin main


 M lib/machine-ads-manifest.ts
 D "public/machine-assets/2601070188/ImageScreen/\346\265\201\347\250\213\345\233\276.jpg"
?? android/
?? "public/machine-assets/2601070188/ImageScreen/See You Again (Cover)--\351\237\263\346\202\246Tai.mp4"
[main 50a0b45] Use same test video in both machine ad slots
 3 files changed, 2 insertions(+), 2 deletions(-)
 create mode 100644 "public/machine-assets/2601070188/ImageScreen/See You Again (Cover)--\351\237\263\346\202\246Tai.mp4"
 delete mode 100644 "public/machine-assets/2601070188/ImageScreen/\346\265\201\347\250\213\345\233\276.jpg"
pre-push: pushing main, running quality checks...
$ node scripts/check-doc-references.mjs
Docs reference check passed for README.md, PLAN.md, and docs/index.md
$ ./scripts/db-local-up.sh
Local Postgres already ready.
$ ./scripts/prisma-migrate-deploy.sh
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "ballbox_local", schema "public" at "127.0.0.1:5432"

2 migrations found in prisma/migrations

No pending migrations to apply.
$ ./scripts/prisma-check-migrations.sh
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "ballbox_local", schema "public" at "127.0.0.1:5432"

2 migrations found in prisma/migrations

Database schema is up to date!
Prisma schema loaded from prisma/schema.prisma
The schema at prisma/schema.prisma is valid 🚀
$ eslint .
$ rm -rf .next/types .next/dev/types
$ tsc --noEmit --incremental false
$ vitest run tests/contracts

 RUN  v4.1.0 /home/sebas/work/projects/ballbox

 ✓ tests/contracts/players/players-admin-player-card.test.tsx (2 tests) 545ms
     ✓ renders the empty memberships state for a player without memberships  458ms
 ✓ tests/contracts/classes/classes-request-form.test.tsx (4 tests) 969ms
     ✓ submits the selected visible slot as part of the request  552ms
 ✓ tests/contracts/clubs/routes.test.ts (9 tests) 414ms
 ✓ tests/contracts/weekly-availability-grid.test.tsx (2 tests) 3012ms
     ✓ exposes focusable button semantics for keyboard users and toggles an open slot  1902ms
     ✓ marks closed slots as aria-disabled and ignores pointer interaction  1107ms
 ✓ tests/contracts/network/network-admin-venue-card.test.tsx (2 tests) 542ms
     ✓ updates the targeted machine active flag through setClubs  419ms
 ✓ tests/contracts/coaches/backend.test.ts (1 test) 265ms
 ✓ tests/contracts/payments/auth.test.ts (2 tests) 421ms
 ✓ tests/contracts/coaches/routes.test.ts (7 tests) 332ms
 ✓ tests/contracts/players/players-admin-create-player-section.test.tsx (1 test) 397ms
     ✓ renders the extracted section shell and forwards player form updates  392ms
 ✓ tests/contracts/accounts/routes.test.ts (3 tests) 191ms
 ✓ tests/contracts/coaches/students-routes.test.ts (5 tests) 260ms
 ✓ tests/contracts/tvs/routes.test.ts (10 tests) 224ms
 ✓ tests/contracts/payments/orders-route.test.ts (2 tests) 151ms
 ✓ tests/contracts/tvs/admin-routes.test.ts (7 tests) 226ms
 ✓ tests/contracts/payments/admin-routes.test.ts (6 tests) 177ms
 ✓ tests/contracts/players/admin-routes.test.ts (9 tests) 180ms
 ✓ tests/contracts/classes/routes.test.ts (5 tests) 165ms
 ✓ tests/contracts/tvs/screen-player.test.tsx (3 tests) 244ms
 ✓ tests/contracts/tvs/backend-modules.test.ts (14 tests) 243ms
 ✓ tests/contracts/sports/routes.test.ts (8 tests) 160ms
 ✓ tests/contracts/players/routes.test.ts (1 test) 118ms
 ✓ tests/contracts/network/backend-modules.test.ts (6 tests) 74ms
 ✓ tests/contracts/network/routes.test.ts (12 tests) 199ms
 ✓ tests/contracts/players/backend-modules.test.ts (3 tests) 166ms
 ✓ tests/contracts/network/network-admin-club-card.test.tsx (2 tests) 162ms
 ✓ tests/contracts/players/players-admin-stats.test.tsx (1 test) 145ms
 ✓ tests/contracts/players/players-admin-player-list.test.tsx (2 tests) 70ms
 ✓ tests/contracts/tvs/backend-runtime.test.ts (4 tests) 70ms
 ✓ tests/contracts/network/network-admin-shell.test.tsx (1 test) 82ms
 ✓ tests/contracts/network/network-admin-create-club-section.test.tsx (1 test) 62ms
 ✓ tests/contracts/players/players-admin-shell.test.tsx (1 test) 62ms
 ✓ tests/contracts/classes/by-date.test.ts (1 test) 10ms
 ✓ tests/contracts/network/network-admin-club-list.test.tsx (1 test) 37ms

 Test Files  33 passed (33)
      Tests  138 passed (138)
   Start at  16:02:24
   Duration  15.24s (transform 2.90s, setup 0ms, import 6.58s, tests 10.37s, environment 19.15s)

$ node scripts/next-build-safe.mjs
[build] linux/arm64 detected: using webpack + SWC wasm fallback to avoid native next-swc crash
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
  Skipping creating a lockfile at /home/sebas/work/projects/ballbox/.next/lock because we're using WASM bindings
▲ Next.js 16.2.1 (webpack)
- Environments: .env.local, .env.production

  Creating an optimized production build ...
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
✓ Compiled successfully in 12.0s
  Skipping validation of types
  Finished TypeScript config validation in 9ms ...
  Collecting page data using 3 workers ...
  Generating static pages using 3 workers (0/56) ...
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
  Generating static pages using 3 workers (14/56)
  Generating static pages using 3 workers (28/56)
  Generating static pages using 3 workers (42/56)
✓ Generating static pages using 3 workers (56/56) in 914ms
  Finalizing page optimization ...
  Collecting build traces ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /admin
├ ƒ /admin/login
├ ƒ /admin/network
├ ƒ /admin/payments
├ ƒ /admin/players
├ ƒ /admin/screens
├ ƒ /admin/sports
├ ƒ /admin/tvs
├ ƒ /api/account/session
├ ƒ /api/admin/logout
├ ƒ /api/admin/payments/operations
├ ƒ /api/admin/payments/sessions
├ ƒ /api/admin/payments/sessions/[sessionId]
├ ƒ /api/admin/players
├ ƒ /api/admin/players/[playerId]
├ ƒ /api/admin/players/[playerId]/memberships
├ ƒ /api/admin/players/memberships/[membershipId]
├ ƒ /api/admin/session
├ ƒ /api/admin/sports/classes
├ ƒ /api/admin/sports/classes/[classId]
├ ƒ /api/admin/sports/coaches
├ ƒ /api/admin/sports/coaches/[coachId]
├ ƒ /api/admin/sports/open-matches
├ ƒ /api/admin/sports/open-matches/[matchId]
├ ƒ /api/admin/sports/operations
├ ƒ /api/admin/summary
├ ƒ /api/admin/tvs/assignments/[assignmentId]
├ ƒ /api/admin/tvs/campaigns
├ ƒ /api/admin/tvs/campaigns/[campaignId]
├ ƒ /api/admin/tvs/campaigns/[campaignId]/assignments
├ ƒ /api/admin/tvs/campaigns/[campaignId]/creatives
├ ƒ /api/admin/tvs/creatives/[creativeId]
├ ƒ /api/admin/tvs/operations
├ ƒ /api/admin/tvs/screens
├ ƒ /api/admin/tvs/screens/[screenId]
├ ƒ /api/admin/tvs/tv-devices
├ ƒ /api/admin/tvs/tv-devices/[tvDeviceId]
├ ƒ /api/atc-public/availability/sportclubs/[sportclubId]
├ ƒ /api/classes/logout
├ ƒ /api/classes/offers
├ ƒ /api/classes/offers/[offerId]
├ ƒ /api/classes/requests
├ ƒ /api/classes/session
├ ƒ /api/clubs/accounts
├ ƒ /api/clubs/coaches
├ ƒ /api/clubs/coaches/[coachId]
├ ƒ /api/clubs/invites
├ ƒ /api/clubs/logout
├ ƒ /api/clubs/manual-classes
├ ƒ /api/clubs/me
├ ƒ /api/clubs/players
├ ƒ /api/clubs/session
├ ƒ /api/clubs/venues
├ ƒ /api/clubs/venues/[venueId]
├ ƒ /api/coaches/accounts
├ ƒ /api/coaches/availability
├ ƒ /api/coaches/clubs
├ ƒ /api/coaches/invites/[token]
├ ƒ /api/coaches/logout
├ ƒ /api/coaches/me
├ ƒ /api/coaches/session
├ ƒ /api/coaches/students
├ ƒ /api/coaches/students/[bookingId]
├ ƒ /api/coaches/students/players
├ ƒ /api/coaches/students/players/[playerId]
├ ƒ /api/integrations/payments/approved
├ ƒ /api/integrations/payments/health
├ ƒ /api/integrations/payments/orders
├ ƒ /api/integrations/payments/sessions
├ ƒ /api/machines/[machineId]/ads-manifest
├ ƒ /api/network
├ ƒ /api/network/[clubId]
├ ƒ /api/network/[clubId]/venues
├ ƒ /api/network/[clubId]/venues/[venueId]
├ ƒ /api/network/venues/[venueId]/vending-machines
├ ƒ /api/network/venues/[venueId]/vending-machines/[vendingMachineId]
├ ƒ /api/players
├ ƒ /api/sports
├ ƒ /api/tvs/health
├ ƒ /api/tvs/heartbeat
├ ƒ /api/tvs/impression
├ ƒ /api/tvs/manifest/[screenId]
├ ƒ /api/tvs/screens
├ ƒ /classes
├ ƒ /classes-by-date
├ ƒ /classes/[offerId]
├ ƒ /classes/login
├ ƒ /classes/me
├ ƒ /clubs
├ ƒ /clubs/coaches
├ ƒ /clubs/venues/[venueId]
├ ○ /coaches
├ ƒ /coaches/settings
├ ○ /manifest.webmanifest
├ ƒ /notification/mercadopago
├ ○ /offline
└ ƒ /player/[screenId]

ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

pre-push: all checks passed.
To https://github.com/sebasfavaron/ballbox.git
   c8f25bf..50a0b45  main -> main


Listo.

Push hecho:
- commit `50a0b45`
- `main` actualizado

Ahora `ballbox.app` debería servir:
- mismo mp4 en `ImageScreen`
- mismo mp4 en `VideoAndImageAd`
- sin el jpg viejo en `ImageScreen`

Probá ahora:
1. abrir app
2. `Sync now`
3. debería bajar/aplicar 1 archivo nuevo en `ImageScreen`
4. debería prunear el jpg viejo
5. reiniciar app principal / reboot si hace falta

Si no cambia visualmente, pasame el log nuevo exacto.


ahora intentemos hacer los cambios desde la api, asi no tengo que reinstalar la apk. se puede ? que requiere?
digo para soportar cualquier prune, cambio arbitrario de archivos (futuros tambien)


**Considering API enhancements**

I need to think conceptually about the current API, which already drives the manifest without needing to reinstall the APK for content changes. However, for arbitrary prunes or changes, the manifest model might need to be richer, possibly to represent the desired state per managed directories. I’m also considering server-side file management, including UI/API, authentication, upload pipelines, and mapping per machine or slot. It might be enough just to inspect the current backend for context.

Sí. Se puede.

De hecho, **ese es el camino correcto**:
- no reinstalar APK
- cambiar estado deseado desde API/backend
- la sidecar solo sincroniza ese estado

## Qué ya tenés hoy
Ya casi está:
- APK instalada una vez
- endpoint manifest en `ballbox.app`
- descarga de archivos
- replace
- prune en carpetas manejadas

O sea:
- **para cambios de contenido, no hace falta reinstalar APK**
- solo hace falta cambiar lo que devuelve la API + assets publicados

## Qué falta para que soporte “cualquier prune / cambio arbitrario”
### 1. manifest más general
Hoy está hardcodeado para una máquina y pocos archivos.

Hay que pasar a algo así:
- lista arbitraria de archivos por máquina
- carpetas manejadas explícitas
- política de prune por carpeta
- quizá delete explícito también

Ejemplo mental:
- `managedDirs: ["ImageScreen", "VideoAndImageAd"]`
- `files: [...]`
- sidecar:
  - baja lo listado
  - borra lo no listado dentro de esas carpetas

Con eso ya soportás:
- reemplazos
- altas
- bajas
- prune real
- futuros archivos

### 2. fuente de verdad en backend
Hoy el manifest sale de archivos hardcodeados en repo.

Mejor:
- DB o JSON admin-managed
- por máquina
- con targets y assets
- editable sin tocar código

### 3. forma de subir assets
Necesitás una vía para:
- subir mp4/jpg
- guardarlos en storage público
- asociarlos a una máquina y target path

### 4. modelo por máquina
Algo tipo:
- machine_id
- target_path
- asset_url / stored_asset_id
- sha256
- bytes
- active
- managed_dir

### 5. auth/admin
Si esto va a quedar real:
- endpoint admin protegido
- no libre

## Qué NO requiere
No requiere:
- reinstalar APK por cada cambio
- cambiar código Android por cada asset nuevo
- redeploy Android por cada prune

## Cuándo sí requerís nueva APK
Solo si cambiás:
- lógica de sync
- permisos
- UI
- estrategia de prune
- soporte de nuevos tipos de operación

## Recomendación
Próximo paso bueno:
1. congelar la APK un rato
2. mover control al backend/API
3. dejar manifest dinámico por máquina
4. exponer admin simple para assets + target paths

## Diseño mínimo útil
Backend:
- tabla/config `machine_ads_entries`
- campos:
  - `machine_id`
  - `target_path`
  - `asset_path`
  - `active`
  - `managed_dir`

Manifest:
- devuelve solo activos
- sidecar prunea dentro de `managed_dir`

Con eso ya podés:
- cambiar cualquier archivo
- borrar cualquier archivo
- hacer pruebas sin reinstalar

## Respuesta corta
Sí, se puede.
Requiere:
- manifest dinámico
- assets subibles
- config por máquina
- carpetas manejadas para prune
- auth admin

Y la buena noticia:
- **la APK que ya hicimos te sirve como base**
- el trabajo fuerte ahora está del lado backend, no Android

Si querés, siguiente paso te lo implemento en Ballbox como MVP admin-driven.


bien y deja plan rfc en doc
empecemos a cambiarlo

en cuanto al nuevo intento se pudo y cambio los dos videos en la app (solo tuve que nombrar bien el target (sin "copy"). eso cambialo en la nueva version que vas a implementar para el rfc

Sync OK • restart policy app
Starting sync...
TARGET ROOT/sdcard/TcnFolder
GET https://ballbox.app/api/machines/2601070188/ads-manifest
TARGET VideoAndImageAd/See You Again (Cover) ---WTai.mp4
DEST /sdcard/TcnFolder/VideoAndImageAd/See You Again (Cover) --iTai.mp4
CONTENT LENGTH 2836068 bytes
DOWNLOAD https://ballbox.app/machine-assets/2601070188/VideoAndImageAd/See%20You%20Again%20(Cover) --%E9%9F%B3%6%82A6Tai.mp4
DOWNLOAD PROGRESS 1052447/2836068 bytes (37%)
DOWNLOAD PROGRESS 2101023/2836068 bytes (74%)
DOWNLOAD COMPLETE 2836068 bytes
SHA expected=37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea
SHA actual=37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea
WRITE TMP/sdcard/TcnFolder/VideoAndImageAd/See You Again (Cover) (Cover)--#l*Tai.mp4.tmp
RENAME TMP -> DEST
WROTE VideoAndImageAd/See You Again (Cover) -Tai.mp4 (2836068 bytes)
TARGET ImageScreen/See You Again (Cover) --aRTai.mp4
DEST /sdcard/TcnFolder/ImageScreen/See You Again (Cover) --#ITai.mp4
DOWNLOAD https://ballbox.app/machine-assets/2601070188/ImageScreen/See%20You%20Again%20(Cover) --%E9%9F%B3%E6%82%A6Tai.mp4
CONTENT LENGTH 2836068 bytes
DOWNLOAD PROGRESS 1055829/2836068 bytes 3/%
DOWNLOAD PROGRESS 2104405/2836068 bytes (74%)
DOWNLOAD COMPLETE 2836068 bytes
SHA expected=37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea
SHA actual=37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea
WRITE TMP/sdcard/TcnFolder/ImageScreen/See You Again (Cover) (Cover)--#t*Tai.mp4.tmp
RENAME TMP -> DEST
WROTE ImageScreen/See You Again (Cover) --iTai.mp4(2836068bytes)
PRUNE DIR /sdcard/TcnFolder/ImageScreen
PRUNE DELETE /sdcard/TcnFolder/ImageScreen/.DS_Store
PRUNE DELETE /sdcard/TcnFolder/ImageScreen/._.DS_Store
PRUNE DELETE /sdcard/TcnFo1der/ImageScreen/VideoAdidasVertical_2.mp4.mp4
PRUNE DELETE /sdcard/TcnFolder/ImageScreen/._VideoAdidasVertical_2.mp4.mp4
PRUNE DELETE /sdcard/TcnFolder/ImageScreen/VideoAdidasVertical_1.mp4.mp4
PRUNE DELETE /sdcard/TcnFolder/ImageScreen/._VideoAdidasVertical_1.mp4.mp4
PRUNE DIR/sdcard/TcnFolder/VideoAndImageAd
PRUNE DELETE /sdcard/TcnFolder/VideoAndImageAd/.DS_Store
PRUNE DELETE /sdcard/TcnFolder/VideoAndImageAd/._
RUNE DELETE /sdcard/TcnFolder/VideoAndImageAd/VideoAdidasHorizontal_1.mp4
PRUNE
DELETE /sdcard/TcnFolder/VideoAndImageAd/._VideoAdidasHorizontal_1.mp4
PRUNE
DONE 10 files
Done. Applied 2 changed files. Pruned 10 stale files.


**Planning documentation changes**

I need to create a Request for Comments (RFC) document in the ballbox repository and possibly update the system, particularly the backend. I should read the existing documentation and consider placing it under the docs section. It's essential to specify that the default target root should be /sdcard/TcnFolder. Since the user asked me to start these changes, implementing an initial RFC and updating the APK default sounds like a good plan. I might also consider backend adjustments and using a to-do list to keep track.

todo

Write RFC and start Ballbox API-driven machine ads control

Document the plan/RFC for moving machine ads control to API-managed desired state, include discovered TcnFolder target-root correction, and begin implementing backend changes toward dynamic machine asset entries.

RFC doc exists in repo and first backend/code changes land toward API-driven machine asset control.


Created #6: Write RFC and start Ballbox API-driven machine ads control (pending)


bash

find /home/sebas/work/projects/ballbox/docs -maxdepth 2 -type f | sort | sed -n '1,160p'

sed -n '1,220p' /home/sebas/work/projects/ballbox/docs/index.md 2>/dev/null || true

sed -n '1,260p' /home/sebas/work/projects/ballbox/package.json


# Docs Index

## How To Navigate
- `PLAN.md`: current repo state and executive summary of next implementation direction
- `docs/repo-map.md`: fast file-level entrypoint for each domain
- `docs/implemented-*.md`: shipped behavior only
- `docs/*.html`: human review, decision, and operator docs
- `docs/*-plan.md`: route/product planning
- `docs/*-discovery*.md`: validation and research framing
- `docs/*-gap-analysis.md`: what Ballbox still needs to build
- `docs/maintainability-priorities-2026-03.md`: prioritized frontend/backend maintainability work for the next refactor cycle
- `docs/demo-iteration-mode.md`: rapid demo delivery model with anti-debt guardrails

## Operator Priority Path
1. `docs/tcn-machine-reality-2026-05-26.html`
2. `docs/tcn-future-action-options.html`
3. `docs/implemented-payments.md`
4. `docs/payments-club-settlement-strategy.md`

## Current TCN Source Of Truth
- `docs/tcn-machine-reality-2026-05-26.html`
- `docs/tcn-future-action-options.html`

## Current Refactor Input
- `docs/maintainability-priorities-2026-03.md`

## Execution And Coordination
- `PLAN.md`
- `docs/repo-map.md`
- `docs/demo-iteration-mode.md`

## Implemented State
- `docs/implemented-host-app.md`
- `docs/implemented-network.md`
- `docs/implemented-payments.md`
- `docs/implemented-players.md`
- `docs/implemented-sports.md`
- `docs/implemented-tvs.md`

## Discovery And Strategy
- `docs/coaches-discovery-stage.md`
- `docs/coaches-form-current.md`
- `docs/atc-integration-discovery.md`
- `docs/coaches-opportunity-brief.md`
- `docs/coaches-research-repository.md`
- `docs/coaches-validation-checklist-2-weeks.md`
- `docs/coaches-solution-thesis.md`

## Current Build Docs
- `docs/classes-subroute-plan.md`
- `docs/classes-mvp-rfc.md`
- `docs/coaches-coordination-gap-analysis.md`
- `docs/atc-mock-integration-plan.md`
- `docs/payments-club-settlement-strategy.md`

- [TCN remote ads autonomy notes](./tcn-remote-ads-autonomy-2026-06-06.md)

- [TCN simple ads backend MVP](./tcn-simple-ads-backend-mvp-2026-06-06.md)


dotenv -e .env.local -- tsx scripts/backfill-atc-clubs.ts

dotenv -e .env.local -- tsx scripts/revalidate-atc-clubs.ts

dotenv -e .env.local -- tsx prisma/seed.ts

vitest run tests/integration --passWithNoTests


**Planning backend changes**

I'm considering creating an RFC document to initiate backend changes, possibly starting with a configuration file. Maybe I can put a new file in docs/rfc called machine-ads-config.json. I need to update the APK's default target root to /sdcard/TcnFolder as the user requested for the new version. A JSON config file might simplify data handling rather than hardcoding values in TypeScript. I could also refactor the manifest to read from this config file. This feels like a solid approach to start with.

**Implementing dynamic changes**

I need the target root to be /sdcard/TcnFolder in the sidecar's default settings. It seems sensible to include targetRoot in the manifest documentation, possibly for the future. I'm thinking about editing lib/machine-ads-manifest.ts to load data from a JSON config file. I can start by creating a JSON file at `data/machine-ads/2601070188.json` that contains the targetRoot, managedDirs, and files, and update the manifest route generator accordingly.

Testing feels essential, so maybe I should add unit tests for the manifest reading using Vitest. For now, no push is needed; I can implement and report local changes instead.

find /home/sebas/work/projects/ballbox/tests -maxdepth 2 -type f | sort | sed -n '1,120p'


import { NextResponse } from "next/server";

import { getMachineAdsManifest } from "@/lib/machine-ads-manifest";

export async function GET(
  request: Request,
  context: { params: Promise<{ machineId: string }> }
) {
  const { machineId } = await context.params;
  const manifest = await getMachineAdsManifest(machineId, request.url);

  if (!manifest) {
    return NextResponse.json(
      {
        ok: false,
        error: "MACHINE_NOT_FOUND",
        message: `No simple ads manifest is configured for machine ${machineId}.`,
      },
      { status: 404 }
    );
  }

  return NextResponse.json({ ok: true, manifest });
}


write

# TCN API-driven machine ads RFC

## Goal
Move Ballbox machine ad/media control from hardcoded repo mappings to API-managed desired state so future changes do not require rebuilding or reinstalling the Android sidecar.

## Reality confirmed on device
- Installed sidecar can sync successfully on Android 7.1.2 once `minSdk` and storage behavior are fixed.
- Correct target root on the machine is `/sdcard/TcnFolder`.
- `ImageScreen/` is the fullscreen standby slot.
- `VideoAndImageAd/` is the smaller top video slot.
- Both folders accept looped video files.
- Current sidecar prune behavior can delete stale files inside managed folders.

## Problem with current MVP
Current backend manifest generation is hardcoded in `lib/machine-ads-manifest.ts`.
That means every content change currently needs a code edit and deploy.
This is too slow for operator use and does not scale to arbitrary file replacement/prune.

## Target state
Ballbox should own a per-machine desired state model.
The sidecar should only:
1. fetch manifest
2. download listed files
3. verify hashes
4. write exact target paths
5. prune stale files inside managed directories

Changing content should mean changing Ballbox backend state, not Android code.

## Proposed manifest shape
```json
{
  "machineId": "2601070188",
  "generatedAt": "2026-06-08T00:00:00.000Z",
  "version": "...",
  "restartPolicy": "app",
  "targetRootHint": "/sdcard/TcnFolder",
  "managedDirs": ["ImageScreen", "VideoAndImageAd"],
  "files": [
    {
      "target": "ImageScreen/foo.mp4",
      "url": "https://ballbox.app/machine-assets/.../foo.mp4",
      "sha256": "...",
      "bytes": 123,
      "restartRequired": "app"
    }
  ]
}
```

## Desired backend model
Short term:
- file-backed machine config in repo
- one JSON document per machine
- assets still served from `public/machine-assets/...`

Medium term:
- DB-backed machine asset entries
- admin-authenticated upload and assignment UI/API
- no code edits required for normal operator changes

## Minimal data model
Per machine config:
- `machineId`
- `targetRootHint`
- `managedDirs[]`
- `restartPolicy`
- `entries[]`

Per entry:
- `target`
- `publicPath`
- `restartRequired`
- optional future metadata: `label`, `active`, `slot`, `contentType`

## Rollout plan
### Phase 1
- move hardcoded manifest config into file-backed JSON
- expose `managedDirs` and `targetRootHint` in manifest
- update sidecar default target root to `/sdcard/TcnFolder`
- keep sidecar reinstalls rare

### Phase 2
- add admin mutation path in Ballbox backend
- validate target paths against allowed machine directories
- allow add/replace/remove entries per machine

### Phase 3
- add upload flow and stored asset references
- optionally track asset history and rollback

## Safety constraints
- prune only inside explicitly managed directories
- never allow arbitrary absolute target paths from user input
- validate machine target prefixes against allowlist
- keep manifest truthful: only list assets that exist and hash correctly

## Current implementation decision
Start with a repo-backed config file because it is the cheapest path from hardcoded code to API-driven desired state.
That gives immediate leverage while keeping migration path open to DB/admin UI.

## Immediate implementation steps
1. add machine config JSON files under repo data directory
2. refactor manifest builder to read config instead of hardcoded map
3. include `managedDirs` and `targetRootHint` in manifest response
4. update Android sidecar default root to `/sdcard/TcnFolder`
5. later add admin write path


Successfully wrote 3575 bytes to /home/sebas/work/projects/ballbox/docs/tcn-api-driven-machine-ads-rfc-2026-06-08.md


/machine-assets/2601070188/VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4

/machine-assets/2601070188/ImageScreen/See You Again (Cover)--音悦Tai.mp4


Successfully wrote 585 bytes to /home/sebas/work/projects/ballbox/data/machine-ads/2601070188.json


import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";

const MACHINE_ADS_ROOT = path.join(process.cwd(), "public", "machine-assets");
const MACHINE_ADS_CONFIG_ROOT = path.join(process.cwd(), "data", "machine-ads");

type RestartRequired = "app" | "reboot";

type MachineAdsConfigEntry = {
  target: string;
  publicPath: string;
  restartRequired: RestartRequired;
};

type MachineAdsConfig = {
  machineId: string;
  targetRootHint: string;
  managedDirs: string[];
  restartPolicy: RestartRequired;
  entries: MachineAdsConfigEntry[];
};

export type MachineAdsManifest = {
  machineId: string;
  version: string;
  generatedAt: string;
  restartPolicy: RestartRequired;
  targetRootHint: string;
  managedDirs: string[];
  files: Array<{
    target: string;
    url: string;
    sha256: string;
    bytes: number;
    restartRequired: RestartRequired;
  }>;
};

function toPublicFilePath(publicPath: string) {
  return path.join(MACHINE_ADS_ROOT, publicPath.replace(/^\/machine-assets\//, ""));
}

async function getFileDigest(filePath: string) {
  const contents = await readFile(filePath);
  return {
    sha256: createHash("sha256").update(contents).digest("hex"),
    bytes: contents.byteLength,
  };
}

async function readMachineAdsConfig(machineId: string): Promise<MachineAdsConfig | null> {
  const filePath = path.join(MACHINE_ADS_CONFIG_ROOT, `${machineId}.json`);

  try {
    const raw = await readFile(filePath, "utf8");
    return JSON.parse(raw) as MachineAdsConfig;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return null;
    }

    throw error;
  }
}

export async function getMachineAdsManifest(machineId: string, origin: string): Promise<MachineAdsManifest | null> {
  const config = await readMachineAdsConfig(machineId);

  if (!config) {
    return null;
  }

  const resolvedFiles = await Promise.all(
    config.entries.map(async (entry) => {
      const filePath = toPublicFilePath(entry.publicPath);
      const digest = await getFileDigest(filePath);

      return {
        target: entry.target,
        url: new URL(entry.publicPath, origin).toString(),
        sha256: digest.sha256,
        bytes: digest.bytes,
        restartRequired: entry.restartRequired,
      };
    })
  );

  return {
    machineId: config.machineId,
    version: resolvedFiles.map((file) => `${file.target}:${file.sha256}`).join("|"),
    generatedAt: new Date().toISOString(),
    restartPolicy: config.restartPolicy,
    targetRootHint: config.targetRootHint,
    managedDirs: config.managedDirs,
    files: resolvedFiles,
  };
}


Successfully wrote 2650 bytes to /home/sebas/work/projects/ballbox/lib/machine-ads-manifest.ts


edit

- [TCN simple ads backend MVP](./tcn-simple-ads-backend-mvp-2026-06-06.md)
- [TCN API-driven machine ads RFC](./tcn-api-driven-machine-ads-rfc-2026-06-08.md)


Successfully replaced 1 block(s) in /home/sebas/work/projects/ballbox/docs/index.md.

    ...
 55
 56 - [TCN remote ads autonomy notes](./tcn-remote-ads-autonomy-2026-06-06.md)
 57
 58 - [TCN simple ads backend MVP](./tcn-simple-ads-backend-mvp-2026-06-06.md)
+59 - [TCN API-driven machine ads RFC](./tcn-api-driven-machine-ads-rfc-2026-06-08.md)

--- /home/sebas/work/projects/ballbox/docs/index.md
+++ /home/sebas/work/projects/ballbox/docs/index.md
@@ -55,4 +55,5 @@

 - [TCN remote ads autonomy notes](./tcn-remote-ads-autonomy-2026-06-06.md)

 - [TCN simple ads backend MVP](./tcn-simple-ads-backend-mvp-2026-06-06.md)
+- [TCN API-driven machine ads RFC](./tcn-api-driven-machine-ads-rfc-2026-06-08.md)


private const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFoldercopy"

private const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFolder"


Successfully replaced 1 block(s) in /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MainActivity.kt.

     ...
 107             Manifest.permission.READ_EXTERNAL_STORAGE,
 108             Manifest.permission.WRITE_EXTERNAL_STORAGE,
 109         )
 110         private const val DEFAULT_MANIFEST_URL = "https://ballbox.app/api/machines/2601070188/ads-manifest"
-111         private const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFoldercopy"
+111         private const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFolder"
 112     }
 113 }

--- /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MainActivity.kt
+++ /home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MainActivity.kt
@@ -107,7 +107,7 @@
             Manifest.permission.READ_EXTERNAL_STORAGE,
             Manifest.permission.WRITE_EXTERNAL_STORAGE,
         )
         private const val DEFAULT_MANIFEST_URL = "https://ballbox.app/api/machines/2601070188/ads-manifest"
-        private const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFoldercopy"
+        private const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFolder"
     }
 }


Successfully replaced 1 block(s) in /home/sebas/work/projects/ballbox-machine-ads-sync/README.md.

    ...
 16 Current source tree size is intentionally tiny.
 17
 18 ## Current defaults
 19 - manifest URL: `https://ballbox.app/api/machines/2601070188/ads-manifest`
-20 - target root: `/sdcard/TcnFoldercopy`
+20 - target root: `/sdcard/TcnFolder`
 21
 22 ## Current behavior
 23 - fetch manifest JSON
 24 - download changed files only
    ...

--- /home/sebas/work/projects/ballbox-machine-ads-sync/README.md
+++ /home/sebas/work/projects/ballbox-machine-ads-sync/README.md
@@ -16,9 +16,9 @@
 Current source tree size is intentionally tiny.

 ## Current defaults
 - manifest URL: `https://ballbox.app/api/machines/2601070188/ads-manifest`
-- target root: `/sdcard/TcnFoldercopy`
+- target root: `/sdcard/TcnFolder`

 ## Current behavior
 - fetch manifest JSON
 - download changed files only


bash

cd /home/sebas/work/projects/ballbox && pnpm lint lib/machine-ads-manifest.ts >/dev/null 2>&1 || true; node -e "import('./lib/machine-ads-manifest.ts').then(async m => { const x = await m.getMachineAdsManifest('2601070188','https://ballbox.app'); console.log(JSON.stringify(x,null,2)); }).catch(e => { console.error(e); process.exit(1); })"


(node:225209) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///home/sebas/work/projects/ballbox/lib/machine-ads-manifest.ts is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /home/sebas/work/projects/ballbox/package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
{
  "machineId": "2601070188",
  "version": "VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4:37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea|ImageScreen/See You Again (Cover)--音悦Tai.mp4:37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea",
  "generatedAt": "2026-06-08T19:24:45.767Z",
  "restartPolicy": "app",
  "targetRootHint": "/sdcard/TcnFolder",
  "managedDirs": [
    "ImageScreen",
    "VideoAndImageAd"
  ],
  "files": [
    {
      "target": "VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4",
      "url": "https://ballbox.app/machine-assets/2601070188/VideoAndImageAd/See%20You%20Again%20(Cover)--%E9%9F%B3%E6%82%A6Tai.mp4",
      "sha256": "37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea",
      "bytes": 2836068,
      "restartRequired": "app"
    },
    {
      "target": "ImageScreen/See You Again (Cover)--音悦Tai.mp4",
      "url": "https://ballbox.app/machine-assets/2601070188/ImageScreen/See%20You%20Again%20(Cover)--%E9%9F%B3%E6%82%A6Tai.mp4",
      "sha256": "37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea",
      "bytes": 2836068,
      "restartRequired": "app"
    }
  ]
}


cd /home/sebas/work/projects/ballbox && git status --short && echo '---' && cd /home/sebas/work/projects/ballbox-machine-ads-sync && git diff --stat


 M docs/index.md
 M lib/machine-ads-manifest.ts
?? android/
?? data/
?? docs/tcn-api-driven-machine-ads-rfc-2026-06-08.md
---
 README.md                                          |   2 +-
 app/build.gradle.kts                               |   6 +-
 app/src/main/AndroidManifest.xml                   |   2 +
 .../ballbox/machineadssync/MachineAdsSyncEngine.kt | 112 +++++++++++++++++++--
 .../app/ballbox/machineadssync/MainActivity.kt     |  95 ++++++++++++-----
 app/src/main/res/layout/activity_main.xml          |  20 +++-
 6 files changed, 198 insertions(+), 39 deletions(-)


todo

Wrote RFC doc docs/tcn-api-driven-machine-ads-rfc-2026-06-08.md, added repo-backed machine config at data/machine-ads/2601070188.json, refactored lib/machine-ads-manifest.ts to read JSON config and expose targetRootHint/managedDirs, updated docs index, and changed sidecar default target root to /sdcard/TcnFolder locally.


Updated #6 (pending → completed)


Hecho.

## Dejé RFC/plan
Nuevo doc:
- `docs/tcn-api-driven-machine-ads-rfc-2026-06-08.md`

También lo agregué al índice:
- `docs/index.md`

## Empecé el cambio real
Backend Ballbox:
- saqué el hardcode de `lib/machine-ads-manifest.ts`
- ahora lee config repo-backed desde:
  - `data/machine-ads/2601070188.json`

Manifest ahora ya soporta:
- `targetRootHint`
- `managedDirs`
- `entries[]`

Validé que genera:
- `targetRootHint: /sdcard/TcnFolder`
- `managedDirs: ["ImageScreen", "VideoAndImageAd"]`

## Cambio importante de Android
En la sidecar local:
- cambié default target root a:
  - `/sdcard/TcnFolder`
- antes estaba mal para esta máquina

## Estado
Todavía está **local**, no pushé nada nuevo de esto.

Archivos clave:
- `/home/sebas/work/projects/ballbox/docs/tcn-api-driven-machine-ads-rfc-2026-06-08.md`
- `/home/sebas/work/projects/ballbox/data/machine-ads/2601070188.json`
- `/home/sebas/work/projects/ballbox/lib/machine-ads-manifest.ts`
- `/home/sebas/work/projects/ballbox-machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MainActivity.kt`

## Lo que sigue
Próximo paso lógico:
1. push de backend repo-backed config
2. rebuild APK con default `/sdcard/TcnFolder`
3. después admin mutation API para no tocar repo

Si querés, sigo ya con:
- `push backend + nueva apk`
o
- `solo backend primero`


ambas


**Planning push and build**

I need to confirm the irreversible push since I've already got the ambas. It seems I should proceed to build the APK and push the backend, likely using the main branch. I’m also wondering if I should commit and push the sidecar repo too. It seems like it would be helpful to provide the APK link without needing to ask anyone. I'll figure out the steps needed to make this happen!

bash

set -e
cd /home/sebas/work/projects/ballbox
git add docs/index.md docs/tcn-api-driven-machine-ads-rfc-2026-06-08.md data/machine-ads/2601070188.json lib/machine-ads-manifest.ts
git commit -m 'Move machine ads manifest to repo-backed config'
git push origin main


[main 45221a8] Move machine ads manifest to repo-backed config
 4 files changed, 169 insertions(+), 28 deletions(-)
 create mode 100644 data/machine-ads/2601070188.json
 create mode 100644 docs/tcn-api-driven-machine-ads-rfc-2026-06-08.md
pre-push: pushing main, running quality checks...
$ node scripts/check-doc-references.mjs
Docs reference check passed for README.md, PLAN.md, and docs/index.md
$ ./scripts/db-local-up.sh
Local Postgres already ready.
$ ./scripts/prisma-migrate-deploy.sh
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "ballbox_local", schema "public" at "127.0.0.1:5432"

2 migrations found in prisma/migrations

No pending migrations to apply.
$ ./scripts/prisma-check-migrations.sh
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "ballbox_local", schema "public" at "127.0.0.1:5432"

2 migrations found in prisma/migrations

Database schema is up to date!
Prisma schema loaded from prisma/schema.prisma
The schema at prisma/schema.prisma is valid 🚀
$ eslint .
$ rm -rf .next/types .next/dev/types
$ tsc --noEmit --incremental false
$ vitest run tests/contracts

 RUN  v4.1.0 /home/sebas/work/projects/ballbox

 ✓ tests/contracts/players/players-admin-player-card.test.tsx (2 tests) 493ms
     ✓ renders the empty memberships state for a player without memberships  380ms
 ✓ tests/contracts/classes/classes-request-form.test.tsx (4 tests) 1010ms
     ✓ submits the selected visible slot as part of the request  636ms
 ✓ tests/contracts/payments/auth.test.ts (2 tests) 286ms
 ✓ tests/contracts/clubs/routes.test.ts (9 tests) 286ms
 ✓ tests/contracts/weekly-availability-grid.test.tsx (2 tests) 3020ms
     ✓ exposes focusable button semantics for keyboard users and toggles an open slot  1726ms
     ✓ marks closed slots as aria-disabled and ignores pointer interaction  1292ms
 ✓ tests/contracts/network/network-admin-venue-card.test.tsx (2 tests) 491ms
     ✓ updates the targeted machine active flag through setClubs  349ms
 ✓ tests/contracts/coaches/routes.test.ts (7 tests) 402ms
 ✓ tests/contracts/coaches/backend.test.ts (1 test) 290ms
 ✓ tests/contracts/coaches/students-routes.test.ts (5 tests) 291ms
 ✓ tests/contracts/players/players-admin-create-player-section.test.tsx (1 test) 384ms
     ✓ renders the extracted section shell and forwards player form updates  381ms
 ✓ tests/contracts/tvs/backend-modules.test.ts (14 tests) 213ms
 ✓ tests/contracts/tvs/admin-routes.test.ts (7 tests) 261ms
 ✓ tests/contracts/tvs/routes.test.ts (10 tests) 271ms
 ✓ tests/contracts/network/routes.test.ts (12 tests) 129ms
 ✓ tests/contracts/tvs/screen-player.test.tsx (3 tests) 239ms
 ✓ tests/contracts/accounts/routes.test.ts (3 tests) 201ms
 ✓ tests/contracts/players/admin-routes.test.ts (9 tests) 177ms
 ✓ tests/contracts/payments/admin-routes.test.ts (6 tests) 183ms
 ✓ tests/contracts/players/backend-modules.test.ts (3 tests) 87ms
 ✓ tests/contracts/classes/routes.test.ts (5 tests) 258ms
 ✓ tests/contracts/sports/routes.test.ts (8 tests) 95ms
 ✓ tests/contracts/payments/orders-route.test.ts (2 tests) 203ms
 ✓ tests/contracts/players/routes.test.ts (1 test) 106ms
 ✓ tests/contracts/network/network-admin-club-card.test.tsx (2 tests) 150ms
 ✓ tests/contracts/network/backend-modules.test.ts (6 tests) 130ms
 ✓ tests/contracts/players/players-admin-stats.test.tsx (1 test) 109ms
 ✓ tests/contracts/tvs/backend-runtime.test.ts (4 tests) 66ms
 ✓ tests/contracts/network/network-admin-shell.test.tsx (1 test) 82ms
 ✓ tests/contracts/players/players-admin-player-list.test.tsx (2 tests) 91ms
 ✓ tests/contracts/players/players-admin-shell.test.tsx (1 test) 58ms
 ✓ tests/contracts/classes/by-date.test.ts (1 test) 8ms
 ✓ tests/contracts/network/network-admin-create-club-section.test.tsx (1 test) 63ms
 ✓ tests/contracts/network/network-admin-club-list.test.tsx (1 test) 37ms

 Test Files  33 passed (33)
      Tests  138 passed (138)
   Start at  16:46:25
   Duration  15.08s (transform 2.82s, setup 0ms, import 6.20s, tests 10.17s, environment 18.51s)

$ node scripts/next-build-safe.mjs
[build] linux/arm64 detected: using webpack + SWC wasm fallback to avoid native next-swc crash
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
  Skipping creating a lockfile at /home/sebas/work/projects/ballbox/.next/lock because we're using WASM bindings
▲ Next.js 16.2.1 (webpack)
- Environments: .env.local, .env.production

  Creating an optimized production build ...
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
✓ Compiled successfully in 9.3s
  Skipping validation of types
  Finished TypeScript config validation in 10ms ...
  Collecting page data using 3 workers ...
  Generating static pages using 3 workers (0/56) ...
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
  Generating static pages using 3 workers (14/56)
  Generating static pages using 3 workers (28/56)
  Generating static pages using 3 workers (42/56)
✓ Generating static pages using 3 workers (56/56) in 942ms
  Finalizing page optimization ...
  Collecting build traces ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /admin
├ ƒ /admin/login
├ ƒ /admin/network
├ ƒ /admin/payments
├ ƒ /admin/players
├ ƒ /admin/screens
├ ƒ /admin/sports
├ ƒ /admin/tvs
├ ƒ /api/account/session
├ ƒ /api/admin/logout
├ ƒ /api/admin/payments/operations
├ ƒ /api/admin/payments/sessions
├ ƒ /api/admin/payments/sessions/[sessionId]
├ ƒ /api/admin/players
├ ƒ /api/admin/players/[playerId]
├ ƒ /api/admin/players/[playerId]/memberships
├ ƒ /api/admin/players/memberships/[membershipId]
├ ƒ /api/admin/session
├ ƒ /api/admin/sports/classes
├ ƒ /api/admin/sports/classes/[classId]
├ ƒ /api/admin/sports/coaches
├ ƒ /api/admin/sports/coaches/[coachId]
├ ƒ /api/admin/sports/open-matches
├ ƒ /api/admin/sports/open-matches/[matchId]
├ ƒ /api/admin/sports/operations
├ ƒ /api/admin/summary
├ ƒ /api/admin/tvs/assignments/[assignmentId]
├ ƒ /api/admin/tvs/campaigns
├ ƒ /api/admin/tvs/campaigns/[campaignId]
├ ƒ /api/admin/tvs/campaigns/[campaignId]/assignments
├ ƒ /api/admin/tvs/campaigns/[campaignId]/creatives
├ ƒ /api/admin/tvs/creatives/[creativeId]
├ ƒ /api/admin/tvs/operations
├ ƒ /api/admin/tvs/screens
├ ƒ /api/admin/tvs/screens/[screenId]
├ ƒ /api/admin/tvs/tv-devices
├ ƒ /api/admin/tvs/tv-devices/[tvDeviceId]
├ ƒ /api/atc-public/availability/sportclubs/[sportclubId]
├ ƒ /api/classes/logout
├ ƒ /api/classes/offers
├ ƒ /api/classes/offers/[offerId]
├ ƒ /api/classes/requests
├ ƒ /api/classes/session
├ ƒ /api/clubs/accounts
├ ƒ /api/clubs/coaches
├ ƒ /api/clubs/coaches/[coachId]
├ ƒ /api/clubs/invites
├ ƒ /api/clubs/logout
├ ƒ /api/clubs/manual-classes
├ ƒ /api/clubs/me
├ ƒ /api/clubs/players
├ ƒ /api/clubs/session
├ ƒ /api/clubs/venues
├ ƒ /api/clubs/venues/[venueId]
├ ƒ /api/coaches/accounts
├ ƒ /api/coaches/availability
├ ƒ /api/coaches/clubs
├ ƒ /api/coaches/invites/[token]
├ ƒ /api/coaches/logout
├ ƒ /api/coaches/me
├ ƒ /api/coaches/session
├ ƒ /api/coaches/students
├ ƒ /api/coaches/students/[bookingId]
├ ƒ /api/coaches/students/players
├ ƒ /api/coaches/students/players/[playerId]
├ ƒ /api/integrations/payments/approved
├ ƒ /api/integrations/payments/health
├ ƒ /api/integrations/payments/orders
├ ƒ /api/integrations/payments/sessions
├ ƒ /api/machines/[machineId]/ads-manifest
├ ƒ /api/network
├ ƒ /api/network/[clubId]
├ ƒ /api/network/[clubId]/venues
├ ƒ /api/network/[clubId]/venues/[venueId]
├ ƒ /api/network/venues/[venueId]/vending-machines
├ ƒ /api/network/venues/[venueId]/vending-machines/[vendingMachineId]
├ ƒ /api/players
├ ƒ /api/sports
├ ƒ /api/tvs/health
├ ƒ /api/tvs/heartbeat
├ ƒ /api/tvs/impression
├ ƒ /api/tvs/manifest/[screenId]
├ ƒ /api/tvs/screens
├ ƒ /classes
├ ƒ /classes-by-date
├ ƒ /classes/[offerId]
├ ƒ /classes/login
├ ƒ /classes/me
├ ƒ /clubs
├ ƒ /clubs/coaches
├ ƒ /clubs/venues/[venueId]
├ ○ /coaches
├ ƒ /coaches/settings
├ ○ /manifest.webmanifest
├ ƒ /notification/mercadopago
├ ○ /offline
└ ƒ /player/[screenId]

ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

pre-push: all checks passed.
To https://github.com/sebasfavaron/ballbox.git
   50a0b45..45221a8  main -> main


building file list ... done
README.md
app/src/main/java/app/ballbox/machineadssync/MainActivity.kt

sent 3,936 bytes  received 126 bytes  2,708.00 bytes/sec
total size is 54,543  speedup is 13.43


> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:dataBindingMergeDependencyArtifactsDebug UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:dataBindingGenBaseClassesDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:mergeDebugShaders UP-TO-DATE
> Task :app:compileDebugShaders NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:mergeDebugStartupProfile UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs NO-SOURCE
> Task :app:stripDebugDebugSymbols NO-SOURCE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:compileDebugKotlin
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:dexBuilderDebug
> Task :app:mergeDebugGlobalSynthetics UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:mergeProjectDexDebug
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect UP-TO-DATE
> Task :app:assembleDebug

BUILD SUCCESSFUL in 4s
38 actionable tasks: 5 executed, 33 up-to-date
package: name='app.ballbox.machineadssync' versionCode='6' versionName='0.1.5' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
sdkVersion:'25'
targetSdkVersion:'34'


receiving file list ... 1 file to consider
app-debug.apk

              0   0%    0.00kB/s    0:00:00
      5,614,456 100%   15.61MB/s    0:00:00 (xfr#1, to-chk=0/1)

sent 42 bytes  received 5,616,685 bytes  3,744,484.67 bytes/sec
total size is 5,614,456  speedup is 1.00


Listo.

## Backend
Push hecho:
- commit `45221a8`

Cambios:
- RFC en docs
- manifest ahora sale de config repo-backed
- config actual en:
  - `data/machine-ads/2601070188.json`
- manifest incluye:
  - `targetRootHint`
  - `managedDirs`

## APK
Rebuild hecho con default corregido:
- target root default ahora: `/sdcard/TcnFolder`

Nueva APK:
https://ballbox-first.emperor-ratio.ts.net/files/outputs/ballbox-machine-ads-sync/ballbox-machine-ads-sync-debug-0.1.5-api25-tcnfolder.apk

## Estado
Ahora ya tenés:
- backend más cerca de API-driven
- sidecar apuntando por default al root correcto
- prune funcionando

Siguiente paso fuerte:
- endpoint admin para editar `data/machine-ads/...` sin tocar repo manualmente
- después upload/admin de assets

Si querés, sigo con ese endpoint admin.


ok la app nueva anda, ahora proba cambiar el video de la api por este para la fullscreen (te acordas que carpeta es?) /Users/sebas/Downloads/14597490_2160_3840_60fps.mp4


laptop-run 'ls -lh /Users/sebas/Downloads/14597490_2160_3840_60fps.mp4 && ffprobe -v error -show_entries format=duration,size:stream=codec_name,width,height,avg_frame_rate -of json /Users/sebas/Downloads/14597490_2160_3840_60fps.mp4'


-rw-r--r--@ 1 sebas  staff    30M Jun  8 16:59 /Users/sebas/Downloads/14597490_2160_3840_60fps.mp4
zsh:1: command not found: ffprobe

Command exited with code 127


**Copying files to Pi**

I think I need to copy some files from my laptop to the Raspberry Pi. Maybe using a method like SSH or SCP would work well for that? After the transfer, if I need to analyze media files, I can run ffprobe locally on the Pi. It seems straightforward, but I want to double-check everything to ensure I don't miss any files. Let’s proceed with the copy part first!

mkdir -p /home/sebas/outputs/inbox/fullscreen-test && HOST=brag-pipe-fur-kneel.emperor-ratio.ts.net && laptop-rsync ${HOST}:/Users/sebas/Downloads/14597490_2160_3840_60fps.mp4 /home/sebas/outputs/inbox/fullscreen-test/


receiving file list ... 1 file to consider
14597490_2160_3840_60fps.mp4

              0   0%    0.00kB/s    0:00:00
     13,172,736  41%   12.56MB/s    0:00:01
     25,296,896  79%   12.06MB/s    0:00:00
     31,922,876 100%   11.93MB/s    0:00:02 (xfr#1, to-chk=0/1)

sent 42 bytes  received 31,934,756 bytes  9,124,228.00 bytes/sec
total size is 31,922,876  speedup is 1.00


ffprobe -v error -show_entries format=duration,size:stream=codec_name,width,height,avg_frame_rate -of json /home/sebas/outputs/inbox/fullscreen-test/14597490_2160_3840_60fps.mp4


**Updating asset configurations**

I see that the user wants me to update the asset, even though it might be too big. I should mention the folder is ImageScreen. I need to change the API to repo config and update the asset file accordingly. I could keep the small video unchanged. Since the user requested this, I’ll commit and push to the main branch. It seems I'll also need to rename the path, copy it into the public folder, and update the JSON entry for ImageScreen. Additionally, I might prune the old ImageScreen file via sidecar. Let's get to it!


-rw-r--r-- 1 sebas sebas 31M Jun  8 17:00 /home/sebas/work/projects/ballbox/public/machine-assets/2601070188/ImageScreen/14597490_2160_3840_60fps.mp4
923eaa847950f69ddf82c913b451bffcbd37940cc39111c3210fb23a77ba6274  /home/sebas/work/projects/ballbox/public/machine-assets/2601070188/ImageScreen/14597490_2160_3840_60fps.mp4


Successfully replaced 1 block(s) in /home/sebas/work/projects/ballbox/data/machine-ads/2601070188.json.

    ...
  9       "publicPath": "/machine-assets/2601070188/VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4",
 10       "restartRequired": "app"
 11     },
 12     {
-13       "target": "ImageScreen/See You Again (Cover)--音悦Tai.mp4",
-14       "publicPath": "/machine-assets/2601070188/ImageScreen/See You Again (Cover)--音悦Tai.mp4",
+13       "target": "ImageScreen/14597490_2160_3840_60fps.mp4",
+14       "publicPath": "/machine-assets/2601070188/ImageScreen/14597490_2160_3840_60fps.mp4",
 15       "restartRequired": "app"
 16     }
 17   ]
 18 }

--- /home/sebas/work/projects/ballbox/data/machine-ads/2601070188.json
+++ /home/sebas/work/projects/ballbox/data/machine-ads/2601070188.json
@@ -9,10 +9,10 @@
       "publicPath": "/machine-assets/2601070188/VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4",
       "restartRequired": "app"
     },
     {
-      "target": "ImageScreen/See You Again (Cover)--音悦Tai.mp4",
-      "publicPath": "/machine-assets/2601070188/ImageScreen/See You Again (Cover)--音悦Tai.mp4",
+      "target": "ImageScreen/14597490_2160_3840_60fps.mp4",
+      "publicPath": "/machine-assets/2601070188/ImageScreen/14597490_2160_3840_60fps.mp4",
       "restartRequired": "app"
     }
   ]
 }


(node:248667) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///home/sebas/work/projects/ballbox/lib/machine-ads-manifest.ts is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /home/sebas/work/projects/ballbox/package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
{
  "machineId": "2601070188",
  "version": "VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4:37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea|ImageScreen/14597490_2160_3840_60fps.mp4:923eaa847950f69ddf82c913b451bffcbd37940cc39111c3210fb23a77ba6274",
  "generatedAt": "2026-06-08T20:00:58.016Z",
  "restartPolicy": "app",
  "targetRootHint": "/sdcard/TcnFolder",
  "managedDirs": [
    "ImageScreen",
    "VideoAndImageAd"
  ],
  "files": [
    {
      "target": "VideoAndImageAd/See You Again (Cover)--音悦Tai.mp4",
      "url": "https://ballbox.app/machine-assets/2601070188/VideoAndImageAd/See%20You%20Again%20(Cover)--%E9%9F%B3%E6%82%A6Tai.mp4",
      "sha256": "37ac7d1a7445dcd9216d255f8f20184ff52c92f881805158fc25e0335d26e2ea",
      "bytes": 2836068,
      "restartRequired": "app"
    },
    {
      "target": "ImageScreen/14597490_2160_3840_60fps.mp4",
      "url": "https://ballbox.app/machine-assets/2601070188/ImageScreen/14597490_2160_3840_60fps.mp4",
      "sha256": "923eaa847950f69ddf82c913b451bffcbd37940cc39111c3210fb23a77ba6274",
      "bytes": 31922876,
      "restartRequired": "app"
    }
  ]
}


**Deciding on an API push**

I’m considering whether I need to push the main changes explicitly. Since the user asked about making changes via the API, it seems necessary to do so. To efficiently test on my machine, I’ll need to deploy. So, I think it’s clear: yes, I'll go ahead and push the changes! That should set everything up properly for testing.

set -e
cd /home/sebas/work/projects/ballbox
git add data/machine-ads/2601070188.json 'public/machine-assets/2601070188/ImageScreen/14597490_2160_3840_60fps.mp4'
git commit -m 'Switch fullscreen machine slot to new test video'
git push origin main


[main e452e6d] Switch fullscreen machine slot to new test video
 2 files changed, 2 insertions(+), 2 deletions(-)
 create mode 100644 public/machine-assets/2601070188/ImageScreen/14597490_2160_3840_60fps.mp4
pre-push: pushing main, running quality checks...
$ node scripts/check-doc-references.mjs
Docs reference check passed for README.md, PLAN.md, and docs/index.md
$ ./scripts/db-local-up.sh
Local Postgres already ready.
$ ./scripts/prisma-migrate-deploy.sh
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "ballbox_local", schema "public" at "127.0.0.1:5432"

2 migrations found in prisma/migrations

No pending migrations to apply.
$ ./scripts/prisma-check-migrations.sh
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "ballbox_local", schema "public" at "127.0.0.1:5432"

2 migrations found in prisma/migrations

Database schema is up to date!
Prisma schema loaded from prisma/schema.prisma
The schema at prisma/schema.prisma is valid 🚀
$ eslint .
$ rm -rf .next/types .next/dev/types
$ tsc --noEmit --incremental false
$ vitest run tests/contracts

 RUN  v4.1.0 /home/sebas/work/projects/ballbox

 ✓ tests/contracts/players/players-admin-player-card.test.tsx (2 tests) 513ms
     ✓ renders the empty memberships state for a player without memberships  411ms
 ✓ tests/contracts/classes/classes-request-form.test.tsx (4 tests) 1060ms
     ✓ submits the selected visible slot as part of the request  597ms
 ✓ tests/contracts/coaches/routes.test.ts (7 tests) 348ms
 ✓ tests/contracts/weekly-availability-grid.test.tsx (2 tests) 2978ms
     ✓ exposes focusable button semantics for keyboard users and toggles an open slot  1826ms
     ✓ marks closed slots as aria-disabled and ignores pointer interaction  1149ms
 ✓ tests/contracts/network/network-admin-venue-card.test.tsx (2 tests) 471ms
     ✓ updates the targeted machine active flag through setClubs  364ms
 ✓ tests/contracts/coaches/backend.test.ts (1 test) 311ms
     ✓ keeps coach profile venue summarized while preserving assigned venue detail  309ms
 ✓ tests/contracts/coaches/students-routes.test.ts (5 tests) 339ms
 ✓ tests/contracts/players/players-admin-create-player-section.test.tsx (1 test) 347ms
     ✓ renders the extracted section shell and forwards player form updates  345ms
 ✓ tests/contracts/payments/auth.test.ts (2 tests) 349ms
 ✓ tests/contracts/clubs/routes.test.ts (9 tests) 350ms
 ✓ tests/contracts/tvs/routes.test.ts (10 tests) 216ms
 ✓ tests/contracts/tvs/admin-routes.test.ts (7 tests) 226ms
 ✓ tests/contracts/classes/routes.test.ts (5 tests) 224ms
 ✓ tests/contracts/tvs/backend-modules.test.ts (14 tests) 186ms
 ✓ tests/contracts/payments/orders-route.test.ts (2 tests) 209ms
 ✓ tests/contracts/accounts/routes.test.ts (3 tests) 216ms
 ✓ tests/contracts/payments/admin-routes.test.ts (6 tests) 179ms
 ✓ tests/contracts/players/admin-routes.test.ts (9 tests) 309ms
 ✓ tests/contracts/network/backend-modules.test.ts (6 tests) 64ms
 ✓ tests/contracts/tvs/screen-player.test.tsx (3 tests) 249ms
 ✓ tests/contracts/network/routes.test.ts (12 tests) 191ms
 ✓ tests/contracts/players/routes.test.ts (1 test) 117ms
 ✓ tests/contracts/network/network-admin-club-card.test.tsx (2 tests) 179ms
 ✓ tests/contracts/sports/routes.test.ts (8 tests) 92ms
 ✓ tests/contracts/players/players-admin-stats.test.tsx (1 test) 86ms
 ✓ tests/contracts/players/backend-modules.test.ts (3 tests) 106ms
 ✓ tests/contracts/tvs/backend-runtime.test.ts (4 tests) 126ms
 ✓ tests/contracts/players/players-admin-player-list.test.tsx (2 tests) 78ms
 ✓ tests/contracts/network/network-admin-shell.test.tsx (1 test) 62ms
 ✓ tests/contracts/network/network-admin-create-club-section.test.tsx (1 test) 122ms
 ✓ tests/contracts/classes/by-date.test.ts (1 test) 8ms
 ✓ tests/contracts/players/players-admin-shell.test.tsx (1 test) 47ms
 ✓ tests/contracts/network/network-admin-club-list.test.tsx (1 test) 39ms

 Test Files  33 passed (33)
      Tests  138 passed (138)
   Start at  17:01:50
   Duration  15.18s (transform 2.90s, setup 0ms, import 6.54s, tests 10.40s, environment 18.35s)

$ node scripts/next-build-safe.mjs
[build] linux/arm64 detected: using webpack + SWC wasm fallback to avoid native next-swc crash
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
  Skipping creating a lockfile at /home/sebas/work/projects/ballbox/.next/lock because we're using WASM bindings
▲ Next.js 16.2.1 (webpack)
- Environments: .env.local, .env.production

  Creating an optimized production build ...
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
✓ Compiled successfully in 8.8s
  Skipping validation of types
  Finished TypeScript config validation in 8ms ...
  Collecting page data using 3 workers ...
  Generating static pages using 3 workers (0/56) ...
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
  Generating static pages using 3 workers (14/56)
  Generating static pages using 3 workers (28/56)
  Generating static pages using 3 workers (42/56)
✓ Generating static pages using 3 workers (56/56) in 868ms
  Finalizing page optimization ...
  Collecting build traces ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /admin
├ ƒ /admin/login
├ ƒ /admin/network
├ ƒ /admin/payments
├ ƒ /admin/players
├ ƒ /admin/screens
├ ƒ /admin/sports
├ ƒ /admin/tvs
├ ƒ /api/account/session
├ ƒ /api/admin/logout
├ ƒ /api/admin/payments/operations
├ ƒ /api/admin/payments/sessions
├ ƒ /api/admin/payments/sessions/[sessionId]
├ ƒ /api/admin/players
├ ƒ /api/admin/players/[playerId]
├ ƒ /api/admin/players/[playerId]/memberships
├ ƒ /api/admin/players/memberships/[membershipId]
├ ƒ /api/admin/session
├ ƒ /api/admin/sports/classes
├ ƒ /api/admin/sports/classes/[classId]
├ ƒ /api/admin/sports/coaches
├ ƒ /api/admin/sports/coaches/[coachId]
├ ƒ /api/admin/sports/open-matches
├ ƒ /api/admin/sports/open-matches/[matchId]
├ ƒ /api/admin/sports/operations
├ ƒ /api/admin/summary
├ ƒ /api/admin/tvs/assignments/[assignmentId]
├ ƒ /api/admin/tvs/campaigns
├ ƒ /api/admin/tvs/campaigns/[campaignId]
├ ƒ /api/admin/tvs/campaigns/[campaignId]/assignments
├ ƒ /api/admin/tvs/campaigns/[campaignId]/creatives
├ ƒ /api/admin/tvs/creatives/[creativeId]
├ ƒ /api/admin/tvs/operations
├ ƒ /api/admin/tvs/screens
├ ƒ /api/admin/tvs/screens/[screenId]
├ ƒ /api/admin/tvs/tv-devices
├ ƒ /api/admin/tvs/tv-devices/[tvDeviceId]
├ ƒ /api/atc-public/availability/sportclubs/[sportclubId]
├ ƒ /api/classes/logout
├ ƒ /api/classes/offers
├ ƒ /api/classes/offers/[offerId]
├ ƒ /api/classes/requests
├ ƒ /api/classes/session
├ ƒ /api/clubs/accounts
├ ƒ /api/clubs/coaches
├ ƒ /api/clubs/coaches/[coachId]
├ ƒ /api/clubs/invites
├ ƒ /api/clubs/logout
├ ƒ /api/clubs/manual-classes
├ ƒ /api/clubs/me
├ ƒ /api/clubs/players
├ ƒ /api/clubs/session
├ ƒ /api/clubs/venues
├ ƒ /api/clubs/venues/[venueId]
├ ƒ /api/coaches/accounts
├ ƒ /api/coaches/availability
├ ƒ /api/coaches/clubs
├ ƒ /api/coaches/invites/[token]
├ ƒ /api/coaches/logout
├ ƒ /api/coaches/me
├ ƒ /api/coaches/session
├ ƒ /api/coaches/students
├ ƒ /api/coaches/students/[bookingId]
├ ƒ /api/coaches/students/players
├ ƒ /api/coaches/students/players/[playerId]
├ ƒ /api/integrations/payments/approved
├ ƒ /api/integrations/payments/health
├ ƒ /api/integrations/payments/orders
├ ƒ /api/integrations/payments/sessions
├ ƒ /api/machines/[machineId]/ads-manifest
├ ƒ /api/network
├ ƒ /api/network/[clubId]
├ ƒ /api/network/[clubId]/venues
├ ƒ /api/network/[clubId]/venues/[venueId]
├ ƒ /api/network/venues/[venueId]/vending-machines
├ ƒ /api/network/venues/[venueId]/vending-machines/[vendingMachineId]
├ ƒ /api/players
├ ƒ /api/sports
├ ƒ /api/tvs/health
├ ƒ /api/tvs/heartbeat
├ ƒ /api/tvs/impression
├ ƒ /api/tvs/manifest/[screenId]
├ ƒ /api/tvs/screens
├ ƒ /classes
├ ƒ /classes-by-date
├ ƒ /classes/[offerId]
├ ƒ /classes/login
├ ƒ /classes/me
├ ƒ /clubs
├ ƒ /clubs/coaches
├ ƒ /clubs/venues/[venueId]
├ ○ /coaches
├ ƒ /coaches/settings
├ ○ /manifest.webmanifest
├ ƒ /notification/mercadopago
├ ○ /offline
└ ƒ /player/[screenId]

ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

pre-push: all checks passed.
To https://github.com/sebasfavaron/ballbox.git
   45221a8..e452e6d  main -> main


Sí. Fullscreen = `ImageScreen/`.

Ya lo cambié en la API:
- `ImageScreen` ahora apunta a:
  - `14597490_2160_3840_60fps.mp4`
- `VideoAndImageAd` quedó igual

Push hecho:
- commit `e452e6d`

Dato:
- ese archivo pesa ~`31.9 MB`
- duración `14.4s`
- resolución `2160x3840`
- `60fps`

No necesitás reinstalar APK.
Solo corré sync de nuevo en la máquina.

Esperado:
- baja el video nuevo en `ImageScreen`
- prunea el mp4 fullscreen viejo
- el video chico arriba queda como estaba

Si querés, después de probarlo, te lo optimizo si resulta demasiado pesado o si la máquina lo reproduce mal.


anda bien (aunque requiere restart de o el sistema o la app, anota eso para el plan de integrar sync al clon de la app), pero pasa algo raro. de los 14s del video se reproducen los primeros 5 en camara lenta. setting o throttle?


edit

## Reality confirmed on device
- Installed sidecar can sync successfully on Android 7.1.2 once `minSdk` and storage behavior are fixed.
- Correct target root on the machine is `/sdcard/TcnFolder`.
- `ImageScreen/` is the fullscreen standby slot.
- `VideoAndImageAd/` is the smaller top video slot.
- Both folders accept looped video files.
- Current sidecar prune behavior can delete stale files inside managed folders.
- Freshly synced media may require restarting the main vending app or rebooting the Android system before visible playback updates reliably apply.
- High-resolution/high-fps fullscreen video can decode poorly on the machine; a 2160x3840 60fps test clip played in slow motion for the first seconds, so practical delivery profiles should be constrained for device decode reality.

## Safety constraints
- prune only inside explicitly managed directories
- never allow arbitrary absolute target paths from user input
- validate machine target prefixes against allowlist
- keep manifest truthful: only list assets that exist and hash correctly
- add operator-visible guidance that sync success does not guarantee live swap until app restart/reboot if the vendor runtime caches media

## Immediate implementation steps
1. add machine config JSON files under repo data directory
2. refactor manifest builder to read config instead of hardcoded map
3. include `managedDirs` and `targetRootHint` in manifest response
4. update Android sidecar default target root to `/sdcard/TcnFolder`
5. later add admin write path

## Immediate implementation steps
1. add machine config JSON files under repo data directory
2. refactor manifest builder to read config instead of hardcoded map
3. include `managedDirs` and `targetRootHint` in manifest response
4. update Android sidecar default target root to `/sdcard/TcnFolder`
5. constrain fullscreen transcode defaults to machine-safe profiles such as lower resolution and 24/30fps instead of assuming original source files are decodable in real time
6. later add admin write path


Could not find edits[2] in /home/sebas/work/projects/ballbox/docs/tcn-api-driven-machine-ads-rfc-2026-06-08.md. The oldText must match exactly including all whitespace and newlines.


# TCN API-driven machine ads RFC

## Goal
Move Ballbox machine ad/media control from hardcoded repo mappings to API-managed desired state so future changes do not require rebuilding or reinstalling the Android sidecar.

## Reality confirmed on device
- Installed sidecar can sync successfully on Android 7.1.2 once `minSdk` and storage behavior are fixed.
- Correct target root on the machine is `/sdcard/TcnFolder`.
- `ImageScreen/` is the fullscreen standby slot.
- `VideoAndImageAd/` is the smaller top video slot.
- Both folders accept looped video files.
- Current sidecar prune behavior can delete stale files inside managed folders.

## Problem with current MVP
Current backend manifest generation is hardcoded in `lib/machine-ads-manifest.ts`.
That means every content change currently needs a code edit and deploy.
This is too slow for operator use and does not scale to arbitrary file replacement/prune.

## Target state
Ballbox should own a per-machine desired state model.
The sidecar should only:
1. fetch manifest
2. download listed files
3. verify hashes
4. write exact target paths
5. prune stale files inside managed directories

Changing content should mean changing Ballbox backend state, not Android code.

## Proposed manifest shape
```json
{
  "machineId": "2601070188",
  "generatedAt": "2026-06-08T00:00:00.000Z",
  "version": "...",
  "restartPolicy": "app",
  "targetRootHint": "/sdcard/TcnFolder",
  "managedDirs": ["ImageScreen", "VideoAndImageAd"],
  "files": [
    {
      "target": "ImageScreen/foo.mp4",
      "url": "https://ballbox.app/machine-assets/.../foo.mp4",
      "sha256": "...",
      "bytes": 123,
      "restartRequired": "app"
    }
  ]
}
```

## Desired backend model
Short term:
- file-backed machine config in repo
- one JSON document per machine
- assets still served from `public/machine-assets/...`

Medium term:
- DB-backed machine asset entries
- admin-authenticated upload and assignment UI/API
- no code edits required for normal operator changes

## Minimal data model
Per machine config:
- `machineId`
- `targetRootHint`
- `managedDirs[]`
- `restartPolicy`
- `entries[]`

Per entry:
- `target`
- `publicPath`
- `restartRequired`
- optional future metadata: `label`, `active`, `slot`, `contentType`

## Rollout plan
### Phase 1
- move hardcoded manifest config into file-backed JSON
- expose `managedDirs` and `targetRootHint` in manifest
- update sidecar default target root to `/sdcard/TcnFolder`
- keep sidecar reinstalls rare

### Phase 2
- add admin mutation path in Ballbox backend
- validate target paths against allowed machine directories
- allow add/replace/remove entries per machine

### Phase 3
- add upload flow and stored asset references
- optionally track asset history and rollback

## Safety constraints
- prune only inside explicitly managed directories
- never allow arbitrary absolute target paths from user input
- validate machine target prefixes against allowlist
- keep manifest truthful: only list assets that exist and hash correctly

## Current implementation decision
Start with a repo-backed config file because it is the cheapest path from hardcoded code to API-driven desired state.
That gives immediate leverage while keeping migration path open to DB/admin UI.

## Immediate implementation steps
1. add machine config JSON files under repo data directory
2. refactor manifest builder to read config instead of hardcoded map
3. include `managedDirs` and `targetRootHint` in manifest response
4. update Android sidecar default root to `/sdcard/TcnFolder`
5. later add admin write path


## Reality confirmed on device
- Installed sidecar can sync successfully on Android 7.1.2 once `minSdk` and storage behavior are fixed.
- Correct target root on the machine is `/sdcard/TcnFolder`.
- `ImageScreen/` is the fullscreen standby slot.
- `VideoAndImageAd/` is the smaller top video slot.
- Both folders accept looped video files.
- Current sidecar prune behavior can delete stale files inside managed folders.
- Freshly synced media may require restarting the main vending app or rebooting Android before visible playback updates reliably apply.
- High-resolution/high-fps fullscreen video can decode poorly on the machine; a 2160x3840 60fps test clip played in slow motion for the first seconds, so practical delivery profiles must be constrained to device-safe formats.

## Safety constraints
- prune only inside explicitly managed directories
- never allow arbitrary absolute target paths from user input
- validate machine target prefixes against allowlist
- keep manifest truthful: only list assets that exist and hash correctly
- operator UX must make restart/reboot requirements explicit when runtime cache prevents hot swap

## Immediate implementation steps
1. add machine config JSON files under repo data directory
2. refactor manifest builder to read config instead of hardcoded map
3. include `managedDirs` and `targetRootHint` in manifest response
4. update Android sidecar default root to `/sdcard/TcnFolder`
5. later add admin write path

## Immediate implementation steps
1. add machine config JSON files under repo data directory
2. refactor manifest builder to read config instead of hardcoded map
3. include `managedDirs` and `targetRootHint` in manifest response
4. update Android sidecar default root to `/sdcard/TcnFolder`
5. constrain fullscreen transcode defaults to machine-safe profiles like 24/30fps and lower resolution instead of assuming source originals are decodable in real time
6. later add admin write path