diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 05b1f4c..eb53fe3 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,12 @@ commons-compress 1.25.0 + + + org.apache.httpcomponents + httpclient + 4.5.14 + diff --git a/src/main/java/lion/Config/Config.java b/src/main/java/lion/Config/Config.java new file mode 100644 index 0000000..8462194 --- /dev/null +++ b/src/main/java/lion/Config/Config.java @@ -0,0 +1,23 @@ +package lion.Config; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class Config { + public static String DouNaiV2ray; + public static String DouNaiClash; + + public static void loadConfig(){ + Properties prop = new Properties(); + + try (InputStream input = new FileInputStream("/root/gallery/storageNode/config.properties")) { + prop.load(input); + DouNaiV2ray = prop.getProperty("DouNaiV2ray"); + DouNaiClash = prop.getProperty("DouNaiClash"); + } catch (IOException ex) { + ex.printStackTrace(); + } + } +} diff --git a/src/main/java/lion/CustomUtil.java b/src/main/java/lion/CustomUtil.java index 3c7ca07..a10b556 100644 --- a/src/main/java/lion/CustomUtil.java +++ b/src/main/java/lion/CustomUtil.java @@ -1,10 +1,10 @@ package lion; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; -import java.io.IOException; -import java.net.ServerSocket; import java.util.concurrent.atomic.AtomicInteger; @@ -15,14 +15,14 @@ public class CustomUtil { public static ObjectMapper objectMapper = new ObjectMapper(); - public static int _findIdlePort(int port) { - for(int i=port; i<65535; i++){ - try(ServerSocket ignored = new ServerSocket(i)){ - ignored.close(); - return i; - }catch (IOException ignored) { + public static void notifyMe(String message) { + String url = "https://personal.lionwebsite.xyz/message2me?AuthCode=alone&message=" + message; + HttpRequest request = HttpRequest.post(url); + request.header("User-Agent", "Mozilla/5.0"); + try(HttpResponse response = request.execute()) { + if(response.getStatus() != 200) { + System.out.println("通知失败, status code:" + response.getStatus() + ", message:" + message); } } - return -1; } } diff --git a/src/main/java/lion/Externel/BackupSubServer.java b/src/main/java/lion/Externel/BackupSubServer.java new file mode 100644 index 0000000..ba4611c --- /dev/null +++ b/src/main/java/lion/Externel/BackupSubServer.java @@ -0,0 +1,290 @@ +package lion.Externel; + +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static lion.Config.Config.DouNaiClash; +import static lion.Config.Config.DouNaiV2ray; + +@Slf4j +public class BackupSubServer { + + public static void main(String[] args) { + updateSub(); + ScheduledExecutorService threadPool = Executors.newScheduledThreadPool(1); + threadPool.scheduleAtFixedRate(BackupSubServer::updateSub, 0, 12, TimeUnit.HOURS); + + String ip = ""; + try(ServerSocket serverSocket = new ServerSocket(8889)) { + log.info("Sub Server listening on port {}", 8889); + while (true) { + Socket clientSocket = serverSocket.accept(); + ip = clientSocket.getInetAddress().getHostAddress(); + log.info("Client connected:{}", ip); + // 线程池处理下载请求 + handleClientRequest(clientSocket); + } + } catch (IOException e) { + log.error("处理http请求时出错,IP:{},ERROR:{}", ip, e.getMessage()); + } + } + + public static void updateSub(){ + File DouNaiClashFile = new File("sub/DouNaiClash.txt"); + File DouNaiV2rayFile = new File("sub/DouNaiV2ray.txt"); + File directory = new File("sub"); + + if(!directory.isDirectory()) + try { + Files.createDirectory(Paths.get("sub")); + } catch (IOException e) { + log.error("create directory error:{}", e.getMessage()); + } + + List DouNaiClash_profile; + + //下载豆奶v2ray订阅 + try(FileWriter writer = new FileWriter(DouNaiV2rayFile)) { + String DouNaiV2rayRaw = Get(DouNaiV2ray).getFirst(); + String[] v2rayPlain = new String(Base64.getDecoder().decode(DouNaiV2rayRaw)).split("\n"); + StringBuilder stringBuilder = new StringBuilder(); + Pattern pattern = Pattern.compile("-?\\d+(\\.\\d+)?"); + + //过滤高倍率节点 + for(String node: v2rayPlain){ + String name = URLDecoder.decode(node.split("#")[1], StandardCharsets.UTF_8); + if(name.contains("流量")){ + Matcher matcher = pattern.matcher(name.substring(name.indexOf("(") + 1, name.indexOf(")"))); + + if (matcher.find()) { + // 将匹配到的数字添加到列表中 + float ratio = Float.parseFloat(matcher.group()); + if(ratio <= 2) { + stringBuilder.append(node).append("\n"); + continue; + } + } + stringBuilder.append(node).append("\n"); + } + else{ + stringBuilder.append(node).append("\n"); + } + } + writer.write(new String(Base64.getEncoder().encode(stringBuilder.toString().getBytes(StandardCharsets.UTF_8)))); + + log.info("load DouNai v2ray complete"); + }catch (IOException e){ + log.error("load DouNai v2ray failure: {}", e.getMessage()); + } + + //下载豆奶clash订阅 + try(FileWriter writer = new FileWriter(DouNaiClashFile)) { + DouNaiClash_profile = Get(DouNaiClash); + //过滤高倍率节点 + ArrayList clashProcessed = new ArrayList<>(); + boolean isProxies = false; + boolean skip = false; + for(String line: DouNaiClash_profile){ + if(line.equals("proxies:")) + isProxies = true; + else if(line.equals("proxy-groups:") && isProxies) + isProxies = false; + + if(isProxies) { + if (line.contains("name")) + skip = line.contains("流量"); + if (!skip) + clashProcessed.add(line); + } + else + if (!line.contains("流量")) + clashProcessed.add(line); + } + + for(String line: clashProcessed) + writer.write(line + "\n"); + + log.info("load DouNai clash complete"); + }catch (IOException e){ + log.error("load DouNai clash failure: {}", e.getMessage()); + } + } + + + public static ArrayList Get(String url) throws IOException { + CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse httpResponse; + HttpGet httpGet = new HttpGet(url); + + httpResponse = httpClient.execute(httpGet); + + HttpEntity responseEntity = httpResponse.getEntity(); + int statusCode = httpResponse.getStatusLine().getStatusCode(); + ArrayList temp = new ArrayList<>(); + + if (statusCode == 200) { + BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent())); + String str; + while ((str = reader.readLine()) != null) + temp.add(str); + } + + httpClient.close(); + httpResponse.close(); + return temp; + } + + private static void handleClientRequest(Socket clientSocket) { + String fileName = ""; + try { + BufferedReader requestReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); + String requestLine = requestReader.readLine(); + + // Parse the request line to get the method and path + String[] requestParts = requestLine.split(" "); + String method = requestParts[0]; + Map paramMap = parseRequestLine(requestParts[1]);//path + if(paramMap == null){ + sendErrorResponse(clientSocket, "404"); + return; + } + log.info(Arrays.toString(requestParts)); + + // Only handle GET requests + if (method.equals("GET")) { + // Set the file path for download + File file = new File(fileName); + switch (paramMap.get("Client")) { + case "v2" -> file = new File("sub/DouNaiV2ray.txt"); + case "cat" -> file = new File("sub/DouNaiClash.txt"); + } + fileName = file.getName(); + log.info(file.getAbsolutePath()); + // Check if the file exists and is readable + if (file.exists() && file.isFile() && file.canRead()) { + // Get the file length + long fileLength = file.length(); + + // Get the range information for resuming download + long startByte = 0; + long endByte = fileLength - 1; + String rangeHeader = getRequestHeader(requestReader); + if (rangeHeader != null && rangeHeader.startsWith("bytes=")) { + String[] rangeValues = rangeHeader.substring(6).split("-"); + startByte = Long.parseLong(rangeValues[0]); + if (rangeValues.length > 1 && !rangeValues[1].isEmpty()) { + endByte = Long.parseLong(rangeValues[1]); + } + } + + // Send the HTTP response headers + OutputStream responseStream = clientSocket.getOutputStream(); + PrintWriter responseWriter = new PrintWriter(responseStream, true); + responseWriter.println("HTTP/1.1 206 Partial Content"); + responseWriter.println("Content-Type: application/octet-stream"); + responseWriter.println("Accept-Ranges: bytes"); + responseWriter.println("Content-Length: " + (endByte - startByte + 1)); + responseWriter.println("Content-Range: bytes " + startByte + "-" + endByte + "/" + fileLength); + responseWriter.println(); + + // Send the file content + try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) { + randomAccessFile.seek(startByte); + byte[] buffer = new byte[1024]; + int bytesRead; + long bytesRemaining = endByte - startByte + 1; + while (bytesRemaining > 0 && (bytesRead = randomAccessFile.read(buffer, 0, (int) Math.min(buffer.length, bytesRemaining))) != -1) { + responseStream.write(buffer, 0, bytesRead); + bytesRemaining -= bytesRead; + } + }catch (SocketException ignore){ + + } + + // Close the response output stream + responseStream.close(); + } else { + // File not found or not readable, send 404 response + sendErrorResponse(clientSocket, "404 Not Found"); + } + } else { + // Non-GET requests, send 501 response + sendErrorResponse(clientSocket, "501 Not Implemented"); + } + + // Close the request reader and client socket + requestReader.close(); + clientSocket.close(); + } catch (IOException e) { + log.error("处理文件下载时出错,IP:{}, 文件:{}, ERROR:{}", clientSocket.getInetAddress().getHostAddress(), fileName, e.getMessage()); + } + } + + private static String getRequestHeader(BufferedReader requestReader) throws IOException { + String line; + while ((line = requestReader.readLine()) != null) { + if (line.trim().isEmpty()) { + break; + } + + if (line.startsWith("Range" + ":")) { + return line.substring("Range".length() + 1).trim(); + } + } + return null; + } + + public static Map parseRequestLine(String requestLine) { + Map pathParams = new HashMap<>(); + + if(requestLine == null) + return null; + + String path; + if(requestLine.contains("?")) + path = requestLine.split("\\?")[0]; + else + path = requestLine; + + String[] vars = path.split("/"); + + if(vars.length < 4) + return null; + + pathParams.put("Key", vars[3]); + pathParams.put("Client", vars[2]); + + if(!pathParams.get("Client").equals("cat") && !pathParams.get("Client").equals("v2")) + return null; + + if(pathParams.get("Key").length()<6) + return null; + + return pathParams; + } + + private static void sendErrorResponse(Socket clientSocket, String statusCode) throws IOException { + OutputStream responseStream = clientSocket.getOutputStream(); + PrintWriter responseWriter = new PrintWriter(responseStream, true); + responseWriter.println("HTTP/1.1 " + statusCode); + responseWriter.println("Content-Type: text/html"); + responseWriter.println(); + responseWriter.println("

" + statusCode + "

"); + responseStream.close(); + } +} diff --git a/src/main/java/lion/Extranel/AESUtils.java b/src/main/java/lion/Extranel/AESUtils.java deleted file mode 100644 index 8797821..0000000 --- a/src/main/java/lion/Extranel/AESUtils.java +++ /dev/null @@ -1,26 +0,0 @@ -package lion.Extranel; - -import javax.crypto.Cipher; -import javax.crypto.spec.SecretKeySpec; -import java.security.Key; - -public class AESUtils { - private static final String ALGORITHM = "AES"; - private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding"; - - private static final byte[] keyBytes = "ThisIsA128BitKey".getBytes(); - - public static byte[] encrypt(byte[] data) throws Exception { - Key key = new SecretKeySpec(keyBytes, ALGORITHM); - Cipher cipher = Cipher.getInstance(TRANSFORMATION); - cipher.init(Cipher.ENCRYPT_MODE, key); - return cipher.doFinal(data); - } - - public static byte[] decrypt(byte[] encryptedData) throws Exception { - Key key = new SecretKeySpec(keyBytes, ALGORITHM); - Cipher cipher = Cipher.getInstance(TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, key); - return cipher.doFinal(encryptedData); - } -} diff --git a/src/main/java/lion/Extranel/Server.java b/src/main/java/lion/Extranel/Server.java deleted file mode 100644 index 770b0c4..0000000 --- a/src/main/java/lion/Extranel/Server.java +++ /dev/null @@ -1,65 +0,0 @@ -package lion.Extranel; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lion.CustomUtil; -import lombok.extern.slf4j.Slf4j; - -import java.io.BufferedInputStream; -import java.io.IOException; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.URI; -import java.net.URL; -import java.util.HashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -@Slf4j -public class Server { - - ExecutorService thread_pool; - - ObjectMapper objectMapper; - - public static void main(String[] args) { - new Server(); - } - - public Server(){ - log.info("开始监听:55555"); - objectMapper = CustomUtil.objectMapper; - thread_pool = Executors.newFixedThreadPool(2); - thread_pool.submit(() -> { - try(ServerSocket server = new ServerSocket(55555)){ - server.setSoTimeout(1000000); - while (true){ - Socket socket = server.accept(); - thread_pool.submit(() -> handleSocket(socket)); - } - }catch (IOException e){ - log.error(e.getMessage()); - } - }); - } - - - public void handleSocket(Socket socket){ - try{ - BufferedInputStream inputStream = new BufferedInputStream(socket.getInputStream()); - Thread.sleep(500); - byte[] buf = new byte[inputStream.available()]; - inputStream.read(buf); - byte[] bytes = AESUtils.decrypt(buf); - - HashMap map = (HashMap) objectMapper.readValue(bytes, HashMap.class); - URL url = new URI(map.get("path")).toURL(); - log.info("处理反代 ip: {},路径: {}", socket.getInetAddress().getHostAddress(), map.get("path")); - bytes = url.openConnection().getInputStream().readAllBytes(); - socket.getOutputStream().write(AESUtils.encrypt(bytes)); - socket.getOutputStream().flush(); - socket.getOutputStream().close(); - } catch (Exception e) { - e.printStackTrace(); - } - } -} diff --git a/src/main/java/lion/Main.java b/src/main/java/lion/Main.java index af58463..235ab32 100644 --- a/src/main/java/lion/Main.java +++ b/src/main/java/lion/Main.java @@ -1,7 +1,8 @@ package lion; import io.netty.bootstrap.Bootstrap; -import lion.Extranel.Server; +import lion.Config.Config; +import lion.Externel.BackupSubServer; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -9,8 +10,9 @@ public class Main { public static void main(String[] args) { boot(); + Config.loadConfig(); + new Thread(() -> BackupSubServer.main(null)).start(); new Thread(() -> MultiThreadedHTTPServer.main(null)).start(); - new Thread(() -> Server.main(null)).start(); new storageNode(); } diff --git a/src/main/java/lion/MultiThreadedHTTPServer.java b/src/main/java/lion/MultiThreadedHTTPServer.java index c8ca647..a7d33eb 100644 --- a/src/main/java/lion/MultiThreadedHTTPServer.java +++ b/src/main/java/lion/MultiThreadedHTTPServer.java @@ -127,6 +127,7 @@ public class MultiThreadedHTTPServer { responseWriter.println("Accept-Ranges: bytes"); responseWriter.println("Content-Length: " + (endByte - startByte + 1)); responseWriter.println("Content-Range: bytes " + startByte + "-" + endByte + "/" + fileLength); + responseWriter.println("Content-Disposition: attachment; filename=\"" + fileName + "\""); responseWriter.println(); // Send the file content diff --git a/src/main/java/lion/Service/DownloadCheckService.java b/src/main/java/lion/Service/DownloadCheckService.java index 14a4920..8d0c844 100644 --- a/src/main/java/lion/Service/DownloadCheckService.java +++ b/src/main/java/lion/Service/DownloadCheckService.java @@ -4,6 +4,7 @@ import io.netty.channel.Channel; import io.netty.channel.DefaultEventLoop; import io.netty.channel.EventLoop; import io.netty.util.concurrent.Promise; +import lion.CustomUtil; import lion.Domain.GalleryTask; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.ZipUtil; @@ -21,7 +22,7 @@ import java.util.concurrent.locks.ReentrantLock; @Slf4j @Data public class DownloadCheckService { - ArrayList queue; + Map queue; String downloadPath = "/root/gallery/hentai/download/"; @@ -37,7 +38,7 @@ public class DownloadCheckService { EventLoop eventLoop; - public DownloadCheckService(ArrayList queue, HashMap> promises){ + public DownloadCheckService(Map queue, HashMap> promises){ this.queue = queue; this.promises = promises; eventLoop = new DefaultEventLoop(); @@ -49,7 +50,7 @@ public class DownloadCheckService { public boolean downloadCheck(){ if(queue.isEmpty()) return false; - log.info("下载检查:" + Arrays.toString(queue.toArray())); + log.info("下载检查:{}", Arrays.toString(queue.values().toArray())); File downloadDirectory = new File(downloadPath); File[] fileArray = downloadDirectory.listFiles(); @@ -62,7 +63,7 @@ public class DownloadCheckService { boolean result = false; //扫描进度 Iterator fileIterator = files.iterator(); - for(GalleryTask galleryTask: queue){ + for(GalleryTask galleryTask: queue.values()){ //跳过已经下载完成或者压缩完成的任务 if(galleryTask.is_compress_complete() || galleryTask.is_compressing()) { result = true; @@ -96,7 +97,7 @@ public class DownloadCheckService { } //压缩队列 - for(GalleryTask galleryTask: queue) + for(GalleryTask galleryTask: queue.values()) if (galleryTask.is_download_complete()) { galleryTask.setStatus(GalleryTask.COMPRESSING); compress_queue.add(galleryTask); @@ -139,4 +140,35 @@ public class DownloadCheckService { } } } + + /** + * 检查改任务是否为已完成任务,如已完成则返回true,若未完成则加入队列 + * @return true if compress complete, false otherwise + */ + public boolean addToQueue(GalleryTask galleryTask){ + //是否含有名字,进行中任务一般有名字,没有名字则肯定为初始任务,存在名字至少在下载路径出现过 + if(galleryTask.getName() == null || galleryTask.getName().isEmpty()){ + queue.putIfAbsent(galleryTask.getGid(), galleryTask); + return false; + } + + //查询hah下载路径中,是否存在该任务下载路径,存在则为下载中或下载完成任务,加入队列 + if(new File(downloadPath + galleryTask.getGid()).isDirectory()){ + queue.putIfAbsent(galleryTask.getGid(), galleryTask); + return false; + } + + + //查询存放路径中是否含有该任务的压缩包,存在则为下载完成任务 + if(new File(storagePath + galleryTask.getName() + "/" + galleryTask.getName() + ".zip").exists()){ + galleryTask.setStatus(GalleryTask.COMPRESS_COMPLETE); + CustomUtil.notifyMe(String.format("任务:%s在添加时已下载完成,更新任务状态", galleryTask.getName())); + return true; + } + + + //异常情况,发送通知 + CustomUtil.notifyMe(String.format("任务:%s存在名字,但是下载路径为空且不存在压缩包", galleryTask.getName())); + return false; + } } diff --git a/src/main/java/lion/storageNode.java b/src/main/java/lion/storageNode.java index db521d1..3719c80 100644 --- a/src/main/java/lion/storageNode.java +++ b/src/main/java/lion/storageNode.java @@ -17,8 +17,8 @@ import lombok.extern.slf4j.Slf4j; import java.net.InetSocketAddress; import java.net.Socket; -import java.util.ArrayList; import java.util.HashMap; +import java.util.Map; import java.util.concurrent.*; import java.util.concurrent.locks.ReentrantLock; @@ -33,9 +33,9 @@ public class storageNode { DownloadCheckService downloadCheckService; - ArrayList queue; + Map queue; - ArrayList tempQueue; + Map tempQueue; ScheduledExecutorService checkThreadPool; @@ -48,8 +48,8 @@ public class storageNode { public static String storagePath = "/root/gallery/gallery/"; public storageNode(){ - queue = new ArrayList<>(0); - tempQueue = new ArrayList<>(0); + queue = new HashMap<>(); + tempQueue = new HashMap<>(); lock = new ReentrantLock(); counter = 0; promises = new HashMap<>(); @@ -94,7 +94,7 @@ public class storageNode { try { lock.lock(); if(!tempQueue.isEmpty()){ - queue.addAll(tempQueue); + queue.putAll(tempQueue); tempQueue.clear(); } lock.unlock(); @@ -103,7 +103,7 @@ public class storageNode { boolean isSkip = true; //返回false之后,还要额外检查是否有压缩完成的任务 if(!queue.isEmpty()) - for (GalleryTask galleryTask : queue) + for (GalleryTask galleryTask : queue.values()) if (galleryTask.is_compress_complete()) { isSkip = false; break; @@ -121,11 +121,13 @@ public class storageNode { //上锁后再发送,避免出现发送完之后再下载完成 lock.lock(); DownloadStatusMessage downloadStatusMessage = new DownloadStatusMessage(); - downloadStatusMessage.setGalleryTasks(queue.toArray(GalleryTask[]::new)); + downloadStatusMessage.setGalleryTasks(queue.values().toArray(GalleryTask[]::new)); server.writeAndFlush(downloadStatusMessage); + + queue.entrySet().removeIf(entry -> entry.getValue().is_compress_complete()); log.info("任务状态发送完成"); - queue.removeIf(GalleryTask::is_compress_complete); + lock.unlock(); }catch (Exception e){ log.error("发送任务状态时发生异常:{}", e.getMessage()); @@ -133,9 +135,9 @@ public class storageNode { } class MyChannelInboundHandlerAdapter extends ChannelInboundHandlerAdapter{ - ArrayList queue; + Map queue; - public MyChannelInboundHandlerAdapter(ArrayList queue) { + public MyChannelInboundHandlerAdapter(Map queue) { this.queue = queue; } @@ -159,7 +161,12 @@ public class storageNode { case AbstractMessage.DOWNLOAD_POST_MESSAGE -> { DownloadPostMessage dpm = (DownloadPostMessage) abstractMessage; lock.lock(); - queue.add(dpm.getGalleryTask()); + //添加到队列方法返回真说明该任务已下载完成,直接发送下载进度 + if(downloadCheckService.addToQueue(dpm.getGalleryTask())){ + DownloadStatusMessage downloadStatusMessage = new DownloadStatusMessage(); + downloadStatusMessage.setGalleryTasks(new GalleryTask[]{dpm.getGalleryTask()}); + server.writeAndFlush(downloadStatusMessage); + } log.info(String.valueOf(queue)); lock.unlock(); ctx.writeAndFlush(new ResponseMessage(dpm.messageId, (byte) 0)); diff --git a/src/main/resources/config.properties b/src/main/resources/config.properties new file mode 100644 index 0000000..427b6b4 --- /dev/null +++ b/src/main/resources/config.properties @@ -0,0 +1,2 @@ +DouNaiV2ray=https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2 +DouNaiClash=https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta \ No newline at end of file