diff --git a/gradle.properties b/gradle.properties index 427e701..c9bc6a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.configuration-cache=false # Mod mod_id=video_player -mod_version=0.4.15 +mod_version=0.4.16 maven_group=com.ejclaw.videoplayer archives_base_name=video_player diff --git a/src/main/java/com/ejclaw/videoplayer/VideoPlayerConfig.java b/src/main/java/com/ejclaw/videoplayer/VideoPlayerConfig.java index 1af66ed..09fbcc7 100644 --- a/src/main/java/com/ejclaw/videoplayer/VideoPlayerConfig.java +++ b/src/main/java/com/ejclaw/videoplayer/VideoPlayerConfig.java @@ -299,10 +299,12 @@ public final class VideoPlayerConfig { JsonObject root = new JsonObject(); root.addProperty("_comment", "max_preload_mb: per-video download cap (each client). " + + "max_cache_mb: total cache directory cap (each client). " + "render_distance_blocks: max distance at which a video anchor still renders. " + "preload_urls: legacy un-named auto-preload list. " + "cache_entries: managed by /videoCache add|list|remove."); root.addProperty("max_preload_mb", maxPreloadMb); + root.addProperty("max_cache_mb", maxCacheMb); root.addProperty("render_distance_blocks", renderDistanceBlocks); JsonArray legacyArr = new JsonArray(); for (String u : preloadUrls) legacyArr.add(u); diff --git a/src/main/java/com/ejclaw/videoplayer/client/net/ClientNetworking.java b/src/main/java/com/ejclaw/videoplayer/client/net/ClientNetworking.java index 20e6ed7..87e0161 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/net/ClientNetworking.java +++ b/src/main/java/com/ejclaw/videoplayer/client/net/ClientNetworking.java @@ -6,6 +6,7 @@ import com.ejclaw.videoplayer.client.gui.VideoConfigScreen; import com.ejclaw.videoplayer.client.playback.VideoCache; import com.ejclaw.videoplayer.client.playback.VideoPlayback; import com.ejclaw.videoplayer.net.CachePolicyPayload; +import com.ejclaw.videoplayer.net.ClearCachePayload; import com.ejclaw.videoplayer.net.DeleteCachePayload; import com.ejclaw.videoplayer.net.OpenScreenPayload; import com.ejclaw.videoplayer.net.PreloadPayload; @@ -57,5 +58,10 @@ public final class ClientNetworking { ClientPlayNetworking.registerGlobalReceiver(DeleteCachePayload.TYPE, (payload, context) -> { VideoCache.purge(payload.url()); }); + + // /videoCache clear — wipe the entire client cache directory. + ClientPlayNetworking.registerGlobalReceiver(ClearCachePayload.TYPE, (payload, context) -> { + VideoCache.clearAll(); + }); } } diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java index abce1e6..355f9d3 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java @@ -20,6 +20,8 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * Client-side disk cache for preloaded HTTP video URLs. Driven by the {@code /videopreload} @@ -38,9 +40,23 @@ public final class VideoCache { /** url → local absolute path of the fully-downloaded cache file. */ private static final Map READY = new ConcurrentHashMap<>(); - /** urls whose download is currently in flight. */ + /** urls whose download is currently queued or in flight. */ private static final Set IN_FLIGHT = ConcurrentHashMap.newKeySet(); + /** + * Single-thread executor that serializes all downloads. Cap enforcement (the total + * cache size check) needs an authoritative view of the cache directory at the moment a + * download starts, so we run one download at a time — that way the directory scan in + * {@link #cacheDirSize} reflects every finished download up to this point. Parallel + * downloads were racing the cap by each reading the directory before any of them had + * renamed their .part to final. + */ + private static final ExecutorService DOWNLOAD_POOL = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "video_player-preload"); + t.setDaemon(true); + return t; + }); + /** * Hard ceiling on a single preload, in bytes. Default 2 GB sized to allow a single 4K * short clip; overridden per-session by {@link com.ejclaw.videoplayer.net.CachePolicyPayload} @@ -103,6 +119,41 @@ public final class VideoCache { } } + /** + * Wipe the entire cache directory and drop both indexes. Sent in response to + * {@code /videoCache clear}. Best-effort per-file deletion; logs failures but doesn't + * abort on the first one. In-flight downloads aren't cancelled — they'll fail at the + * atomic-move step (target dir gone) and log a warning, which is fine. + */ + public static void clearAll() { + int deleted = 0; + int failed = 0; + try { + Path dir = cacheDir(); + if (dir != null && Files.isDirectory(dir)) { + try (var stream = Files.newDirectoryStream(dir)) { + for (Path p : stream) { + try { + if (Files.isRegularFile(p) && Files.deleteIfExists(p)) deleted++; + } catch (Throwable t) { + failed++; + VideoPlayerMod.LOG.warn("[{}] clearAll: could not delete {}: {}", + VideoPlayerMod.MOD_ID, p.getFileName(), t.toString()); + } + } + } + } + } catch (Throwable t) { + VideoPlayerMod.LOG.warn("[{}] clearAll failed: {}", + VideoPlayerMod.MOD_ID, t.toString()); + } + READY.clear(); + IN_FLIGHT.clear(); + VideoPlayerMod.LOG.info("[{}] clearAll: deleted={} failed={}", + VideoPlayerMod.MOD_ID, deleted, failed); + notifyChat("[videocache] 전체 캐시 삭제: " + deleted + "개 파일", ChatFormatting.YELLOW); + } + /** Kick off a background download. No-op if already cached or in flight. */ public static void preload(String url) { if (url == null || url.isEmpty()) return; @@ -117,10 +168,8 @@ public final class VideoCache { notifyChat("[videopreload] 이미 다운로드 중: " + url, ChatFormatting.GRAY); return; } - notifyChat("[videopreload] 다운로드 시작: " + url, ChatFormatting.YELLOW); - Thread t = new Thread(() -> download(url), "video_player-preload"); - t.setDaemon(true); - t.start(); + notifyChat("[videopreload] 다운로드 대기열 추가: " + url, ChatFormatting.YELLOW); + DOWNLOAD_POOL.submit(() -> download(url)); } /** Return the local cached file path for this URL, or {@code null} if not yet ready. */ diff --git a/src/main/java/com/ejclaw/videoplayer/command/VideoCacheCommand.java b/src/main/java/com/ejclaw/videoplayer/command/VideoCacheCommand.java index ebc9def..a44fb3e 100644 --- a/src/main/java/com/ejclaw/videoplayer/command/VideoCacheCommand.java +++ b/src/main/java/com/ejclaw/videoplayer/command/VideoCacheCommand.java @@ -1,6 +1,7 @@ package com.ejclaw.videoplayer.command; import com.ejclaw.videoplayer.VideoPlayerConfig; +import com.ejclaw.videoplayer.net.ClearCachePayload; import com.ejclaw.videoplayer.net.DeleteCachePayload; import com.ejclaw.videoplayer.net.PreloadPayload; import com.mojang.brigadier.CommandDispatcher; @@ -127,26 +128,26 @@ public final class VideoCacheCommand { private static int runClear(CommandContext ctx) { CommandSourceStack src = ctx.getSource(); + // Drop server-side named entries (preload_urls is left alone — it's an admin-edited + // list, not something /videoCache manages). java.util.List urls = VideoPlayerConfig.clearCacheEntries(); - if (urls.isEmpty()) { - src.sendSuccess(() -> Component.literal("[videocache] 저장된 항목이 없습니다") - .withStyle(ChatFormatting.GRAY), false); - return 0; - } + + // Always broadcast a full-cache wipe to every client, even if the named index was + // empty — leftover files from prior sessions, legacy preload_urls downloads, and + // any in-flight stragglers all get scrubbed in one shot. MinecraftServer server = src.getServer(); + ClearCachePayload payload = new ClearCachePayload(); int clients = 0; for (ServerPlayer p : PlayerLookup.all(server)) { - for (String url : urls) { - ServerPlayNetworking.send(p, new DeleteCachePayload(url)); - } + ServerPlayNetworking.send(p, payload); clients++; } final int sentFinal = clients; final int countFinal = urls.size(); src.sendSuccess(() -> Component.literal( - "[videocache] 전체 삭제: " + countFinal + "개 항목" - + " (" + sentFinal + " 클라이언트에 cache_delete 전송)"), false); - return countFinal; + "[videocache] 전체 삭제: 등록 항목 " + countFinal + "개 제거" + + " + " + sentFinal + " 클라이언트 디스크 캐시 전체 wipe"), false); + return Math.max(countFinal, 1); } private static int runRemove(CommandContext ctx) throws CommandSyntaxException { diff --git a/src/main/java/com/ejclaw/videoplayer/net/ClearCachePayload.java b/src/main/java/com/ejclaw/videoplayer/net/ClearCachePayload.java new file mode 100644 index 0000000..523532e --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/net/ClearCachePayload.java @@ -0,0 +1,26 @@ +package com.ejclaw.videoplayer.net; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * S2C — tell every client to wipe its entire {@code video_player_cache} directory and reset + * the in-memory READY/IN_FLIGHT index. Sent by {@code /videoCache clear}. Carries no payload — + * the receiving client just nukes everything under the cache dir regardless of how the file + * got there (named entry, legacy preload_url, or leftover from a prior session). + */ +public record ClearCachePayload() implements CustomPacketPayload { + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "cache_clear")); + + public static final StreamCodec CODEC = + StreamCodec.unit(new ClearCachePayload()); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java b/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java index 02ac50d..bb4e7e5 100644 --- a/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java +++ b/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java @@ -28,6 +28,7 @@ public final class VideoPlayerNetwork { PayloadTypeRegistry.clientboundPlay().register(PreloadPayload.TYPE, PreloadPayload.CODEC); PayloadTypeRegistry.clientboundPlay().register(CachePolicyPayload.TYPE, CachePolicyPayload.CODEC); PayloadTypeRegistry.clientboundPlay().register(DeleteCachePayload.TYPE, DeleteCachePayload.CODEC); + PayloadTypeRegistry.clientboundPlay().register(ClearCachePayload.TYPE, ClearCachePayload.CODEC); // C2S PayloadTypeRegistry.serverboundPlay().register(SaveConfigPayload.TYPE, SaveConfigPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(DeleteAnchorPayload.TYPE, DeleteAnchorPayload.CODEC);