From 52bd180b56becc092a95480dfcb11186b0fa82d1 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 19 Aug 2026 16:03:03 +0000 Subject: [PATCH] Collect directory listings from the events that carry them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ftgetfilelist is answered with a notifyfilelist event per entry and a closing notifyfilelistfinished, not with rows of the command's own reply — and ts3j dispatches anything named notify* as an event, so every listing came back empty. The entries are now collected for the request in flight, which is what the icon chooser and the file browser read. The icons themselves live in the repository's "icons" directory on newer servers rather than loose in its root, so both places are read. They are still addressed as /icon_ for download, upload and delete, which is how the server resolves them regardless of where it keeps them. Co-Authored-By: Claude Opus 5 --- .../java/com/ts3client/net/ChannelAdmin.java | 25 +++++- .../net/filetransfer/FileTransferManager.java | 83 +++++++++++++++---- 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java index 2419013..1a6bc69 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java @@ -32,6 +32,9 @@ import java.util.zip.CRC32; */ final class ChannelAdmin { + /** The directory a server's icons live in, where the server has one. */ + private static final String ICON_DIRECTORY = "icons"; + /** How long to wait for a whole icon to travel over the file connection. */ private static final long ICON_UPLOAD_TIMEOUT_MS = 30_000; @@ -178,14 +181,30 @@ final class ChannelAdmin { */ List listIcons() throws Exception { List ids = new ArrayList<>(); + boolean hasIconDirectory = false; for (RemoteFile file : fileTransfers().list(0, "", "/")) { - if (file.isDirectory() || !file.getName().startsWith("icon_")) continue; - long id = parseLong(file.getName().substring("icon_".length())); - if (id > 0) ids.add(id); + if (file.isDirectory()) { + hasIconDirectory |= file.getName().equals(ICON_DIRECTORY); + continue; + } + addIcon(ids, file); + } + // Newer servers keep the icons in their own directory rather than loose in the + // repository's root; both layouts exist, so whichever this server uses is read. + if (hasIconDirectory) { + for (RemoteFile file : fileTransfers().list(0, "", "/" + ICON_DIRECTORY)) { + if (!file.isDirectory()) addIcon(ids, file); + } } return ids; } + private static void addIcon(List ids, RemoteFile file) { + if (!file.getName().startsWith("icon_")) return; + long id = parseLong(file.getName().substring("icon_".length())); + if (id > 0 && !ids.contains(id)) ids.add(id); + } + /** * Uploads an image as a server icon. * diff --git a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java index f2802e8..2f121cd 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java @@ -52,6 +52,8 @@ public final class FileTransferManager { /** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */ private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501; + /** How long to wait for the server to finish listing a directory. */ + private static final long LIST_TIMEOUT_MS = 15_000; private final LocalTeamspeakClientSocket socket; /** Host we are connected to, used when the server reports no dedicated file-transfer host. */ @@ -70,6 +72,14 @@ public final class FileTransferManager { * routes back to the waiting transfer thread. */ private final Map>> pendingInits = new ConcurrentHashMap<>(); + + /** + * The {@code ftgetfilelist} in progress. The server answers that command with a + * {@code notifyfilelist} event per entry and a closing {@code notifyfilelistfinished} + * rather than with rows of the command's own reply, so the entries are collected here + * until the listing ends. One listing may be in flight at a time. + */ + private volatile FileListRequest fileListRequest; private final TS3Listener ftEventListener = new TS3Listener() { @Override public void onUnknownEvent(UnknownTeamspeakEvent e) { @@ -88,34 +98,49 @@ public final class FileTransferManager { /** * Lists the files and subdirectories directly under {@code path} in the given * channel's repository. Returns an empty list for an empty directory. + * + *

Blocks until the server has sent the whole listing, so it must not be called from + * the event thread that delivers it. */ public List list(int channelId, String channelPassword, String path) throws Exception { + String directory = path == null || path.isEmpty() ? "/" : path; SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT, new CommandSingleParameter("cid", Integer.toString(channelId)), new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), - new CommandSingleParameter("path", path == null || path.isEmpty() ? "/" : path)); + new CommandSingleParameter("path", directory)); - List files = new ArrayList<>(); - Iterable rows; + FileListRequest request = new FileListRequest(channelId, directory); + fileListRequest = request; try { - rows = socket.executeCommand(cmd).get(); + socket.executeCommand(cmd).complete(); + // The command's reply only acknowledges it; the entries are events, and the + // server marks their end with notifyfilelistfinished. + request.finished.get(LIST_TIMEOUT_MS, TimeUnit.MILLISECONDS); } catch (com.github.manevolent.ts3j.command.CommandException e) { - if (e.getErrorId() == ERROR_DATABASE_EMPTY_RESULT) return files; - throw e; + if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e; + } catch (TimeoutException e) { + throw new IOException("The server did not finish listing " + directory); + } finally { + fileListRequest = null; } - for (SingleCommand row : rows) { - Map m = row.toMap(); - String name = m.get("name"); - if (name == null || name.isEmpty()) continue; - files.add(new RemoteFile( - name, - m.getOrDefault("path", path), - parseLong(m.get("size")), - "0".equals(m.get("type")), - parseLong(m.get("datetime")))); - } - return files; + return new ArrayList<>(request.files); + } + + /** Collects one {@code notifyfilelist} entry for the listing in flight. */ + private void collectListEntry(Map row) { + FileListRequest request = fileListRequest; + if (request == null) return; + String cid = row.get("cid"); + if (cid != null && !cid.isEmpty() && parseLong(cid) != request.channelId) return; + String name = row.get("name"); + if (name == null || name.isEmpty()) return; + request.files.add(new RemoteFile( + name, + row.getOrDefault("path", request.path), + parseLong(row.get("size")), + "0".equals(row.get("type")), + parseLong(row.get("datetime")))); } /** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */ @@ -351,6 +376,15 @@ public final class FileTransferManager { String command = e.getCommand(); if (command == null) return; Map map = e.getMap(); + if (command.equals("notifyfilelist")) { + collectListEntry(map); + return; + } + if (command.equals("notifyfilelistfinished")) { + FileListRequest request = fileListRequest; + if (request != null) request.finished.complete(null); + return; + } Integer ftfid = tryParseInt(map.get("clientftfid")); if (ftfid == null) return; CompletableFuture> future = pendingInits.get(ftfid); @@ -454,4 +488,17 @@ public final class FileTransferManager { String m = r.getMessage(); return m != null ? m : r.getClass().getSimpleName(); } + + /** One in-flight {@code ftgetfilelist} and the entries received for it so far. */ + private static final class FileListRequest { + final int channelId; + final String path; + final List files = new java.util.concurrent.CopyOnWriteArrayList<>(); + final CompletableFuture finished = new CompletableFuture<>(); + + FileListRequest(int channelId, String path) { + this.channelId = channelId; + this.path = path; + } + } }