OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 25d37c0

Add video GC cascade to clear all HLS assets when original blob is orphaned
When a video hash becomes orphaned, _cascadeClearVideo now deletes the poster, master manifest, per-level playlists, all segments, and the video index entry before clearing the original blob. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
25d37c011be5f304182652d2f5c80cb2b65a07da
by plankatron <fleeky@gmail.com> · 2026-03-19T18:10:55.000Z

Changed files (2) +155 -0

lib/media-store.js +56 -0
……@@ -110,9 +110,65 @@ export class MediaStore {
110110 this._orphanQueue.delete(hash)
111111 const stillReferenced = await this.isReferencedAnywhere(hash)
112112 if (stillReferenced) return
113
114 // Check if this is a video with associated HLS assets
115 await this._cascadeClearVideo(hash)
116
117 // Clear the original blob
113118 await this.clear(hash)
114119 }
115120
121 /**
122 * If hash has a video index, cascade-clear all associated blobs:
123 * poster, manifest, level playlists, and all segments.
124 */
125 async _cascadeClearVideo (hash) {
126 const entry = await this._index.get('video:' + hash)
127 if (!entry) return
128
129 const video = entry.value
130 let cleared = 0
131
132 // Clear poster
133 if (video.poster) {
134 await this.clear(video.poster).catch(() => {})
135 cleared++
136 }
137
138 // Clear manifest
139 if (video.manifest) {
140 await this.clear(video.manifest).catch(() => {})
141 cleared++
142 }
143
144 // Clear all level playlists and segments
145 if (video.levels) {
146 for (const level of video.levels) {
147 if (level.m3u8) {
148 await this.clear(level.m3u8).catch(() => {})
149 cleared++
150 }
151 if (level.segments) {
152 for (const segHash of level.segments) {
153 // segments may be strings (hashes) or objects with .hash
154 const h = typeof segHash === 'string' ? segHash : segHash.hash
155 if (h) {
156 await this.clear(h).catch(() => {})
157 cleared++
158 }
159 }
160 }
161 }
162 }
163
164 // Remove video index entry
165 await this._index.del('video:' + hash).catch(() => {})
166
167 if (cleared > 0) {
168 console.log('[GC] Cascade cleared ' + cleared + ' video assets for ' + hash.substring(0, 16) + '...')
169 }
170 }
171
116172 async listAll () {
117173 const results = []
118174 for await (const entry of this._index.createReadStream({ gte: 'blob:', lt: 'blob:\xff' })) {
test/unit/video-gc.test.js +99 -0
……@@ -0,0 +1,99 @@
1import test from 'brittle'
2import Corestore from 'corestore'
3import path from 'bare-path'
4import fs from 'bare-fs'
5import os from 'bare-os'
6import { MediaStore } from '../../lib/media-store.js'
7
8function tmpdir () {
9 const dir = path.join(os.tmpdir(), 'hn-vgc-test-' + Date.now() + '-' + Math.random().toString(36).slice(2))
10 fs.mkdirSync(dir, { recursive: true })
11 return dir
12}
13
14function rmrf (dir) {
15 try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
16}
17
18test('Video GC - cascade clears poster, manifest, segments', async (t) => {
19 const dir = tmpdir()
20 try {
21 const store = new Corestore(dir)
22 const media = new MediaStore(store, {
23 graceMs: 50,
24 isReferencedAnywhere: async () => false
25 })
26 await media.ready()
27
28 // Simulate a processed video: original + poster + manifest + segments
29 const originalHash = await media.put(Buffer.from('original video data'), 'video/mp4')
30 const posterHash = await media.put(Buffer.from('jpeg poster'), 'image/jpeg')
31 const manifestHash = await media.put(Buffer.from('#EXTM3U\nmaster'), 'application/vnd.apple.mpegurl')
32 const m3u8Hash = await media.put(Buffer.from('#EXTINF:6\nseg'), 'application/vnd.apple.mpegurl')
33 const seg1Hash = await media.put(Buffer.from('segment 1 ts data'), 'video/mp2t')
34 const seg2Hash = await media.put(Buffer.from('segment 2 ts data'), 'video/mp2t')
35
36 // Write video index
37 await media._index.put('video:' + originalHash, {
38 status: 'ready',
39 poster: posterHash,
40 manifest: manifestHash,
41 levels: [{
42 name: '480p',
43 m3u8: m3u8Hash,
44 segments: [seg1Hash, seg2Hash]
45 }]
46 })
47
48 // All blobs exist
49 t.is(await media.has(originalHash), true)
50 t.is(await media.has(posterHash), true)
51 t.is(await media.has(manifestHash), true)
52 t.is(await media.has(seg1Hash), true)
53 t.is(await media.has(seg2Hash), true)
54
55 // Schedule orphan - should cascade clear everything
56 media.scheduleOrphan(originalHash)
57 await new Promise(r => setTimeout(r, 200))
58
59 // All blobs should be cleared
60 t.is(await media.has(originalHash), false)
61 t.is(await media.has(posterHash), false)
62 t.is(await media.has(manifestHash), false)
63 t.is(await media.has(m3u8Hash), false)
64 t.is(await media.has(seg1Hash), false)
65 t.is(await media.has(seg2Hash), false)
66
67 // Video index should be deleted
68 const videoEntry = await media._index.get('video:' + originalHash)
69 t.is(videoEntry, null)
70
71 await store.close()
72 } finally {
73 rmrf(dir)
74 }
75})
76
77test('Video GC - non-video blob clears normally without cascade', async (t) => {
78 const dir = tmpdir()
79 try {
80 const store = new Corestore(dir)
81 const media = new MediaStore(store, {
82 graceMs: 50,
83 isReferencedAnywhere: async () => false
84 })
85 await media.ready()
86
87 const hash = await media.put(Buffer.from('just an image'), 'image/jpeg')
88 t.is(await media.has(hash), true)
89
90 media.scheduleOrphan(hash)
91 await new Promise(r => setTimeout(r, 200))
92
93 t.is(await media.has(hash), false)
94
95 await store.close()
96 } finally {
97 rmrf(dir)
98 }
99})