@pictor-sdk/video-android 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- package/src/useVideoSurface.ts +282 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import java.io.File
|
|
5
|
+
import java.security.MessageDigest
|
|
6
|
+
import java.util.concurrent.Executors
|
|
7
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
8
|
+
import org.json.JSONArray
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The audio a preview plays, prepared once and reused.
|
|
13
|
+
*
|
|
14
|
+
* ## Why a file, and why the same mixer
|
|
15
|
+
*
|
|
16
|
+
* Preview and export must agree about what a project sounds like, and the only
|
|
17
|
+
* way to guarantee that is for both to run the same code over the same plan.
|
|
18
|
+
* [AudioMixer] already sums spans, applies gains and ramps, stretches by clip
|
|
19
|
+
* speed and saturates once — so preview asks it for exactly what an export
|
|
20
|
+
* asks, and plays the result. Reimplementing any of that in the player, or in
|
|
21
|
+
* React, would be a second definition of the sound.
|
|
22
|
+
*
|
|
23
|
+
* The output is an app-private file rather than a stream because the mix is not
|
|
24
|
+
* realtime: it decodes every contributing source, re-times it and encodes once.
|
|
25
|
+
* See the latency table in the JS wrapper — a caller has to know this is
|
|
26
|
+
* preparation, not playback.
|
|
27
|
+
*
|
|
28
|
+
* ## Keyed by what the sound depends on, and nothing else
|
|
29
|
+
*
|
|
30
|
+
* The cache key is the digest of the compiled mix program, the resolved source
|
|
31
|
+
* URIs and the mixer's own configuration. That is precisely the set of things
|
|
32
|
+
* that can change the samples: selecting a clip, opening a sheet or scrolling
|
|
33
|
+
* the timeline change none of them, so they cannot invalidate a mix. A trim, a
|
|
34
|
+
* volume, a fade, a speed, a mute or a different source all change the program,
|
|
35
|
+
* so they all do.
|
|
36
|
+
*
|
|
37
|
+
* ## One job, and the newest wins
|
|
38
|
+
*
|
|
39
|
+
* A newer request cancels the one in flight. The old job unwinds through
|
|
40
|
+
* [AudioMixer.Cancelled], deletes its partial output and reports `superseded` —
|
|
41
|
+
* which the caller must not publish. Generation numbers rather than flags: a
|
|
42
|
+
* job compares the generation it was started with against the current one, so a
|
|
43
|
+
* completion that arrives late can tell that it is late.
|
|
44
|
+
*/
|
|
45
|
+
object PreviewMix {
|
|
46
|
+
|
|
47
|
+
private const val DIR = "pictor-preview"
|
|
48
|
+
private const val SUFFIX = ".m4a"
|
|
49
|
+
private const val PART_SUFFIX = ".part"
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The mixer configuration that reaches the key.
|
|
53
|
+
*
|
|
54
|
+
* Bumped when anything about how the samples are produced changes, so a
|
|
55
|
+
* cached file from an older rule is never played. It is not a schema
|
|
56
|
+
* version: the program carries its own.
|
|
57
|
+
*
|
|
58
|
+
* `v3` is the priming shift. `v1` never corrected the delay at all and plays
|
|
59
|
+
* about 43 ms late; `v2` corrected it by dropping access units and lost the
|
|
60
|
+
* first 43 ms of any sound starting at zero. Both are silently wrong rather
|
|
61
|
+
* than merely slow, which is the worse thing for a cache to be.
|
|
62
|
+
*/
|
|
63
|
+
private const val MIXER_CONFIG = "48000/2/sum-then-saturate/v3-primingshifted"
|
|
64
|
+
|
|
65
|
+
private val executor = Executors.newSingleThreadExecutor { r ->
|
|
66
|
+
Thread(r, "pictor-preview-mix").apply { isDaemon = true }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The generation a job must still match to be allowed to publish. */
|
|
70
|
+
private val generation = AtomicLong(0)
|
|
71
|
+
|
|
72
|
+
/** Bumped by [cancel] and by every new request. */
|
|
73
|
+
@Volatile private var currentJob: String? = null
|
|
74
|
+
|
|
75
|
+
/** Preparations started and finished, so an abandoned one is visible. */
|
|
76
|
+
@Volatile private var jobsStarted = 0L
|
|
77
|
+
@Volatile private var jobsFinished = 0L
|
|
78
|
+
|
|
79
|
+
fun jobCounters(): JSONObject =
|
|
80
|
+
JSONObject()
|
|
81
|
+
.put("started", jobsStarted)
|
|
82
|
+
.put("finished", jobsFinished)
|
|
83
|
+
.put("live", jobsStarted - jobsFinished)
|
|
84
|
+
.put("current", currentJob ?: "")
|
|
85
|
+
|
|
86
|
+
private fun dir(context: Context): File {
|
|
87
|
+
// `filesDir`: a cached mix that vanished mid-session would silently
|
|
88
|
+
// become "Preparing audio" again with nothing to explain it.
|
|
89
|
+
val d = File(context.filesDir, DIR)
|
|
90
|
+
if (!d.exists()) d.mkdirs()
|
|
91
|
+
return d
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The name a given sound has.
|
|
96
|
+
*
|
|
97
|
+
* Content-addressed like a managed source, and for one of the same reasons:
|
|
98
|
+
* a partial file can never wear the name of a complete one, because the
|
|
99
|
+
* name is decided before the work and the file only takes it after a
|
|
100
|
+
* successful rename.
|
|
101
|
+
*/
|
|
102
|
+
fun keyFor(programJson: String, urisJson: String): String {
|
|
103
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
104
|
+
digest.update(MIXER_CONFIG.toByteArray())
|
|
105
|
+
digest.update(programJson.toByteArray())
|
|
106
|
+
digest.update(urisJson.toByteArray())
|
|
107
|
+
return digest.digest().joinToString("") { "%02x".format(it) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Where a prepared mix lives, if it is there. */
|
|
111
|
+
fun cached(context: Context, key: String): File? {
|
|
112
|
+
val file = File(dir(context), key + SUFFIX)
|
|
113
|
+
return if (file.exists() && file.length() > 0) file else null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Prepare the mix for this program, or return the one already prepared.
|
|
118
|
+
*
|
|
119
|
+
* Blocking: the caller runs it on [executor] via the module. Returns
|
|
120
|
+
* `{ok, uri, key, cached, durationUs, byteLength}`, or `{ok:false, ...}`
|
|
121
|
+
* with `superseded` or `cancelled` set when it stopped rather than failed.
|
|
122
|
+
*/
|
|
123
|
+
fun prepare(context: Context, jobId: String, programJson: String, urisJson: String): String =
|
|
124
|
+
try {
|
|
125
|
+
prepareInner(context, jobId, programJson, urisJson)
|
|
126
|
+
} finally {
|
|
127
|
+
/*
|
|
128
|
+
Every exit, not just the cancelled one.
|
|
129
|
+
|
|
130
|
+
The first version of this counter only closed a job in `cancel`,
|
|
131
|
+
so a run of sixteen rebuilds reported sixteen live jobs and no
|
|
132
|
+
finished ones — a measurement artefact that reads exactly like a
|
|
133
|
+
leak. `finally` is what makes "started" and "finished" answer the
|
|
134
|
+
same question.
|
|
135
|
+
*/
|
|
136
|
+
jobsFinished += 1
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private fun prepareInner(
|
|
140
|
+
context: Context,
|
|
141
|
+
jobId: String,
|
|
142
|
+
programJson: String,
|
|
143
|
+
urisJson: String,
|
|
144
|
+
): String {
|
|
145
|
+
val out = JSONObject()
|
|
146
|
+
val key = keyFor(programJson, urisJson)
|
|
147
|
+
val mine = generation.incrementAndGet()
|
|
148
|
+
currentJob = jobId
|
|
149
|
+
jobsStarted += 1
|
|
150
|
+
PreviewTrace.record("mix.start") {
|
|
151
|
+
JSONObject().put("job", jobId).put("key", key.take(12)).put("gen", mine)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
cached(context, key)?.let { file ->
|
|
155
|
+
// Already prepared. Still checked against the generation: a request
|
|
156
|
+
// superseded before it got here must not publish, even instantly.
|
|
157
|
+
if (generation.get() != mine) {
|
|
158
|
+
return out.put("ok", false).put("superseded", true).put("key", key).toString()
|
|
159
|
+
}
|
|
160
|
+
PreviewTrace.record("mix.cached") {
|
|
161
|
+
JSONObject().put("key", key.take(12)).put("gen", mine)
|
|
162
|
+
}
|
|
163
|
+
return out
|
|
164
|
+
.put("ok", true)
|
|
165
|
+
.put("uri", android.net.Uri.fromFile(file).toString())
|
|
166
|
+
.put("key", key)
|
|
167
|
+
.put("cached", true)
|
|
168
|
+
.put("byteLength", file.length())
|
|
169
|
+
.toString()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
val part = File(dir(context), "$key$SUFFIX$PART_SUFFIX")
|
|
173
|
+
CodecLedger.currentJob = jobId
|
|
174
|
+
try {
|
|
175
|
+
val result = JSONObject(
|
|
176
|
+
AudioMixer.mix(
|
|
177
|
+
context,
|
|
178
|
+
programJson,
|
|
179
|
+
urisJson,
|
|
180
|
+
part,
|
|
181
|
+
// Cancelled the moment a newer request arrives. Checked in
|
|
182
|
+
// every loop that can run for seconds, so superseding does
|
|
183
|
+
// not wait out a decode and an encode.
|
|
184
|
+
keepGoing = { generation.get() == mine },
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
if (!result.optBoolean("ok", false)) {
|
|
188
|
+
runCatching { part.delete() }
|
|
189
|
+
return out
|
|
190
|
+
.put("ok", false)
|
|
191
|
+
.put("key", key)
|
|
192
|
+
.put("error", result.optString("error", "mix failed"))
|
|
193
|
+
.toString()
|
|
194
|
+
}
|
|
195
|
+
} catch (e: AudioMixer.Cancelled) {
|
|
196
|
+
PreviewTrace.record("mix.superseded") {
|
|
197
|
+
JSONObject().put("key", key.take(12)).put("gen", mine).put("at", "mix")
|
|
198
|
+
}
|
|
199
|
+
// Superseded or cancelled. The partial goes with it: a half-encoded
|
|
200
|
+
// file under a name nothing will ask for is storage nobody can
|
|
201
|
+
// account for.
|
|
202
|
+
runCatching { part.delete() }
|
|
203
|
+
return out.put("ok", false).put("superseded", true).put("key", key).toString()
|
|
204
|
+
} catch (e: Throwable) {
|
|
205
|
+
runCatching { part.delete() }
|
|
206
|
+
return out
|
|
207
|
+
.put("ok", false)
|
|
208
|
+
.put("key", key)
|
|
209
|
+
.put("error", e.javaClass.simpleName + (e.message?.let { ": $it" } ?: ""))
|
|
210
|
+
.toString()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (generation.get() != mine) {
|
|
214
|
+
// Finished, but the answer is to a question nobody is asking any
|
|
215
|
+
// more. Never published, and never left behind.
|
|
216
|
+
PreviewTrace.record("mix.superseded") {
|
|
217
|
+
JSONObject().put("key", key.take(12)).put("gen", mine).put("at", "afterMix")
|
|
218
|
+
}
|
|
219
|
+
runCatching { part.delete() }
|
|
220
|
+
return out.put("ok", false).put("superseded", true).put("key", key).toString()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/*
|
|
224
|
+
Preparation gives its codecs back before anything adopts the file.
|
|
225
|
+
|
|
226
|
+
Every codec a job creates is its own to release, and a hardware
|
|
227
|
+
instance that outlives the job is one the video decoder cannot have.
|
|
228
|
+
Reported rather than enforced with a wait: a publication that blocked
|
|
229
|
+
on a stuck codec would turn a resource leak into a hang, and the
|
|
230
|
+
count is what identifies the defect either way.
|
|
231
|
+
*/
|
|
232
|
+
CodecLedger.trimCompleted()
|
|
233
|
+
val codecs = CodecLedger.snapshot()
|
|
234
|
+
val liveAfterMix = codecs.optInt("live", -1)
|
|
235
|
+
val profile = AudioMixer.profile.json()
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
The start moves before anything adopts the file.
|
|
239
|
+
|
|
240
|
+
`AudioMixer.encode` is left exactly as it was — its output is the
|
|
241
|
+
frozen baseline the encoder rewrite was checked against, and moving
|
|
242
|
+
the trim inside it would invalidate that and change what an export
|
|
243
|
+
mixes from. So the raw mix is written, trimmed once into the name the
|
|
244
|
+
cache will use, and the raw copy removed. Corrected exactly once, at
|
|
245
|
+
the point of publication, by the same code the export mux uses.
|
|
246
|
+
*/
|
|
247
|
+
val trimAt = System.nanoTime()
|
|
248
|
+
val target = File(dir(context), key + SUFFIX)
|
|
249
|
+
/*
|
|
250
|
+
The compiled end reaches the publication, so preview follows the same
|
|
251
|
+
tail policy as an export.
|
|
252
|
+
|
|
253
|
+
Without it the mix ran 643 frames — 13.4 ms — past the timeline: the
|
|
254
|
+
encoder pads its last frame and nothing was trimming it. Marker
|
|
255
|
+
alignment says nothing about the end, which is why the duration is
|
|
256
|
+
checked separately from the offsets.
|
|
257
|
+
*/
|
|
258
|
+
val programEndUs = try {
|
|
259
|
+
org.json.JSONObject(programJson).optLong("durationUs", 0)
|
|
260
|
+
} catch (e: Exception) {
|
|
261
|
+
0L
|
|
262
|
+
}
|
|
263
|
+
val trimmed = PrimingTrim.shiftAudioFile(
|
|
264
|
+
context, part, target, programEndUs,
|
|
265
|
+
// Superseding still wins during the trim: it is short, but a rapid
|
|
266
|
+
// edit must not have to wait out a remux either.
|
|
267
|
+
keepGoing = { generation.get() == mine },
|
|
268
|
+
)
|
|
269
|
+
val trimMs = (System.nanoTime() - trimAt) / 1_000_000
|
|
270
|
+
runCatching { part.delete() }
|
|
271
|
+
if (!trimmed.optBoolean("ok", false)) {
|
|
272
|
+
// Both outputs go: the raw mix above, and whatever the trim managed
|
|
273
|
+
// to write under the published name.
|
|
274
|
+
runCatching { target.delete() }
|
|
275
|
+
if (trimmed.optBoolean("cancelled", false)) {
|
|
276
|
+
return out.put("ok", false).put("superseded", true).put("key", key).toString()
|
|
277
|
+
}
|
|
278
|
+
return out
|
|
279
|
+
.put("ok", false)
|
|
280
|
+
.put("key", key)
|
|
281
|
+
.put("error", trimmed.optString("error", "the mix could not be corrected"))
|
|
282
|
+
.toString()
|
|
283
|
+
}
|
|
284
|
+
if (generation.get() != mine) {
|
|
285
|
+
runCatching { target.delete() }
|
|
286
|
+
return out.put("ok", false).put("superseded", true).put("key", key).toString()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
PreviewTrace.record("mix.published") {
|
|
290
|
+
JSONObject().put("key", key.take(12)).put("gen", mine)
|
|
291
|
+
.put("bytes", target.length()).put("trimMs", trimMs)
|
|
292
|
+
}
|
|
293
|
+
return out
|
|
294
|
+
.put("ok", true)
|
|
295
|
+
.put("uri", android.net.Uri.fromFile(target).toString())
|
|
296
|
+
.put("key", key)
|
|
297
|
+
.put("cached", false)
|
|
298
|
+
.put("byteLength", target.length())
|
|
299
|
+
// Where the time went. A multi-second preparation is a product
|
|
300
|
+
// problem, and the first thing anyone will do about it is guess.
|
|
301
|
+
.put("profile", profile)
|
|
302
|
+
.put("trimMs", trimMs)
|
|
303
|
+
.put("codecsLiveAfterMix", liveAfterMix)
|
|
304
|
+
.put("codecsPeakLive", codecs.optInt("peakLive", -1))
|
|
305
|
+
.put("priming", trimmed)
|
|
306
|
+
.toString()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Stop whatever is preparing.
|
|
311
|
+
*
|
|
312
|
+
* Bumping the generation is the whole mechanism: the running job's
|
|
313
|
+
* `keepGoing` closure compares against it and unwinds at its next
|
|
314
|
+
* checkpoint.
|
|
315
|
+
*/
|
|
316
|
+
fun cancel(): String {
|
|
317
|
+
PreviewTrace.record("mix.cancel") {
|
|
318
|
+
JSONObject().put("job", currentJob ?: "").put("gen", generation.get())
|
|
319
|
+
}
|
|
320
|
+
generation.incrementAndGet()
|
|
321
|
+
val was = currentJob
|
|
322
|
+
currentJob = null
|
|
323
|
+
return JSONObject().put("ok", true).put("cancelled", was ?: "").toString()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Drop one prepared mix. */
|
|
327
|
+
fun release(context: Context, key: String): String {
|
|
328
|
+
if (!safeKey(key)) {
|
|
329
|
+
return JSONObject().put("ok", false).put("error", "unsafe key").toString()
|
|
330
|
+
}
|
|
331
|
+
val file = File(dir(context), key + SUFFIX)
|
|
332
|
+
val removed = file.exists() && file.delete()
|
|
333
|
+
return JSONObject().put("ok", true).put("removed", removed).toString()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Remove every prepared mix except the ones named, and every partial.
|
|
338
|
+
*
|
|
339
|
+
* The bound on the cache. A caller keeps the key it is playing and lets the
|
|
340
|
+
* rest go; nothing here decides on its own that a file is stale, for the
|
|
341
|
+
* same reason nothing deletes a managed source implicitly.
|
|
342
|
+
*/
|
|
343
|
+
fun sweep(context: Context, keep: JSONArray): String {
|
|
344
|
+
val keepSet = HashSet<String>(keep.length())
|
|
345
|
+
for (i in 0 until keep.length()) keepSet.add(keep.optString(i))
|
|
346
|
+
|
|
347
|
+
var removed = 0
|
|
348
|
+
var kept = 0
|
|
349
|
+
var partials = 0
|
|
350
|
+
var skipped = 0
|
|
351
|
+
for (file in dir(context).listFiles() ?: emptyArray()) {
|
|
352
|
+
val name = file.name
|
|
353
|
+
when {
|
|
354
|
+
name.endsWith(PART_SUFFIX) -> if (file.delete()) partials += 1
|
|
355
|
+
name.endsWith(SUFFIX) && safeKey(name.removeSuffix(SUFFIX)) -> {
|
|
356
|
+
if (keepSet.contains(name.removeSuffix(SUFFIX))) kept += 1
|
|
357
|
+
else if (file.delete()) removed += 1
|
|
358
|
+
}
|
|
359
|
+
// Not ours, and therefore not ours to delete — the rule the
|
|
360
|
+
// managed-source sweep had to learn the hard way.
|
|
361
|
+
else -> skipped += 1
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return JSONObject()
|
|
365
|
+
.put("ok", true)
|
|
366
|
+
.put("removed", removed)
|
|
367
|
+
.put("kept", kept)
|
|
368
|
+
.put("partials", partials)
|
|
369
|
+
.put("skipped", skipped)
|
|
370
|
+
.toString()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** What is prepared, for assertions. */
|
|
374
|
+
fun ledger(context: Context): String {
|
|
375
|
+
val items = JSONArray()
|
|
376
|
+
var bytes = 0L
|
|
377
|
+
for (file in (dir(context).listFiles() ?: emptyArray()).sortedBy { it.name }) {
|
|
378
|
+
bytes += file.length()
|
|
379
|
+
items.put(JSONObject().put("name", file.name).put("byteLength", file.length()))
|
|
380
|
+
}
|
|
381
|
+
return JSONObject()
|
|
382
|
+
.put("ok", true)
|
|
383
|
+
.put("count", items.length())
|
|
384
|
+
.put("bytes", bytes)
|
|
385
|
+
.put("items", items)
|
|
386
|
+
.toString()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The executor the module runs [prepare] on. */
|
|
390
|
+
fun submit(task: () -> Unit) {
|
|
391
|
+
executor.execute(task)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private fun safeKey(key: String): Boolean =
|
|
395
|
+
key.length == 64 && key.all { it in '0'..'9' || it in 'a'..'f' }
|
|
396
|
+
}
|