Collect directory listings from the events that carry them

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_<id> for download, upload and delete, which is
how the server resolves them regardless of where it keeps them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 16:03:03 +00:00
parent f2885d33ad
commit 52bd180b56
2 changed files with 87 additions and 21 deletions

View File

@@ -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<Long> listIcons() throws Exception {
List<Long> 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<Long> 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.
*

View File

@@ -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<Integer, CompletableFuture<Map<String, String>>> 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.
*
* <p>Blocks until the server has sent the whole listing, so it must not be called from
* the event thread that delivers it.
*/
public List<RemoteFile> 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<RemoteFile> files = new ArrayList<>();
Iterable<SingleCommand> 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<String, String> m = row.toMap();
String name = m.get("name");
if (name == null || name.isEmpty()) continue;
files.add(new RemoteFile(
return new ArrayList<>(request.files);
}
/** Collects one {@code notifyfilelist} entry for the listing in flight. */
private void collectListEntry(Map<String, String> 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,
m.getOrDefault("path", path),
parseLong(m.get("size")),
"0".equals(m.get("type")),
parseLong(m.get("datetime"))));
}
return files;
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<String, String> 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<Map<String, String>> 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<RemoteFile> files = new java.util.concurrent.CopyOnWriteArrayList<>();
final CompletableFuture<Void> finished = new CompletableFuture<>();
FileListRequest(int channelId, String path) {
this.channelId = channelId;
this.path = path;
}
}
}