Compare commits

...
21 Commits
Author SHA1 Message Date
death-claw b2a10eafd9 v2.10.8 2020-01-25 16:00:05 +01:00
death-claw f027aff89d Fix app url 2020-01-25 15:57:45 +01:00
death-claw b4eaf0d1c4 v2.10.7 2020-01-25 14:45:54 +01:00
death-claw 9d2975faaa Drop SockJs and switch to WebSocket
Autoreconnect front to back
2020-01-25 14:39:50 +01:00
death-claw 0c0fdf9d78 v2.10.6 2020-01-19 13:01:03 +01:00
death-claw e17afa0ef3 Limit cache size 2020-01-19 12:59:47 +01:00
death-claw 3d3eb6aa36 v2.10.5 2020-01-18 22:28:27 +01:00
death-claw e22132255e Bug fixes 2020-01-18 22:24:53 +01:00
death-claw ef041526ec v2.10.4 2020-01-18 13:27:47 +01:00
death-claw 704311dd9a Zero value for total max download will disable the limit
Add system notification
Bug fixes
2020-01-18 13:21:46 +01:00
death-claw 9617a6f20f v2.10.3 2020-01-16 21:39:59 +01:00
death-claw bc17442961 Some bug fixes
Enhance download queue logic
Clear cache button
Fix the partial status
2020-01-16 21:34:09 +01:00
death-claw 574d6818c1 Refactor PostImgHost 2020-01-14 21:03:35 +01:00
death-clawandGitHub 5c416055ee Merge pull request #3 from life-claw/postimg
Added Support for postimg.cc
2020-01-14 19:01:41 +01:00
death-clawandGitHub fa30458dc6 Merge pull request #1 from life-claw/imagevenue
Add support for imagevenue.com
2020-01-14 18:47:47 +01:00
death-clawandGitHub 638af93225 Merge pull request #2 from life-claw/urlSpaces
Sanitize URLs that contain spaces
2020-01-14 18:14:47 +01:00
life-claw 2c6ca741c7 Added PostImgHost 2020-01-07 16:50:11 -06:00
life-claw d7f2df2540 Sanitize URLs with that contain spaces 2020-01-07 16:18:45 -06:00
life-claw b85ade1dba Added ImageVenueHost 2020-01-07 16:04:04 -06:00
death-claw 1c61a0b34e v2.10.2 2019-12-29 11:35:59 +01:00
death-claw 46b9449969 Upgrade electron to v7
Update the retry policy
2019-12-29 11:33:03 +01:00
59 changed files with 3455 additions and 3199 deletions
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## [2.10.8] - 2020-01-25
### Changed
- Fix app url
## [2.10.7] - 2020-01-25
### Changed
- Drop SockJs and switch to WebSocket
- Autoreconnect front to back
## [2.10.6] - 2020-01-19
### Changed
- Limit cache size
## [2.10.5] - 2020-01-18
### Changed
- Bug fixes
## [2.10.4] - 2020-01-18
### Changed
- Zero value for total max download will disable the limit
- Add system notification
- Bug fixes
## [2.10.3] - 2020-01-16
### Changed
- Some bug fixes
- Enhance download queue logic
- Clear cache button
- Fix the partial status
- Sanitize URLs with spaces
### Added
- Support for Postimg host
- Support for Imagevenue host
## [2.10.2] - 2019-12-29
### Changed
- Upgrade electron to v7
- Bug fix
## [2.10.1] - 2019-12-27
### Changed
- Bug fix
+1 -1
View File
@@ -4,7 +4,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.10.1</version>
<version>2.10.8</version>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
+45 -40
View File
@@ -23,6 +23,11 @@ process.on("uncaughtException", err => {
});
function createWindow() {
if (process.platform === 'win32') {
app.setAppUserModelId("tn.mnlr.vripper");
}
let icon;
if(process.platform === "win32") {
icon = __dirname + '/icon.ico';
@@ -59,51 +64,51 @@ function createWindow() {
});
}
getPort().then(port => {
serverPort = port;
ipcMain.on("get-port", event => {
event.reply("port", port);
});
let javaBinPath;
if(appDir !== undefined) {
javaBinPath = path.join(appDir, "java-runtime/bin/java");
} else {
if(process.platform === 'darwin') {
javaBinPath = path.join(app.getPath('exe'), "../../java-runtime/bin/java");
} else {
javaBinPath = path.join(app.getPath('exe'), "../java-runtime/bin/java");
}
}
let jarPath;
if(appDir !== undefined) {
jarPath = path.join(appDir, "bin/vripper-server.jar");
} else {
if(process.platform === 'darwin') {
jarPath = path.join(app.getPath('exe'), "../../bin/vripper-server.jar");
} else {
jarPath = path.join(app.getPath('exe'), "../bin/vripper-server.jar");
}
}
vripperServer = spawn(javaBinPath, [
"-Xms256m",
"-Dvripper.server.port=" + port,
"-jar",
jarPath
], {
stdio: 'ignore'
});
vripperServer.on('exit', (code, signal) => {
console.log(`vripper server terminated, code = ${code}, signal = ${signal}`);
terminated = true;
app.quit();
});
});
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
getPort().then(port => {
serverPort = port;
ipcMain.on("get-port", event => {
event.reply("port", port);
});
let javaBinPath;
if(appDir !== undefined) {
javaBinPath = path.join(appDir, "java-runtime/bin/java");
} else {
if(process.platform === 'darwin') {
javaBinPath = path.join(app.getPath('exe'), "../../java-runtime/bin/java");
} else {
javaBinPath = path.join(app.getPath('exe'), "../java-runtime/bin/java");
}
}
let jarPath;
if(appDir !== undefined) {
jarPath = path.join(appDir, "bin/vripper-server.jar");
} else {
if(process.platform === 'darwin') {
jarPath = path.join(app.getPath('exe'), "../../bin/vripper-server.jar");
} else {
jarPath = path.join(app.getPath('exe'), "../bin/vripper-server.jar");
}
}
vripperServer = spawn(javaBinPath, [
"-Xms256m",
"-Dvripper.server.port=" + port,
"-jar",
jarPath
], {
stdio: 'ignore'
});
vripperServer.on('exit', (code, signal) => {
console.log(`vripper server terminated, code = ${code}, signal = ${signal}`);
terminated = true;
app.quit();
});
});
app.on("second-instance", (event, commandLine, workingDirectory) => {
if (win) {
if (win.isMinimized()) win.restore();
+865 -1244
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "vripper-electron",
"version": "2.10.1",
"version": "2.10.8",
"description": "A ripper for vipergirls.to built using web technolgies",
"main": "main.js",
"author": "death-claw <53543762+death-claw@users.noreply.github.com>",
@@ -70,8 +70,8 @@
"dist": "node pre-build.js && electron-builder"
},
"devDependencies": {
"electron": "^6.0.9",
"electron-builder": "^21.2.0"
"electron": "^7.1.9",
"electron-builder": "^22.2.0"
},
"dependencies": {
"axios": "^0.19.0",
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.10.1</version>
<version>2.10.8</version>
</parent>
<artifactId>vripper-electron</artifactId>
<name>vripper-electron</name>
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.10.1</version>
<version>2.10.8</version>
</parent>
<artifactId>vripper-server</artifactId>
<name>vripper-server</name>
@@ -18,6 +18,10 @@ import java.util.stream.Collectors;
@NoArgsConstructor
public class Post {
public enum METADATA {
PREVIEWS, RESOLVED_NAME, POSTED_BY, THANKED
}
private AppStateService appStateService;
private Status status;
@@ -120,7 +120,7 @@ abstract public class Host {
throw new DownloadException(String.format("Server returned code %d", response.getStatusLine().getStatusCode()));
}
File outputFile = new File(destinationFolder.getPath() + File.separator + imageFileData.getImageName() + ".tmp");
File outputFile = new File(destinationFolder.getPath() + File.separator + String.format("%03d_", image.getIndex()) + imageFileData.getImageName() + ".tmp");
try (InputStream downloadStream = response.getEntity().getContent(); FileOutputStream fos = new FileOutputStream(outputFile)) {
image.setTotal(response.getEntity().getContentLength());
logger.debug(String.format("%s length is %d", imageFileData.getImageUrl(), image.getTotal()));
@@ -0,0 +1,79 @@
package tn.mnlr.vripper.host;
import org.apache.http.client.protocol.HttpClientContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import tn.mnlr.vripper.exception.HostException;
import tn.mnlr.vripper.exception.XpathException;
import tn.mnlr.vripper.q.ImageFileData;
import java.net.URI;
@Service
public class ImageVenueHost extends Host {
private static final Logger logger = LoggerFactory.getLogger(ImageVenueHost.class);
private static final String host = "imagevenue.com";
private static final String CONTINUE_BUTTON_XPATH = "//a[@title='Continue to your image']";
private static final String IMG_XPATH = "//img[@id='thepic']";
@Override
public String getHost() {
return host;
}
@Override
public String getLookup() {
return host;
}
@Override
protected void setNameAndUrl(final String url, final ImageFileData imageFileData, final HttpClientContext context) throws HostException {
//Sadly, they do not support https.
//If they add such support in the future,
//then we should automatically adapt the URL here as done elsewhere.
Response resp = getResponse(url, context);
Document doc = resp.getDocument();
try {
logger.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, url));
if(xpathService.getAsNode(doc, CONTINUE_BUTTON_XPATH) != null) {
//Button detected. No need to actually click it, just make the call again.
resp = getResponse(url, context);
doc = resp.getDocument();
}
} catch (XpathException e) {
throw new HostException(e);
}
Node imgNode;
try {
logger.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url));
imgNode = xpathService.getAsNode(doc, IMG_XPATH);
} catch (XpathException e) {
throw new HostException(e);
}
if (imgNode == null) {
throw new HostException("Failed to locate image");
}
try {
logger.debug(String.format("Resolving name and image url for %s", url));
String imgTitle = imgNode.getAttributes().getNamedItem("alt").getTextContent().trim();
String imgUrl = imgNode.getAttributes().getNamedItem("src").getTextContent().trim();
URI baseUri = new URI(url);
imageFileData.setImageUrl(new URI(baseUri.getScheme(), baseUri.getHost(), '/'+imgUrl, null).toString());
imageFileData.setImageName(imgTitle.isEmpty() ? imgUrl.substring(imgUrl.lastIndexOf('/') + 1) : imgTitle);
} catch(Exception e) {
throw new HostException("Unexpected error occurred", e);
}
}
}
@@ -0,0 +1,61 @@
package tn.mnlr.vripper.host;
import org.apache.http.client.protocol.HttpClientContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import tn.mnlr.vripper.exception.HostException;
import tn.mnlr.vripper.exception.XpathException;
import tn.mnlr.vripper.q.ImageFileData;
import java.util.Optional;
@Service
public class PostImgHost extends Host {
private static final Logger logger = LoggerFactory.getLogger(PostImgHost.class);
private static final String host = "postimg.cc";
private static final String TITLE_XPATH = "//span[contains(@class,'imagename')]";
private static final String IMG_XPATH = "//a[@id='download']";
@Override
public String getHost() {
return host;
}
@Override
public String getLookup() {
return host;
}
@Override
protected void setNameAndUrl(final String _url, final ImageFileData imageFileData, final HttpClientContext context) throws HostException {
String url = _url.replace("http://", "https://");
Document doc = getResponse(url, context).getDocument();
Node urlNode, titleNode;
try {
logger.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url));
titleNode = xpathService.getAsNode(doc, TITLE_XPATH);
logger.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url));
urlNode = xpathService.getAsNode(doc, IMG_XPATH);
} catch (XpathException e) {
throw new HostException(e);
}
try {
logger.debug(String.format("Resolving name and image url for %s", url));
String imgTitle = Optional.ofNullable(titleNode).map(node -> node.getTextContent().trim()).orElseGet(() -> getDefaultImageName(url));
imageFileData.setImageUrl(urlNode.getAttributes().getNamedItem("href").getTextContent().trim());
imageFileData.setImageName(imgTitle);
} catch (Exception e) {
throw new HostException("Unexpected error occurred", e);
}
}
}
@@ -12,6 +12,7 @@ import tn.mnlr.vripper.services.AppStateService;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingDeque;
@@ -45,18 +46,19 @@ public class DownloadQ {
appStateService.newDownloadJob(downloadJob);
}
public void rePut(final DownloadJob downloadJob) throws InterruptedException {
downloadQ.get(downloadJob.getImage().getHost()).putFirst(downloadJob);
public void remove(final DownloadJob downloadJob) {
downloadQ.get(downloadJob.getImage().getHost()).remove(downloadJob);
}
public List<DownloadJob> take() throws Exception {
if (hosts.size() == 0) {
throw new Exception("No host available in th application");
}
public List<DownloadJob> peek() {
List<DownloadJob> downloadJobs = new ArrayList<>();
if (hosts.size() == 0) {
return downloadJobs;
}
for (Host host : hosts) {
Iterator<DownloadJob> it = downloadQ.get(host).iterator();
for (int i = 0; i < appSettingsService.getMaxThreads(); i++) {
DownloadJob downloadJob = downloadQ.get(host).pollFirst();
DownloadJob downloadJob = it.hasNext() ? it.next() : null;
if (downloadJob != null) {
downloadJobs.add(downloadJob);
}
@@ -51,24 +51,29 @@ public class ExecutionService {
private Thread executionThread;
BlockingQueue<DownloadJob> queue = new LinkedBlockingQueue<>();
private RetryPolicy<Object> retryPolicy;
private List<DownloadJob> running = Collections.synchronizedList(new ArrayList<>());
private Map<String, Future<?>> futures = new ConcurrentHashMap<>();
private Thread pollThread;
@PostConstruct
private void init() {
retryPolicy = new RetryPolicy<>()
.handleIf(e -> !(e instanceof InterruptedException))
.withDelay(1, 3, ChronoUnit.SECONDS)
.withBackoff(1, 10, ChronoUnit.SECONDS)
.withMaxDuration(Duration.of(10, ChronoUnit.SECONDS))
.withMaxRetries(2)
.withMaxAttempts(3)
.abortOn(InterruptedException.class)
.onFailedAttempt(e -> logger.warn(String.format("#%d tries failed", e.getAttemptCount()), e.getLastFailure()));
executionThread = new Thread(this::start, "Executor thread");
pollThread = new Thread(this::poll, "Polling thread");
pollThread.start();
executionThread.start();
}
@@ -165,7 +170,6 @@ public class ExecutionService {
stopRunning(postId);
}
public synchronized void stop(String postId) {
try {
if (FINISHED.contains(appStateService.getPost(postId).getStatus())) {
@@ -189,13 +193,13 @@ public class ExecutionService {
}
}
private synchronized boolean canRun(Host host) {
private boolean canRun(Host host) {
boolean canRun;
AtomicInteger count = threadCount.get(host);
if (count == null) {
threadCount.put(host, new AtomicInteger(0));
}
canRun = threadCount.get(host).get() < settings.getMaxThreads() && threadCount.values().stream().mapToInt(AtomicInteger::get).sum() < settings.getMaxTotalThreads();
canRun = threadCount.get(host).get() < settings.getMaxThreads() && (settings.getMaxTotalThreads() == 0 || threadCount.values().stream().mapToInt(AtomicInteger::get).sum() < settings.getMaxTotalThreads());
if (canRun && notPauseQ) {
threadCount.get(host).incrementAndGet();
return true;
@@ -203,14 +207,14 @@ public class ExecutionService {
return false;
}
public void start() {
public void poll() {
while (!Thread.interrupted()) {
List<DownloadJob> take;
try {
take = downloadQ.take();
for (DownloadJob downloadJob : take) {
if (!push(downloadJob)) {
downloadQ.rePut(downloadJob);
List<DownloadJob> peek = downloadQ.peek();
for (DownloadJob downloadJob : peek) {
if (canRun(downloadJob.getImage().getHost())) {
queue.offer(downloadJob);
downloadQ.remove(downloadJob);
}
}
synchronized (threadCount) {
@@ -219,6 +223,17 @@ public class ExecutionService {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
public void start() {
while (!Thread.interrupted()) {
try {
push(queue.take());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
logger.error("Execution Service failed", e);
break;
@@ -226,43 +241,38 @@ public class ExecutionService {
}
}
private boolean push(DownloadJob take) {
if (canRun(take.getImage().getHost())) {
Runnable task = () -> {
running.add(take);
private void push(DownloadJob take) {
Runnable task = () -> {
running.add(take);
Failsafe.with(retryPolicy)
.onFailure(e -> {
if (e.getFailure() instanceof InterruptedException || (e.getFailure() instanceof FailsafeException && e.getFailure().getCause() instanceof InterruptedException)) {
logger.debug("Job successfully interrupted");
return;
}
logger.error(String.format("Failed to download %s after %d tries", take.getImage().getUrl(), e.getAttemptCount()), e.getFailure());
take.getImage().setStatus(Image.Status.ERROR);
})
.onComplete(e -> {
appStateService.doneDownloadJob(take.getImage());
logger.debug(String.format("Finished downloading %s", take.getImage().getUrl()));
if (appSettingsService.isViewPhotos()) {
VripperApplication.commonExecutor.submit(
() -> thumbnailGenerator.getThumbnails()
.get(new ThumbnailGenerator.CacheKey(take.getImage().getPostId(), take.getImageFileData().getFileName())));
}
threadCount.get(take.getImage().getHost()).decrementAndGet();
running.remove(take);
futures.remove(take.getImage().getUrl());
synchronized (threadCount) {
threadCount.notify();
}
})
.get(take::call);
};
logger.debug(String.format("Scheduling a job for %s", take.getImage().getUrl()));
futures.put(take.getImage().getUrl(), executor.submit(task));
return true;
} else {
return false;
}
Failsafe.with(retryPolicy)
.onFailure(e -> {
if (e.getFailure() instanceof InterruptedException || (e.getFailure() instanceof FailsafeException && e.getFailure().getCause() instanceof InterruptedException)) {
logger.debug("Job successfully interrupted");
return;
}
logger.error(String.format("Failed to download %s after %d tries", take.getImage().getUrl(), e.getAttemptCount()), e.getFailure());
take.getImage().setStatus(Image.Status.ERROR);
})
.onComplete(e -> {
appStateService.doneDownloadJob(take.getImage());
logger.debug(String.format("Finished downloading %s", take.getImage().getUrl()));
if (appSettingsService.isViewPhotos()) {
VripperApplication.commonExecutor.submit(
() -> thumbnailGenerator.getThumbnails()
.get(new ThumbnailGenerator.CacheKey(take.getImage().getPostId(), take.getImageFileData().getFileName())));
}
threadCount.get(take.getImage().getHost()).decrementAndGet();
running.remove(take);
futures.remove(take.getImage().getUrl());
synchronized (threadCount) {
threadCount.notify();
}
})
.get(take::call);
};
logger.debug(String.format("Scheduling a job for %s", take.getImage().getUrl()));
futures.put(take.getImage().getUrl(), executor.submit(task));
}
public int runningCount() {
@@ -49,15 +49,15 @@ public class AppSettingsService {
private final String CLEAR = "CLEAR";
private final String DARK_THEME = "DARK_THEME";
private final String VIEW_PHOTOS = "VIEW_PHOTOS";
private String downloadPath;
private int maxThreads;
private final String NOTIFICATION = "NOTIFICATION";
@PostConstruct
private void init() {
restore();
}
private String downloadPath;
private int maxThreads;
private boolean autoStart;
private boolean vLogin;
private String vUsername;
@@ -70,6 +70,7 @@ public class AppSettingsService {
private boolean clearCompleted;
private boolean darkTheme;
private boolean viewPhotos;
private boolean notificationEnabled;
public void setVPassword(String vPassword) {
if(vPassword.isEmpty()) {
@@ -83,7 +84,7 @@ public class AppSettingsService {
downloadPath = prefs.get(DOWNLOAD_PATH, defaultDownloadPath);
maxThreads = prefs.getInt(MAX_THREADS, 4);
maxTotalThreads = prefs.getInt(MAX_TOTAL_THREADS, 8);
maxTotalThreads = prefs.getInt(MAX_TOTAL_THREADS, 0);
autoStart = prefs.getBoolean(AUTO_START, true);
vLogin = prefs.getBoolean(V_LOGIN, false);
vUsername = prefs.get(V_USERNAME, "");
@@ -96,6 +97,7 @@ public class AppSettingsService {
clearCompleted = prefs.getBoolean(CLEAR, false);
darkTheme = prefs.getBoolean(DARK_THEME, false);
viewPhotos = prefs.getBoolean(VIEW_PHOTOS, false);
notificationEnabled = prefs.getBoolean(NOTIFICATION, false);
}
@PreDestroy
@@ -116,6 +118,7 @@ public class AppSettingsService {
prefs.putBoolean(CLEAR, clearCompleted);
prefs.putBoolean(DARK_THEME, darkTheme);
prefs.putBoolean(VIEW_PHOTOS, viewPhotos);
prefs.putBoolean(NOTIFICATION, notificationEnabled);
try {
prefs.sync();
@@ -138,7 +141,7 @@ public class AppSettingsService {
throw new ValidationException(String.format("%s is not a directory", settings.getDownloadPath()));
}
if (settings.getMaxTotalThreads() < 1) {
if (settings.getMaxTotalThreads() < 0) {
throw new ValidationException(String.format("Invalid max global concurrent download settings, values must be in greater than %d", 1));
}
@@ -199,8 +202,10 @@ public class AppSettingsService {
private boolean clearCompleted;
@JsonProperty("viewPhotos")
private boolean viewPhotos;
@JsonProperty("notification")
private boolean notification;
public Settings(String downloadPath, int maxThreads, int maxTotalThreads, boolean autoStart, boolean vLogin, String vUsername, String vPassword, boolean vThanks, boolean desktopClipboard, boolean forceOrder, boolean subLocation, boolean threadSubLocation, boolean clearCompleted, boolean viewPhotos) {
public Settings(String downloadPath, int maxThreads, int maxTotalThreads, boolean autoStart, boolean vLogin, String vUsername, String vPassword, boolean vThanks, boolean desktopClipboard, boolean forceOrder, boolean subLocation, boolean threadSubLocation, boolean clearCompleted, boolean viewPhotos, boolean notification) {
this.downloadPath = downloadPath;
this.maxThreads = maxThreads;
this.maxTotalThreads = maxTotalThreads;
@@ -215,6 +220,7 @@ public class AppSettingsService {
this.threadSubLocation = threadSubLocation;
this.clearCompleted = clearCompleted;
this.viewPhotos = viewPhotos;
this.notification = notification;
}
}
}
@@ -67,7 +67,7 @@ public class AppStateService {
public synchronized void postDownloadingUpdate(String postId) {
Post post = currentPosts.get(postId);
if (!post.getStatus().equals(Post.Status.DOWNLOADING)) {
if (!post.getStatus().equals(Post.Status.DOWNLOADING) && !post.getStatus().equals(Post.Status.PARTIAL)) {
post.setStatus(Post.Status.DOWNLOADING);
livePostsState.onNext(post);
}
@@ -54,13 +54,13 @@ public class ConnectionManager {
}
public HttpGet buildHttpGet(String url) {
HttpGet httpGet = new HttpGet(url);
HttpGet httpGet = new HttpGet(url.replace(" ", "+"));
httpGet.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36");
return httpGet;
}
public HttpPost buildHttpPost(String url) {
HttpPost httpPost = new HttpPost(url);
HttpPost httpPost = new HttpPost(url.replace(" ", "+"));
httpPost.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36");
return httpPost;
}
@@ -13,7 +13,7 @@ public class DownloadSpeed {
}
private String formatSI(long bytes) {
return humanReadableByteCount(bytes, true);
return humanReadableByteCount(bytes, false);
}
private String humanReadableByteCount(long bytes, boolean si) {
@@ -40,6 +40,12 @@ public class PostParser {
@Autowired
private List<Host> supportedHosts;
@Autowired
private HtmlProcessorService htmlProcessorService;
@Autowired
private XpathService xpathService;
@PostConstruct
private void init() {
@@ -52,7 +58,7 @@ public class PostParser {
return;
}
VRPostParser vrPostParser = new VRPostParser(threadId, postId, cm, vipergirlsAuthService, supportedHosts);
VRPostParser vrPostParser = new VRPostParser(threadId, postId, cm, vipergirlsAuthService, supportedHosts, htmlProcessorService, xpathService);
Post post = vrPostParser.parse();
post.setAppStateService(appStateService);
@@ -3,6 +3,7 @@ package tn.mnlr.vripper.services;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.cache.Weigher;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -10,23 +11,36 @@ import org.imgscalr.Scalr;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import javax.annotation.PostConstruct;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class ThumbnailGenerator {
@Getter
private LoadingCache<CacheKey, byte[]> thumbnails;
@Value("${base.dir}")
private String baseDir;
@Autowired
private PathService pathService;
@Getter
private File cacheFolder;
CacheLoader<CacheKey, byte[]> loader = new CacheLoader<>() {
@Override
@@ -47,10 +61,58 @@ public class ThumbnailGenerator {
throw new Exception(String.format("%s could not be created", cacheFolder.toString()));
}
thumbnails = CacheBuilder.newBuilder()
.maximumSize(20000)
.expireAfterAccess(Duration.ofMinutes(30))
.weigher((Weigher<CacheKey, byte[]>) (k, v) -> v.length)
.maximumWeight(104_857_600)
.build(loader);
}
public void clearCache() {
thumbnails.invalidateAll();
for (File file : Optional.ofNullable(cacheFolder.listFiles()).orElse(new File[]{})) {
FileSystemUtils.deleteRecursively(file);
}
}
public long cacheSize() {
final AtomicLong size = new AtomicLong(0);
try {
Files.walkFileTree(cacheFolder.toPath(), new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
size.addAndGet(attrs.size());
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
System.out.println("skipped: " + file + " (" + exc + ")");
// Skip folders that can't be traversed
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
if (exc != null)
System.out.println("had trouble traversing: " + dir + " (" + exc + ")");
else
size.addAndGet(dir.toFile().length());
// Ignore errors traversing a folder
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
throw new AssertionError("walkFileTree will not throw IOException if the FileVisitor does not");
}
return size.get();
}
private File generateThumbnail(File inputFile, String postId) throws Exception {
if (!inputFile.exists()) {
throw new Exception(String.format("Input file %s does not exist", inputFile.toString()));
@@ -57,21 +57,18 @@ public class VGHandler {
if (queuedVGLink.getPostId() != null) {
postParser.addPost(queuedVGLink.getPostId(), queuedVGLink.getThreadId());
} else {
appStateService.getGrabQueue().put(queuedVGLink.getLink(), queuedVGLink);
appStateService.getLiveGrabQueue().onNext(queuedVGLink);
Callable<Void> cl = () -> {
List<VRPostState> vrPostStates = cache.get(queuedVGLink.getThreadId());
logger.debug(String.format("%d found for %s", vrPostStates.size(), queuedVGLink.getLink()));
if (vrPostStates.size() == 1) {
postParser.addPost(vrPostStates.get(0).getPostId(), vrPostStates.get(0).getThreadId());
remove(queuedVGLink.getLink());
logger.debug(String.format("threadId %s, postId %s is added automatically for download", queuedVGLink.getThreadId(), queuedVGLink.getPostId()));
} else {
appStateService.getGrabQueue().put(queuedVGLink.getLink(), queuedVGLink);
appStateService.getLiveGrabQueue().onNext(queuedVGLink);
}
return null;
};
VripperApplication.commonExecutor.submit(cl);
}
}
@@ -10,6 +10,8 @@ import org.apache.http.client.utils.URIBuilder;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.xml.sax.Attributes;
import org.xml.sax.helpers.DefaultHandler;
import tn.mnlr.vripper.entities.Image;
@@ -51,13 +53,17 @@ class VRPostParser {
private final ConnectionManager cm;
private final VipergirlsAuthService vipergirlsAuthService;
private final List<Host> supportedHosts;
private final HtmlProcessorService htmlProcessorService;
private final XpathService xpathService;
VRPostParser(String threadId, String postId, ConnectionManager cm, VipergirlsAuthService vipergirlsAuthService, List<Host> supportedHosts) {
VRPostParser(String threadId, String postId, ConnectionManager cm, VipergirlsAuthService vipergirlsAuthService, List<Host> supportedHosts, HtmlProcessorService htmlProcessorService, XpathService xpathService) {
this.threadId = threadId;
this.postId = postId;
this.cm = cm;
this.vipergirlsAuthService = vipergirlsAuthService;
this.supportedHosts = supportedHosts;
this.htmlProcessorService = htmlProcessorService;
this.xpathService = xpathService;
}
public Post parse() throws PostParseException {
@@ -74,7 +80,16 @@ class VRPostParser {
VRPostHandler handler = new VRPostHandler(threadId, postId, supportedHosts);
AtomicReference<Throwable> thr = new AtomicReference<>();
logger.debug(String.format("Requesting %s", httpGet));
Post post = Failsafe.with(retryPolicy)
Post post = getPost(httpGet, handler, thr);
if (thr.get() != null || post == null) {
logger.error(String.format("parsing failed for thread %s, post %s", threadId, postId), thr.get());
throw new PostParseException(thr.get());
}
return post;
}
private Post getPost(HttpGet httpGet, VRPostHandler handler, AtomicReference<Throwable> thr) {
return Failsafe.with(retryPolicy)
.onFailure(e -> thr.set(e.getFailure()))
.get(() -> {
HttpClient connection = cm.getClient().build();
@@ -93,11 +108,34 @@ class VRPostParser {
}
}
});
if (thr.get() != null || post == null) {
logger.error(String.format("parsing failed for thread %s, post %s", threadId, postId), thr.get());
throw new PostParseException(thr.get());
}
return post;
}
private void getPostExtraMetadata(Post post, AtomicReference<Throwable> thr) {
HttpGet httpGet = cm.buildHttpGet(post.getUrl());
Failsafe.with(retryPolicy)
.onFailure(e -> thr.set(e.getFailure()))
.get(() -> {
HttpClient connection = cm.getClient().build();
try (CloseableHttpResponse response = (CloseableHttpResponse) connection.execute(httpGet, vipergirlsAuthService.getContext())) {
if (response.getStatusLine().getStatusCode() / 100 != 2) {
throw new DownloadException(String.format("Unexpected response code '%d' for %s", response.getStatusLine().getStatusCode(), httpGet));
}
try {
Document document = htmlProcessorService.clean(EntityUtils.toString(response.getEntity()));
Node postNode = xpathService.getAsNode(document, String.format("//li[@id='post_%s']/div[contains(@class, 'postdetails')]", post.getPostId()));
String postedBy = xpathService.getAsNode(postNode, "./div[contains(@class, 'userinfo')]//a[contains(@class, 'username')]//font").getTextContent().trim();
HashMap<String, Object> stringObjectHashMap = new HashMap<>();
response.getEntity().getContent();
return stringObjectHashMap;
} catch (Exception e) {
throw new PostParseException(String.format("Failed to parse thread %s, post %s", threadId, postId), e);
} finally {
EntityUtils.consumeQuietly(response.getEntity());
}
}
});
}
}
@@ -169,7 +207,7 @@ class VRPostHandler extends DefaultHandler {
if ("post".equals(qName.toLowerCase())) {
if (imageCount != 0) {
HashMap<String, Object> metadata = new HashMap<>();
metadata.put("PREVIEWS", previews);
metadata.put(Post.METADATA.PREVIEWS.name(), previews);
parsedPost = new Post(
postTitle,
String.format("https://vipergirls.to/threads/%s/?p=%s&viewfull=1#post%s", threadId, postId, postId),
@@ -1,10 +1,14 @@
package tn.mnlr.vripper.web;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
import tn.mnlr.vripper.web.wsendpoints.WebSocketHandler;
@Configuration
@@ -16,6 +20,24 @@ public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler, "/endpoint").setAllowedOrigins("*").withSockJS().setClientLibraryUrl("../../assets/sockjs.min.js");
registry.addHandler(handler, "/endpoint").setAllowedOrigins("*");
}
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("scheduled-task-");
scheduler.setDaemon(true);
return scheduler;
}
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxSessionIdleTimeout(0L);
return container;
}
}
@@ -102,6 +102,39 @@ public class GalleryEndpoint {
}
return new ResponseEntity("Gallery does not exist in download location, you probably removed it", HttpStatus.BAD_REQUEST);
}
@GetMapping("/gallery/cache")
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity<CacheSize> getCacheSize() {
return ResponseEntity.ok(new CacheSize(humanReadableByteCount(thumbnailGenerator.cacheSize(), false)));
}
@GetMapping("/gallery/cache/clear")
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity<CacheSize> clearCache() {
thumbnailGenerator.clearCache();
return ResponseEntity.ok(new CacheSize(humanReadableByteCount(thumbnailGenerator.cacheSize(), false)));
}
private String humanReadableByteCount(long bytes, boolean si) {
int unit = si ? 1000 : 1024;
if (bytes < unit) return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(unit));
String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp - 1) + (si ? "" : "i");
return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
}
}
@Getter
@Setter
@NoArgsConstructor
class CacheSize {
private String size;
public CacheSize(String size) {
this.size = size;
}
}
@Getter
@@ -13,7 +13,10 @@ import tn.mnlr.vripper.q.ExecutionService;
import tn.mnlr.vripper.services.*;
import java.io.File;
import java.util.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -48,7 +51,6 @@ public class PostRestEndpoint {
@Autowired
private ExecutionService executionService;
// private DownloadQ downloadQ;
@Autowired
@@ -61,6 +63,7 @@ public class PostRestEndpoint {
return new ResponseEntity("Failed to process empty request", HttpStatus.BAD_REQUEST);
}
List<String> urls = Arrays.stream(_url.getUrl().split("\\r?\\n")).map(String::trim).filter(e -> !e.isEmpty()).collect(Collectors.toList());
ArrayList<QueuedVGLink> queuedVGLinks = new ArrayList<>();
for (String url : urls) {
logger.debug(String.format("Starting to process thread: %s", url));
if (!url.startsWith("https://vipergirls.to")) {
@@ -80,8 +83,9 @@ public class PostRestEndpoint {
} catch (Exception e) {
throw new PostParseException(String.format("Cannot retrieve thread id from URL %s", url), e);
}
vgHandler.handle(Collections.singletonList(new QueuedVGLink(url, threadId, postId)));
queuedVGLinks.add(new QueuedVGLink(url, threadId, postId));
}
vgHandler.handle(queuedVGLinks);
return ResponseEntity.ok().build();
}
@@ -81,6 +81,7 @@ public class SettingsRestEndpoint {
this.settings.setThreadSubLocation(settings.isThreadSubLocation());
this.settings.setClearCompleted(settings.isClearCompleted());
this.settings.setViewPhotos(settings.isViewPhotos());
this.settings.setNotificationEnabled(settings.isNotification());
this.settings.save();
@@ -107,7 +108,8 @@ public class SettingsRestEndpoint {
settings.isSubLocation(),
settings.isThreadSubLocation(),
settings.isClearCompleted(),
settings.isViewPhotos()
settings.isViewPhotos(),
settings.isNotificationEnabled()
);
}
+1461 -1311
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "vripper-ui",
"version": "2.10.1",
"version": "2.10.8",
"scripts": {
"ng": "ng",
"start": "ng serve",
@@ -35,7 +35,7 @@
"zone.js": "~0.8.26"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.803.21",
"@angular-devkit/build-angular": "^0.803.23",
"@angular/cli": "~8.3.20",
"@angular/compiler-cli": "^8.2.14",
"@angular/language-service": "~8.2.14",
@@ -43,7 +43,7 @@
"@types/jasminewd2": "~2.0.3",
"@types/node": "~8.9.4",
"codelyzer": "~4.5.0",
"electron": "^6.0.9",
"electron": "^7.1.7",
"jasmine-core": "~2.99.1",
"jasmine-spec-reporter": "~4.2.1",
"karma": "^4.2.0",
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.10.1</version>
<version>2.10.8</version>
</parent>
<artifactId>vripper-ui</artifactId>
<name>vripper-ui</name>
+13 -16
View File
@@ -1,5 +1,15 @@
<ng-container *ngIf="appState | async as state">
<div *ngIf="state === 'CONNECTING'" class="overlay loading" fxLayout="column" fxLayoutAlign="center center">
<div *ngIf="loaded | async; else loading" [ngClass]="{ electron: electron | async }" fxLayout="column"
id="app-container">
<div fxFlex="nogrow" id="app-header">
<app-toolbar></app-toolbar>
</div>
<div fxFlex="grow" id="app-body" style="margin-bottom: 20px;">
<router-outlet></router-outlet>
</div>
<app-status-bar style="width: 100%"></app-status-bar>
</div>
<ng-template #loading>
<div class="overlay loading" fxLayout="column" fxLayoutAlign="center center">
<div class="sk-chase">
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
@@ -9,17 +19,4 @@
<div class="sk-chase-dot"></div>
</div>
</div>
<div *ngIf="state === 'DISCONNECTED'" class="overlay no-connection" fxLayout="column" fxLayoutAlign="center center">
<mat-icon class="overlay-icon">error</mat-icon>
<h2>Error connecting to daemon, try restarting the application</h2>
</div>
<div *ngIf="state === 'CONNECTED'" [ngClass]="{ electron: electron | async }" fxLayout="column" id="app-container">
<div fxFlex="nogrow" id="app-header">
<app-toolbar></app-toolbar>
</div>
<div fxFlex="grow" id="app-body" style="margin-bottom: 20px;">
<router-outlet></router-outlet>
</div>
<app-status-bar style="width: 100%"></app-status-bar>
</div>
</ng-container>
</ng-template>
+23 -18
View File
@@ -4,7 +4,7 @@ import { ElectronService } from 'ngx-electron';
import { Component, OnDestroy, AfterViewInit, Renderer2, ChangeDetectionStrategy, NgZone } from '@angular/core';
import { MatDialog } from '@angular/material';
import { Subscription, BehaviorSubject, Subject, merge } from 'rxjs';
import { WsConnectionService, WSState } from './ws-connection.service';
import { WsConnectionService } from './ws-connection.service';
@Component({
selector: 'app-root',
@@ -15,7 +15,7 @@ import { WsConnectionService, WSState } from './ws-connection.service';
export class AppComponent implements OnDestroy, AfterViewInit {
constructor(
private dialog: MatDialog,
private ws: WsConnectionService,
public ws: WsConnectionService,
public electronService: ElectronService,
private clipboardService: ClipboardService,
private appService: AppService,
@@ -26,28 +26,33 @@ export class AppComponent implements OnDestroy, AfterViewInit {
}
private subscriptions: Subscription[] = [];
appState: Subject<string> = new BehaviorSubject('CONNECTING');
electron: Subject<boolean>;
loaded: Subject<boolean> = new BehaviorSubject(false);
ngAfterViewInit() {
this.appService.renderer = this.renderer;
this.subscriptions.push(this.ws.state.subscribe(wsState => {
if (wsState === WSState.CLOSE || wsState === WSState.ERROR) {
setTimeout(() => this.ngZone.run(() => {
this.dialog.closeAll();
this.appState.next('DISCONNECTED');
}), 500);
} else if (wsState === WSState.OPEN) {
this.clipboardService.init();
merge(this.appService.loadTheme(), this.appService.loadSettings())
.subscribe(() => this.ngZone.run(() => this.appState.next('CONNECTED')));
}
}));
this.subscriptions.push(
this.ws.state.subscribe(online => {
if (!online) {
this.ngZone.run(() => this.loaded.next(false));
setTimeout(
() =>
this.ngZone.run(() => {
this.dialog.closeAll();
}),
500
);
} else {
this.ngZone.run(() => this.loaded.next(true));
this.clipboardService.init();
this.subscriptions.push(merge(this.appService.loadTheme(), this.appService.loadSettings()).subscribe());
}
})
);
}
ngOnDestroy() {
this.subscriptions.forEach(e => {
e.unsubscribe();
});
this.subscriptions.forEach(e => e.unsubscribe());
}
}
+1 -3
View File
@@ -6,7 +6,7 @@ import { Injectable, Renderer2 } from '@angular/core';
import { ServerService } from './server-service';
import { tap } from 'rxjs/operators';
import { ScanComponent } from './scan/scan.component';
import { Observable, Subject } from 'rxjs';
import { Observable } from 'rxjs';
import { BreakpointState, Breakpoints, BreakpointObserver } from '@angular/cdk/layout';
import { Title } from '@angular/platform-browser';
@@ -26,8 +26,6 @@ export class AppService {
private _settings: Settings;
private _renderer: Renderer2;
// private settings$: Subject<Settings> = new Subject();
get settings(): Settings {
return { ...this._settings };
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Subject, Observable } from 'rxjs';
import { ElectronService } from 'ngx-electron';
import { Injectable, OnInit } from '@angular/core';
import { Injectable } from '@angular/core';
import { Clipboard } from 'electron';
import { HttpClient } from '@angular/common/http';
import { Settings } from './common/settings.model';
+2 -1
View File
@@ -9,4 +9,5 @@ export interface Settings {
vThanks: boolean;
desktopClipboard: boolean;
viewPhotos: boolean;
}
notification: boolean;
}
@@ -4,23 +4,37 @@
</div>
<mat-dialog-content fxFlex="grow">
<div class="container">
<div class="masonry ">
<ng-container *ngFor="let img of images | async; let i=index">
<figure class="masonry-brick " fxLayout="row" fxLayoutAlign="center center"
style="position: relative; cursor: pointer;">
<mat-progress-spinner *ngIf="!img._initialized" mode="indeterminate"
style="position: absolute;"></mat-progress-spinner>
<img (click)="openSlideshow(i)" (load)="loaded(img)" [src]="img.msrc"/>
<div class="masonry">
<ng-container *ngFor="let img of images | async; let i = index">
<figure class="masonry-brick">
<img
[style.opacity]="img._initialized ? 1 : 0"
[style.visibility]="img._initialized ? 'visible' : 'hidden'"
(click)="openSlideshow(i)"
(load)="loaded(img)"
[src]="img.msrc"
/>
</figure>
</ng-container>
<app-photo-swipe #photoSwipe></app-photo-swipe>
</div>
</div>
</mat-dialog-content>
<div style="height: 5px; width: 100%; margin-top: 8px">
<div *ngIf="loadProgress | async as progress">
<mat-progress-bar
*ngIf="!progress.done"
[mode]="progress.loading ? 'determinate' : 'indeterminate'"
[value]="progress.progress"
></mat-progress-bar>
</div>
</div>
<mat-dialog-actions align="end" fxFlex="nogrow">
<button (click)="refresh()" color="primary" mat-raised-button>
<button (click)="refresh()" [disabled]="disableRefresh | async" color="primary" mat-raised-button>
<mat-icon>refresh</mat-icon>
<span>Refresh</span></button>
<span>Refresh</span>
</button>
<button mat-dialog-close mat-raised-button>Close</button>
</mat-dialog-actions>
</div>
@@ -3,23 +3,22 @@
}
.masonry {
display: flex;
flex-flow: row wrap;
margin-left: -8px;
width: 100%;
}
.masonry-brick {
flex: auto;
height: 250px;
min-width: 150px;
margin: 0 8px 8px 0;
width: 250px;
display: inline-block;
cursor: pointer;
margin: 0 3px;
}
img {
opacity: 0;
visibility: hidden;
transition: opacity 0.5s ease-in, visibility 0.5s;
object-fit: cover;
height: 250px;
width: auto;
}
figure {
width: 250px;
}
@@ -19,6 +19,14 @@ class Image extends IImage {
}
}
interface Progress {
loading: boolean;
progress: number;
done: boolean;
}
const initialProgressState: Progress = { loading: false, progress: 0, done: false };
@Component({
selector: 'app-gallery',
templateUrl: './gallery.component.html',
@@ -37,11 +45,23 @@ export class GalleryComponent implements OnInit, OnDestroy {
images: Subject<Image[]> = new BehaviorSubject([]);
_images: Image[] = [];
disableRefresh: Subject<boolean> = new BehaviorSubject(true);
loadProgress: Subject<Progress> = new BehaviorSubject(initialProgressState);
loadedImages = 0;
@ViewChild('photoSwipe', { static: true }) photoSwipe: PhotoSwipeComponent;
loaded(img: Image) {
img._initialized = true;
this.loadedImages++;
this.loadProgress.next({
loading: true,
progress: Math.floor((this.loadedImages / this._images.length) * 100),
done: this.loadedImages === this._images.length
});
if (this.loadedImages === this._images.length) {
this.disableRefresh.next(false);
}
}
ngOnInit() {
@@ -69,6 +89,9 @@ export class GalleryComponent implements OnInit, OnDestroy {
}
refresh() {
this.loadProgress.next(initialProgressState);
this.loadedImages = 0;
this.disableRefresh.next(true);
this.httpClient.get<Image[]>(this.serverService.baseUrl + '/gallery/' + this.dialogData.postId).subscribe(
response => {
this.ngZone.run(() => {
@@ -4,6 +4,7 @@ import { GrabQueueDataSource } from './grab-queue.datasource';
import { Component, OnInit, NgZone, ChangeDetectionStrategy } from '@angular/core';
import { GridOptions } from 'ag-grid-community';
import { WsConnectionService } from '../ws-connection.service';
import { NotificationService } from '../notification.service';
@Component({
selector: 'app-grab-queue',
@@ -12,7 +13,11 @@ import { WsConnectionService } from '../ws-connection.service';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GrabQueueComponent implements OnInit {
constructor(private wsConnection: WsConnectionService, private zone: NgZone, private linkCollectorService: LinkCollectorService) {
constructor(
private wsConnection: WsConnectionService,
private zone: NgZone,
private linkCollectorService: LinkCollectorService,
private notificationService: NotificationService) {
this.gridOptions = <GridOptions>{
columnDefs: [
{
@@ -35,7 +40,7 @@ export class GrabQueueComponent implements OnInit {
getRowNodeId: data => data['link'],
onGridReady: () => {
this.gridOptions.api.sizeColumnsToFit();
this.dataSource = new GrabQueueDataSource(this.wsConnection, this.gridOptions, this.zone);
this.dataSource = new GrabQueueDataSource(this.wsConnection, this.gridOptions, this.zone, this.notificationService);
this.dataSource.connect();
},
onGridSizeChanged: () => this.gridOptions.api.sizeColumnsToFit(),
@@ -1,60 +1,71 @@
import { flatMap, filter } from 'rxjs/operators';
import { GrabQueueState } from './grab-queue.model';
import { CMD } from './../common/cmd.enum';
import { WSMessage } from './../common/ws-message.model';
import { Subscription } from 'rxjs';
import { WsConnectionService } from '../ws-connection.service';
import { NotificationService } from '../notification.service';
import { GridOptions } from 'ag-grid-community';
import { WsHandler } from '../ws-handler';
import { NgZone } from '@angular/core';
export class GrabQueueDataSource {
constructor(
private wsConnectionService: WsConnectionService,
private ws: WsConnectionService,
private gridOptions: GridOptions,
private zone: NgZone
) {
this.websocketHandlerPromise = this.wsConnectionService.getConnection();
}
private zone: NgZone,
private notificationService: NotificationService
) {}
websocketHandlerPromise: Promise<WsHandler>;
subscriptions: Subscription[] = [];
grabQueueSub: Subscription;
connect() {
this.websocketHandlerPromise.then((handler: WsHandler) => {
console.log('Connecting to grab queue datasource');
this.subscriptions.push(
handler.subscribeForGrabQueue((e: GrabQueueState[]) => {
this.zone.run(() => {
const toAdd = [];
const toUpdate = [];
const toRemove = [];
e.forEach(v => {
if (v.removed) {
if (this.gridOptions.api.getRowNode(v.link) != null) {
toRemove.push(this.gridOptions.api.getRowNode(v.link).data);
console.log('Connecting to grab queue datasource');
this.subscriptions.push(
this.ws.state.subscribe(state => {
if (state) {
this.grabQueueSub = this.ws.subscribeForGrabQueue().subscribe((e: GrabQueueState[]) => {
this.zone.run(() => {
const toAdd = [];
const toUpdate = [];
const toRemove = [];
e.forEach(v => {
if (v.removed) {
if (this.gridOptions.api.getRowNode(v.link) != null) {
toRemove.push(this.gridOptions.api.getRowNode(v.link).data);
}
return;
}
return;
}
if (this.gridOptions.api.getRowNode(v.link) == null) {
toAdd.push(v);
} else {
toUpdate.push(v);
if (this.gridOptions.api.getRowNode(v.link) == null) {
toAdd.push(v);
} else {
toUpdate.push(v);
}
});
this.gridOptions.api.updateRowData({ update: toUpdate, add: toAdd, remove: toRemove });
const count = this.gridOptions.api.getDisplayedRowCount();
if (count > 0 && toAdd.length > 0) {
this.notificationService.notifyFromGrabQueue(
'Link Collector',
`You have ${count} ${count > 1 ? 'threads' : 'thread'} waiting in the link collector`
);
}
});
this.gridOptions.api.updateRowData({ update: toUpdate, add: toAdd, remove: toRemove });
});
})
);
handler.send(new WSMessage(CMD.GRAB_QUEUE_SUB.toString()));
});
this.ws.send(new WSMessage(CMD.GRAB_QUEUE_SUB.toString()));
} else if (this.grabQueueSub != null) {
this.grabQueueSub.unsubscribe();
}
})
);
}
disconnect() {
console.log('Disconnecting from grab queue datasource');
this.subscriptions.forEach(e => e.unsubscribe());
this.websocketHandlerPromise.then((handler: WsHandler) => {
handler.send(new WSMessage(CMD.GRAB_QUEUE_UNSUB.toString()));
});
this.ws.send(new WSMessage(CMD.GRAB_QUEUE_UNSUB.toString()));
if (this.grabQueueSub != null) {
this.grabQueueSub.unsubscribe();
}
}
}
+9 -2
View File
@@ -1,3 +1,4 @@
import { Subscription } from 'rxjs';
import { LinkCollectorService } from './../link-collector.service';
import { ServerService } from './../server-service';
import { ElectronService } from 'ngx-electron';
@@ -24,8 +25,10 @@ export class HomeComponent implements OnInit, OnDestroy {
public linkCollectorService: LinkCollectorService
) {}
clipboardSub: Subscription;
ngOnInit() {
this.clipboardService.links.subscribe(e => {
this.clipboardSub = this.clipboardService.links.subscribe(e => {
this.ngZone.run(() => {
this.httpClient
.post<{ threadId: string; postId: string }>(this.serverService.baseUrl + '/post', { url: e })
@@ -45,5 +48,9 @@ export class HomeComponent implements OnInit, OnDestroy {
});
}
ngOnDestroy() {}
ngOnDestroy() {
if (this.clipboardSub != null) {
this.clipboardSub.unsubscribe();
}
}
}
@@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import { AppService } from './app.service';
@Injectable({
providedIn: 'root'
})
export class NotificationService {
constructor(private appService: AppService) {}
notifyFromGrabQueue(title: string, body: string) {
if (!this.appService.settings.notification) {
return;
}
if (!('Notification' in window)) {
return;
} else if (Notification.permission === 'granted') {
const notification = new Notification(title, { body: body, icon: 'assets/icon.png' });
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(function(permission) {
if (permission === 'granted') {
const notification = new Notification(title, { body: body, icon: 'assets/icon.png' });
}
});
}
}
}
@@ -13,7 +13,7 @@
}
app-details-cell .error {
background-color: mat-color($red, 50);
background-color: mat-color($red, 100);
& .progress-bar .mat-progress-bar-fill::after {
background-color: mat-color($red, 300);
}
@@ -22,13 +22,7 @@ import { ElectronService } from 'ngx-electron';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PostDetailsProgressRendererComponent implements AgRendererComponent, OnInit, OnDestroy, AfterViewInit {
constructor(
private wsConnectionService: WsConnectionService,
private zone: NgZone,
public electronService: ElectronService
) {
this.websocketHandlerPromise = this.wsConnectionService.getConnection();
}
constructor(private ws: WsConnectionService, private zone: NgZone, public electronService: ElectronService) {}
websocketHandlerPromise: Promise<WsHandler>;
subscription: Subscription;
@@ -37,22 +31,29 @@ export class PostDetailsProgressRendererComponent implements AgRendererComponent
loaded: Subject<boolean> = new BehaviorSubject(false);
loading;
stateSub: Subscription;
postDetailsSub: Subscription;
trunc(value: number): number {
return Math.trunc(value);
}
ngOnInit(): void {
this.websocketHandlerPromise.then((handler: WsHandler) => {
this.subscription = handler.subscribeForPostDetails(e => {
this.zone.run(() => {
e.forEach(v => {
if (this.postDetails.url === v.url) {
this.postDetails = v;
this.postDetails$.emit(this.postDetails);
}
this.stateSub = this.ws.state.subscribe(state => {
if (state) {
this.postDetailsSub = this.ws.subscribeForPostDetails().subscribe(e => {
this.zone.run(() => {
e.forEach(v => {
if (this.postDetails.url === v.url) {
this.postDetails = v;
this.postDetails$.emit(this.postDetails);
}
});
});
});
});
} else if (this.postDetailsSub != null) {
this.postDetailsSub.unsubscribe();
}
});
}
@@ -73,6 +74,12 @@ export class PostDetailsProgressRendererComponent implements AgRendererComponent
if (this.subscription != null) {
this.subscription.unsubscribe();
}
if (this.postDetailsSub != null) {
this.postDetailsSub.unsubscribe();
}
if (this.stateSub != null) {
this.stateSub.unsubscribe();
}
clearTimeout(this.loading);
}
@@ -1,54 +1,57 @@
import { WsHandler } from './../ws-handler';
import { GridOptions } from 'ag-grid-community';
import { Subscription } from 'rxjs';
import { WsConnectionService, WSState } from '../ws-connection.service';
import { WsConnectionService } from '../ws-connection.service';
import { WSMessage } from '../common/ws-message.model';
import { CMD } from '../common/cmd.enum';
import { NgZone } from '@angular/core';
export class PostDetailsDataSource {
constructor(
private wsConnectionService: WsConnectionService,
private ws: WsConnectionService,
private gridOptions: GridOptions,
private postId: string,
private zone: NgZone
) {
this.websocketHandlerPromise = this.wsConnectionService.getConnection();
}
) {}
websocketHandlerPromise: Promise<WsHandler>;
subscriptions: Subscription[] = [];
postDetailsSub: Subscription;
connect() {
console.log('Connecting to post details datasource');
this.websocketHandlerPromise.then((handler: WsHandler) => {
this.subscriptions.push(
handler.subscribeForPostDetails(e => {
this.zone.run(() => {
const toAdd = [];
const toUpdate = [];
e.forEach(v => {
if (this.gridOptions.api.getRowNode(v.url) == null) {
toAdd.push(v);
} else {
toUpdate.push(v);
}
this.subscriptions.push(
this.ws.state.subscribe(state => {
if (state) {
this.postDetailsSub = this.ws.subscribeForPostDetails().subscribe(e => {
this.zone.run(() => {
const toAdd = [];
const toUpdate = [];
e.forEach(v => {
if (this.gridOptions.api.getRowNode(v.url) == null) {
toAdd.push(v);
} else {
toUpdate.push(v);
}
});
this.gridOptions.api.updateRowData({ update: toUpdate, add: toAdd });
});
this.gridOptions.api.updateRowData({ update: toUpdate, add: toAdd });
});
})
);
handler.send(new WSMessage(CMD.POST_DETAILS_SUB.toString(), this.postId));
});
this.ws.send(new WSMessage(CMD.POST_DETAILS_SUB.toString(), this.postId));
} else if (this.postDetailsSub != null) {
this.postDetailsSub.unsubscribe();
}
})
);
}
disconnect() {
console.log('Disconnecting from post details datasource');
this.subscriptions.forEach(e => e.unsubscribe());
this.websocketHandlerPromise.then((handler: WsHandler) => {
handler.send(new WSMessage(CMD.POST_DETAILS_UNSUB.toString()));
});
this.ws.send(new WSMessage(CMD.POST_DETAILS_UNSUB.toString()));
if (this.postDetailsSub != null) {
this.postDetailsSub.unsubscribe();
}
}
}
@@ -3,7 +3,8 @@
<div
[ngClass]="{
error: postState.status === 'ERROR',
downloading: postState.status === 'DOWNLOADING' || postState.status === 'PARTIAL',
downloading: postState.status === 'DOWNLOADING',
partial: postState.status === 'PARTIAL',
complete: postState.status === 'COMPLETE',
stopped: postState.status === 'STOPPED'
}"
@@ -4,6 +4,7 @@
$red: mat-palette($mat-red);
$grey: mat-palette($mat-grey);
$green: mat-palette($mat-green);
$orange: mat-palette($mat-orange);
app-progress-cell .progress-bar .mat-progress-bar-fill::after {
background-color: mat-color($grey, 400);
@@ -14,7 +15,7 @@
}
app-progress-cell .error {
background-color: mat-color($red, 50);
background-color: mat-color($red, 100);
& .progress-bar .mat-progress-bar-fill::after {
background-color: mat-color($red, 300);
}
@@ -23,6 +24,16 @@
}
}
app-progress-cell .partial {
background-color: mat-color($orange, 100);
& .progress-bar .mat-progress-bar-fill::after {
background-color: mat-color($orange, 300);
}
& .progress-bar .mat-progress-bar-buffer {
background-color: mat-color($orange, 100);
}
}
app-progress-cell .downloading {
background-color: mat-color($green, 50);
& .progress-bar .mat-progress-bar-fill::after {
@@ -44,6 +55,6 @@
}
app-progress-cell .stopped {
background-color: mat-color($grey, 100);
background-color: mat-color($grey, 300);
}
}
@@ -7,7 +7,7 @@ import {
NgZone,
ChangeDetectionStrategy,
AfterViewInit,
EventEmitter,
EventEmitter
} from '@angular/core';
import { AgRendererComponent } from 'ag-grid-angular';
import { Subscription, BehaviorSubject, Subject } from 'rxjs';
@@ -25,13 +25,12 @@ import { ContextMenuService } from '../ctxt-menu.service';
})
export class PostProgressRendererComponent implements AgRendererComponent, OnInit, OnDestroy, AfterViewInit {
constructor(
private wsConnectionService: WsConnectionService,
private ws: WsConnectionService,
private zone: NgZone,
public electronService: ElectronService,
public dialog: MatDialog,
private contextMenuService: ContextMenuService) {
this.websocketHandlerPromise = this.wsConnectionService.getConnection();
}
private contextMenuService: ContextMenuService
) {}
websocketHandlerPromise: Promise<WsHandler>;
postState$: EventEmitter<PostState> = new EventEmitter();
@@ -43,22 +42,29 @@ export class PostProgressRendererComponent implements AgRendererComponent, OnIni
node: RowNode;
gridApi: GridApi;
postsSub: Subscription;
stateSub: Subscription;
trunc(value: number): number {
return Math.trunc(value);
}
ngOnInit(): void {
this.websocketHandlerPromise.then((handler: WsHandler) => {
this.updatesSubscription = handler.subscribeForPosts(e => {
this.zone.run(() => {
e.forEach(v => {
if (this.postState.postId === v.postId) {
this.postState = v;
this.postState$.emit(this.postState);
}
this.stateSub = this.ws.state.subscribe(state => {
if (state) {
this.postsSub = this.ws.subscribeForPosts().subscribe(e => {
this.zone.run(() => {
e.forEach(v => {
if (this.postState.postId === v.postId) {
this.postState = v;
this.postState$.emit(this.postState);
}
});
});
});
});
} else if (this.postsSub != null) {
this.postsSub.unsubscribe();
}
});
}
@@ -71,6 +77,12 @@ export class PostProgressRendererComponent implements AgRendererComponent, OnIni
if (this.updatesSubscription != null) {
this.updatesSubscription.unsubscribe();
}
if (this.postsSub != null) {
this.postsSub.unsubscribe();
}
if (this.stateSub != null) {
this.stateSub.unsubscribe();
}
clearTimeout(this.loading);
}
+36 -36
View File
@@ -8,53 +8,53 @@ import { WsHandler } from '../ws-handler';
import { NgZone } from '@angular/core';
export class PostsDataSource {
constructor(
private wsConnectionService: WsConnectionService,
private gridOptions: GridOptions,
private zone: NgZone
) {
this.websocketHandlerPromise = this.wsConnectionService.getConnection();
}
constructor(private ws: WsConnectionService, private gridOptions: GridOptions, private zone: NgZone) {}
websocketHandlerPromise: Promise<WsHandler>;
subscriptions: Subscription[] = [];
connect() {
this.websocketHandlerPromise.then((handler: WsHandler) => {
console.log('Connecting to posts datasource');
this.subscriptions.push(
handler.subscribeForPosts((e: PostState[]) => {
this.zone.run(() => {
const toAdd = [];
const toUpdate = [];
const toRemove = [];
e.forEach(v => {
if (v.removed) {
if (this.gridOptions.api.getRowNode(v.postId) != null) {
toRemove.push(this.gridOptions.api.getRowNode(v.postId).data);
}
return;
}
postsSub: Subscription;
if (this.gridOptions.api.getRowNode(v.postId) == null) {
toAdd.push(v);
} else {
toUpdate.push(v);
}
connect() {
console.log('Connecting to posts datasource');
this.subscriptions.push(
this.ws.state.subscribe(state => {
if (state) {
this.postsSub = this.ws.subscribeForPosts().subscribe((e: PostState[]) => {
this.zone.run(() => {
const toAdd = [];
const toUpdate = [];
const toRemove = [];
e.forEach(v => {
if (v.removed) {
if (this.gridOptions.api.getRowNode(v.postId) != null) {
toRemove.push(this.gridOptions.api.getRowNode(v.postId).data);
}
return;
}
if (this.gridOptions.api.getRowNode(v.postId) == null) {
toAdd.push(v);
} else {
toUpdate.push(v);
}
});
this.gridOptions.api.updateRowData({ update: toUpdate, add: toAdd, remove: toRemove });
});
this.gridOptions.api.updateRowData({ update: toUpdate, add: toAdd, remove: toRemove });
});
})
);
handler.send(new WSMessage(CMD.POSTS_SUB.toString()));
});
this.ws.send(new WSMessage(CMD.POSTS_SUB.toString()));
} else if (this.postsSub != null) {
this.postsSub.unsubscribe();
}
})
);
}
disconnect() {
console.log('Disconnecting from posts datasource');
this.subscriptions.forEach(e => e.unsubscribe());
this.websocketHandlerPromise.then((handler: WsHandler) => {
handler.send(new WSMessage(CMD.POSTS_UNSUB.toString()));
});
this.ws.send(new WSMessage(CMD.POSTS_UNSUB.toString()));
if (this.postsSub != null) {
this.postsSub.unsubscribe();
}
}
}
+9
View File
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core';
export class ServerService {
private _baseUrl: string;
private _wsBaseUrl: string;
constructor() {}
@@ -14,4 +15,12 @@ export class ServerService {
get baseUrl(): string {
return this._baseUrl;
}
set wsBaseUrl(wsBaseUrl: string) {
this._wsBaseUrl = wsBaseUrl;
}
get wsBaseUrl(): string {
return this._wsBaseUrl;
}
}
@@ -43,7 +43,7 @@
<input
formControlName="maxTotalThreads"
matInput
min="1"
min="0"
name="maxTotalThreads"
placeholder="Max global concurrent downloads"
required
@@ -80,6 +80,13 @@
<mat-checkbox color="primary" formControlName="viewPhotos" name="viewPhotos">
Enable photo gallery
</mat-checkbox>
<div style="margin-left: 15px">
Cache size: {{(cacheSize | async).size}}
<button (click)="clearCache()" [disabled]="(cacheClearLoading | async)" color="primary"
mat-stroked-button>
<mat-icon>clear</mat-icon>
<span>Clear</span></button>
</div>
<mat-slide-toggle color="primary" formControlName="vLogin" name="vLogin">
ViperGirls Authentication
@@ -106,10 +113,17 @@
</section>
</form>
</mat-tab>
<mat-tab label="Desktop Integration" *ngIf="electronService.isElectronApp">
<mat-tab label="Desktop Integration">
<form [formGroup]="desktopSettingsForm" autocomplete="off">
<mat-checkbox color="primary" formControlName="desktopClipboard" name="desktopClipboard"
>Monitor Clipboard</mat-checkbox
<mat-checkbox *ngIf="electronService.isElectronApp" color="primary" formControlName="desktopClipboard"
name="desktopClipboard"
>Monitor Clipboard
</mat-checkbox
>
<mat-checkbox color="primary" formControlName="notification" name="notification"
>Enable system notifications
</mat-checkbox
>
</form>
</mat-tab>
@@ -9,6 +9,11 @@ import { ServerService } from '../server-service';
import { ElectronService } from 'ngx-electron';
import { Settings } from '../common/settings.model';
import { OpenDialogReturnValue } from 'electron';
import { Subject, BehaviorSubject } from 'rxjs';
interface CacheSize {
size: string;
}
@Component({
selector: 'app-settings',
@@ -45,10 +50,13 @@ export class SettingsComponent implements OnInit {
});
desktopSettingsForm = new FormGroup({
desktopClipboard: new FormControl(false)
desktopClipboard: new FormControl(false),
notification: new FormControl(false)
});
darkTheme = false;
cacheSize: Subject<CacheSize> = new BehaviorSubject({size: '0'});
cacheClearLoading: Subject<boolean> = new BehaviorSubject(false);
updateTheme() {
this.appService.updateTheme(this.darkTheme);
@@ -60,17 +68,36 @@ export class SettingsComponent implements OnInit {
ngOnInit() {
this.darkTheme = this.appService.darkTheme;
this.httpClient.get<Settings>(this.serverService.baseUrl + '/settings').subscribe(
data => {
this.generalSettingsForm.reset(data);
this.desktopSettingsForm.reset(data);
},
error => {
this._snackBar.open(error.error || 'Unexpected error, check log file', null, {
duration: 5000
});
}
);
this.httpClient.get<Settings>(this.serverService.baseUrl + '/settings')
.subscribe(data => {
this.generalSettingsForm.reset(data);
this.desktopSettingsForm.reset(data);
}, error => {
this._snackBar.open(error.error || 'Unexpected error, check log file', null, {
duration: 5000
});
});
this.httpClient.get<CacheSize>(this.serverService.baseUrl + '/gallery/cache')
.subscribe(data => {
this.cacheSize.next(data);
}, error => {
this._snackBar.open(error.error || 'Unexpected error, check log file', null, {
duration: 5000
});
});
}
clearCache() {
this.cacheClearLoading.next(true);
this.httpClient.get<CacheSize>(this.serverService.baseUrl + '/gallery/cache/clear')
.pipe(finalize(() => this.cacheClearLoading.next(false)))
.subscribe(data => {
this.cacheSize.next(data);
}, error => {
this._snackBar.open(error.error || 'Unexpected error, check log file', null, {
duration: 5000
});
})
}
browse() {
@@ -1,3 +1,4 @@
import { flatMap, filter } from 'rxjs/operators';
import { SelectionService } from './../selection-service';
import {
Component,
@@ -23,13 +24,7 @@ import { CMD } from '../common/cmd.enum';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StatusBarComponent implements OnInit, OnDestroy, AfterViewInit {
constructor(
private wsConnectionService: WsConnectionService,
private ngZone: NgZone,
private selectionService: SelectionService
) {
this.websocketHandlerPromise = this.wsConnectionService.getConnection();
}
constructor(private ws: WsConnectionService, private ngZone: NgZone, private selectionService: SelectionService) {}
websocketHandlerPromise: Promise<WsHandler>;
downloadSpeed: EventEmitter<DownloadSpeed> = new EventEmitter();
@@ -37,6 +32,9 @@ export class StatusBarComponent implements OnInit, OnDestroy, AfterViewInit {
globalState: EventEmitter<GlobalState> = new EventEmitter();
selected: EventEmitter<number> = new EventEmitter();
globalStateSub: Subscription;
speedSub: Subscription;
ngAfterViewInit(): void {
this.ngZone.run(() => {
this.selected.emit(0);
@@ -46,25 +44,34 @@ export class StatusBarComponent implements OnInit, OnDestroy, AfterViewInit {
}
ngOnInit() {
this.websocketHandlerPromise.then((handler: WsHandler) => {
console.log('Connecting to global state and download speed');
this.subscriptions.push(
handler.subscribeForGlobalState((e: GlobalState[]) => {
this.ngZone.run(() => {
this.globalState.emit(e[0]);
console.log('Connecting to global state and download speed');
this.subscriptions.push(
this.ws.state.subscribe(state => {
if (state) {
this.globalStateSub = this.ws.subscribeForGlobalState().subscribe((e: GlobalState[]) => {
this.ngZone.run(() => {
this.globalState.emit(e[0]);
});
});
})
);
this.subscriptions.push(
handler.subscribeForSpeed((e: DownloadSpeed[]) => {
this.ngZone.run(() => {
this.downloadSpeed.emit(e[0]);
this.speedSub = this.ws.subscribeForSpeed().subscribe((e: DownloadSpeed[]) => {
this.ngZone.run(() => {
this.downloadSpeed.emit(e[0]);
});
});
})
);
handler.send(new WSMessage(CMD.GLOBAL_STATE_SUB.toString()));
handler.send(new WSMessage(CMD.SPEED_SUB.toString()));
});
this.ws.send(new WSMessage(CMD.GLOBAL_STATE_SUB.toString()));
this.ws.send(new WSMessage(CMD.SPEED_SUB.toString()));
} else {
if (this.globalStateSub != null) {
this.globalStateSub.unsubscribe();
}
if (this.speedSub != null) {
this.speedSub.unsubscribe();
}
}
})
);
this.subscriptions.push(
this.selectionService.selected$.subscribe(selected => this.ngZone.run(() => this.selected.emit(selected.length)))
);
@@ -72,9 +79,13 @@ export class StatusBarComponent implements OnInit, OnDestroy, AfterViewInit {
ngOnDestroy(): void {
this.subscriptions.forEach(e => e.unsubscribe());
this.websocketHandlerPromise.then((handler: WsHandler) => {
handler.send(new WSMessage(CMD.GLOBAL_STATE_UNSUB.toString()));
handler.send(new WSMessage(CMD.SPEED_UNSUB.toString()));
});
this.ws.send(new WSMessage(CMD.GLOBAL_STATE_UNSUB.toString()));
this.ws.send(new WSMessage(CMD.SPEED_UNSUB.toString()));
if (this.globalStateSub != null) {
this.globalStateSub.unsubscribe();
}
if (this.speedSub != null) {
this.speedSub.unsubscribe();
}
}
}
+35 -22
View File
@@ -1,4 +1,12 @@
import { Component, OnInit, NgZone, OnDestroy, ChangeDetectionStrategy, EventEmitter, AfterViewInit } from '@angular/core';
import {
Component,
OnInit,
NgZone,
OnDestroy,
ChangeDetectionStrategy,
EventEmitter,
AfterViewInit
} from '@angular/core';
import { RemoveAllResponse } from '../common/remove-all-response.model';
import { ServerService } from '../server-service';
import { AppService } from '../app.service';
@@ -26,7 +34,6 @@ import { PostsDataService } from '../posts-data.service';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ToolbarComponent implements OnInit, OnDestroy, AfterViewInit {
constructor(
private serverService: ServerService,
private appService: AppService,
@@ -38,9 +45,7 @@ export class ToolbarComponent implements OnInit, OnDestroy, AfterViewInit {
private ws: WsConnectionService,
private selectionService: SelectionService,
private postsDataService: PostsDataService
) {
this.websocketHandlerPromise = this.ws.getConnection();
}
) {}
loggedUser: EventEmitter<LoggedUser> = new EventEmitter();
isExtraSmall: Observable<BreakpointState> = this.breakpointObserver.observe(Breakpoints.XSmall);
@@ -48,6 +53,7 @@ export class ToolbarComponent implements OnInit, OnDestroy, AfterViewInit {
subscriptions: Subscription[] = [];
selected: RowNode[] = [];
disableSelection: EventEmitter<boolean> = new EventEmitter();
userSub: Subscription;
openSettings(): void {
const dialogRef = this.dialog.open(SettingsComponent, {
@@ -92,9 +98,7 @@ export class ToolbarComponent implements OnInit, OnDestroy, AfterViewInit {
.afterClosed()
.pipe(
filter(e => e === 'yes'),
flatMap(e =>
this.httpClient.post<RemoveResponse[]>(this.serverService.baseUrl + '/post/remove', toRemove)
)
flatMap(e => this.httpClient.post<RemoveResponse[]>(this.serverService.baseUrl + '/post/remove', toRemove))
)
.subscribe(
data => {
@@ -223,25 +227,34 @@ export class ToolbarComponent implements OnInit, OnDestroy, AfterViewInit {
}
ngOnInit() {
this.websocketHandlerPromise.then((handler: WsHandler) => {
console.log('Connecting to user stream');
this.subscriptions.push(
handler.subscribeForUser((e: LoggedUser[]) => {
this.ngZone.run(() => {
this.loggedUser.emit(e[0]);
console.log('Connecting to user stream');
this.subscriptions.push(
this.ws.state.subscribe(state => {
if (state) {
this.userSub = this.ws.subscribeForUser().subscribe((e: LoggedUser[]) => {
this.ngZone.run(() => {
this.loggedUser.emit(e[0]);
});
});
})
);
handler.send(new WSMessage(CMD.USER_SUB.toString()));
});
this.ws.send(new WSMessage(CMD.USER_SUB.toString()));
} else if (this.userSub != null) {
this.userSub.unsubscribe();
}
})
);
this.selectionService.selected$.subscribe(selected => {
this.selected = selected;
this.ngZone.run(() => this.disableSelection.next(this.selected.length === 0));
});
this.subscriptions.push(
this.selectionService.selected$.subscribe(selected => {
this.selected = selected;
this.ngZone.run(() => this.disableSelection.next(this.selected.length === 0));
})
);
}
ngOnDestroy() {
this.subscriptions.forEach(e => e.unsubscribe());
if (this.userSub != null) {
this.userSub.unsubscribe();
}
}
}
+166 -107
View File
@@ -1,132 +1,191 @@
import { Injectable } from '@angular/core';
import { Observer, Subject, BehaviorSubject, ConnectableObservable, Observable, Subscription } from 'rxjs';
import { Subject, BehaviorSubject, Observable, Subscription } from 'rxjs';
import { WebSocketSubject } from 'rxjs/webSocket';
import { environment } from 'src/environments/environment';
import { multicast } from 'rxjs/operators';
import { ElectronService } from 'ngx-electron';
import { WsHandler } from './ws-handler';
import { ServerService } from './server-service';
declare var SockJS;
export enum WSState {
INIT,
CONNECTING,
ERROR,
CLOSE,
OPEN
}
const maxAttemps = 15;
import { GrabQueueState } from './grab-queue/grab-queue.model';
import { WSMessage } from './common/ws-message.model';
import { PostDetails } from './post-detail/post-details.model';
import { PostState } from './posts/post-state.model';
import { LoggedUser } from './common/logged-user.model';
import { DownloadSpeed } from './common/download-speed.model';
import { GlobalState } from './common/global-state.model';
import { map, filter } from 'rxjs/operators';
@Injectable()
export class WsConnectionService {
private websocket: Subject<any>;
private state$: BehaviorSubject<WSState> = new BehaviorSubject(WSState.INIT);
private sock;
private _state: WSState = WSState.INIT;
private wsHandlerPromise: Promise<WsHandler>;
private wsHandler: WsHandler;
private websocket: WebSocketSubject<any>;
private online: Subject<boolean> = new BehaviorSubject(false);
private open = false;
constructor(private electronService: ElectronService, private serverService: ServerService) {
this.wsHandlerPromise = new Promise((resolve, reject) => {
if (this.wsHandler != null) {
return this.wsHandler;
}
if (this.electronService.isElectronApp) {
const portRequest = setInterval(() => {
this.electronService.ipcRenderer.send('get-port');
}, 1000);
// wait for MainIPC
this.electronService.ipcRenderer.once('port', (event, port) => {
clearInterval(portRequest);
console.log('server running on port', port);
this.serverService.baseUrl = 'http://localhost:' + port;
this.tryConnect(resolve, reject);
});
} else {
this.serverService.baseUrl = environment.localhost;
this.tryConnect(resolve, reject);
}
});
this.init();
}
public get state(): Observable<WSState> {
return this.state$.asObservable();
}
init() {
if (this.electronService.isElectronApp) {
const portRequest = setInterval(() => {
this.electronService.ipcRenderer.send('get-port');
}, 1000);
tryConnect(resolve, reject) {
let attempts = 0;
this.connect();
const subscription: Subscription = this.state$.subscribe(e => {
if (e === WSState.OPEN) {
this.wsHandler = new WsHandler(this.websocket);
resolve(this.wsHandler);
}
});
const interval = setInterval(() => {
if (this._state !== WSState.OPEN && attempts >= maxAttemps) {
clearInterval(interval);
this._state = WSState.ERROR;
this.state$.next(this._state);
reject('Failed to connect to server');
if (subscription != null) {
subscription.unsubscribe();
}
return;
} else if (this._state === WSState.OPEN) {
clearInterval(interval);
if (subscription != null) {
subscription.unsubscribe();
}
return;
}
// wait for MainIPC
this.electronService.ipcRenderer.once('port', (event, port) => {
clearInterval(portRequest);
console.log('server running on port', port);
this.serverService.baseUrl = 'http://localhost:' + port;
this.serverService.wsBaseUrl = 'ws://localhost:' + port;
this.connect();
});
} else {
this.serverService.baseUrl = environment.localhost;
this.serverService.wsBaseUrl = environment.ws;
this.connect();
attempts++;
}, 5000);
}
}
connect() {
this._state = WSState.CONNECTING;
this.state$.next(this._state);
this.sock = new SockJS(this.serverService.baseUrl + '/endpoint');
const observable = Observable.create((obs: Observer<string>) => {
this.sock.onmessage = message => obs.next(message.data);
this.sock.onerror = error => {
console.log('Sockjs error', error);
obs.error(error);
this._state = WSState.ERROR;
this.state$.next(this._state);
};
this.sock.onclose = () => {
console.log('Sockjs disconnected');
obs.complete();
this._state = WSState.CLOSE;
this.state$.next(this._state);
};
});
this.sock.onopen = () => {
console.log('Sockjs connection established');
const observer = {
next: (data: Object) => {
if (this.sock.readyState === WebSocket.OPEN) {
this.sock.send(JSON.stringify(data));
console.log('Connecting...');
if (this.websocket != null) {
this.websocket.unsubscribe();
}
this.websocket = new WebSocketSubject({
url: this.serverService.wsBaseUrl + '/endpoint',
openObserver: {
next: () => {
console.log('Connection established');
if (this.open !== true) {
this.open = true;
this.online.next(true);
}
}
};
this.websocket = Subject.create(observer, observable).pipe(multicast(() => new Subject()));
(<ConnectableObservable<any>>(<unknown>this.websocket)).connect();
this._state = WSState.OPEN;
this.state$.next(this._state);
};
},
closeObserver: {
next: () => {
if (this.open !== false) {
this.open = false;
this.online.next(false);
}
setTimeout(() => this.connect(), 1000);
}
}
});
this.websocket.subscribe();
}
getConnection(): Promise<WsHandler> {
return this.wsHandlerPromise;
public get state(): Observable<boolean> {
return this.online.asObservable();
}
disconnect() {
console.log('Sockjs disconnecting');
this.sock.close();
this.websocket.unsubscribe();
}
subscribeForGlobalState(): Observable<GlobalState[]> {
return this.websocket.pipe(
filter(e => e.length > 0 && e.filter(v => v.type === 'globalState').length > 0),
map(e => {
const state: Array<GlobalState> = [];
(<Array<any>>e).forEach(element => {
state.push(new GlobalState(element.running, element.queued, element.remaining, element.error));
});
return state;
})
);
}
subscribeForSpeed(): Observable<DownloadSpeed[]> {
return this.websocket.pipe(
filter(e => e.length > 0 && e.filter(v => v.type === 'downSpeed').length > 0),
map(e => {
const speed: Array<DownloadSpeed> = [];
(<Array<any>>e).forEach(element => {
speed.push(new DownloadSpeed(element.speed));
});
return speed;
})
);
}
subscribeForUser(): Observable<LoggedUser[]> {
return this.websocket.pipe(
filter(e => e.length > 0 && e.filter(v => v.type === 'user').length > 0),
map(e => {
const user: Array<LoggedUser> = [];
(<Array<any>>e).forEach(element => {
user.push(new LoggedUser(element.user));
});
return user;
})
);
}
subscribeForPosts(): Observable<PostState[]> {
return this.websocket.pipe(
filter(e => e.length > 0 && e.filter(v => v.type === 'post').length > 0),
map(e => {
const posts: Array<PostState> = [];
(<Array<any>>e).forEach(element => {
posts.push(
new PostState(
element.type,
element.postId,
element.title,
element.done === 0 && element.total === 0 ? 0 : (element.done / element.total) * 100,
element.status,
element.removed,
element.url,
element.done,
element.total,
element.hosts,
element.metadata.PREVIEWS
)
);
});
return posts;
})
);
}
subscribeForPostDetails(): Observable<PostDetails[]> {
return this.websocket.pipe(
filter(e => e.length > 0 && e[0].type === 'img'),
map(e => {
const values = [];
(<Array<any>>e).forEach(element => {
values.push(
new PostDetails(
element.postId,
element.postName,
element.url,
element.current === 0 && element.total === 0 ? 0 : (element.current / element.total) * 100,
element.status,
element.index
)
);
});
return values;
})
);
}
subscribeForGrabQueue(): Observable<GrabQueueState[]> {
return this.websocket.pipe(
filter(e => e.length > 0 && e.filter(v => v.type === 'grabQueue').length > 0),
map(e => {
const grabQueue: Array<GrabQueueState> = [];
(<Array<any>>e).forEach(element => {
grabQueue.push(
new GrabQueueState(element.type, element.link, element.threadId, element.postId, element.removed)
);
});
return grabQueue;
})
);
}
send(wsMessage: WSMessage) {
this.websocket.next(wsMessage);
}
}
-135
View File
@@ -10,140 +10,5 @@ import { GlobalState } from './common/global-state.model';
import { DownloadSpeed } from './common/download-speed.model';
export class WsHandler {
constructor(private websocket: Subject<any>) {}
subscribeForGlobalState(callback: (stateStream: Array<GlobalState>) => void): Subscription {
return this.websocket
.pipe(
map(e => JSON.parse(e)),
filter(e => e.length > 0 && e.filter(v => v.type === 'globalState').length > 0),
map(e => {
const state: Array<GlobalState> = [];
(<Array<any>>e).forEach(element => {
state.push(new GlobalState(element.running, element.queued, element.remaining, element.error));
});
return state;
})
)
.subscribe(e => {
callback(e);
});
}
subscribeForSpeed(callback: (speedStream: Array<DownloadSpeed>) => void): Subscription {
return this.websocket
.pipe(
map(e => JSON.parse(e)),
filter(e => e.length > 0 && e.filter(v => v.type === 'downSpeed').length > 0),
map(e => {
const speed: Array<DownloadSpeed> = [];
(<Array<any>>e).forEach(element => {
speed.push(new DownloadSpeed(element.speed));
});
return speed;
})
)
.subscribe(e => {
callback(e);
});
}
subscribeForUser(callback: (userStream: Array<LoggedUser>) => void): Subscription {
return this.websocket
.pipe(
map(e => JSON.parse(e)),
filter(e => e.length > 0 && e.filter(v => v.type === 'user').length > 0),
map(e => {
const user: Array<LoggedUser> = [];
(<Array<any>>e).forEach(element => {
user.push(new LoggedUser(element.user));
});
return user;
})
)
.subscribe(e => {
callback(e);
});
}
subscribeForPosts(callback: (postStream: Array<PostState>) => void): Subscription {
return this.websocket
.pipe(
map(e => JSON.parse(e)),
filter(e => e.length > 0 && e.filter(v => v.type === 'post').length > 0),
map(e => {
const posts: Array<PostState> = [];
(<Array<any>>e).forEach(element => {
posts.push(
new PostState(
element.type,
element.postId,
element.title,
element.done === 0 && element.total === 0 ? 0 : (element.done / element.total) * 100,
element.status,
element.removed,
element.url,
element.done,
element.total,
element.hosts,
element.metadata.PREVIEWS
)
);
});
return posts;
})
)
.subscribe(e => {
callback(e);
});
}
subscribeForPostDetails(callback: (stream: Array<PostDetails>) => void): Subscription {
return this.websocket
.pipe(
map(e => JSON.parse(e)),
filter(e => e.length > 0 && e[0].type === 'img'),
map(e => {
const values = [];
(<Array<any>>e).forEach(element => {
values.push(
new PostDetails(
element.postId,
element.postName,
element.url,
element.current === 0 && element.total === 0 ? 0 : (element.current / element.total) * 100,
element.status,
element.index
)
);
});
return values;
})
)
.subscribe(e => callback(e));
}
subscribeForGrabQueue(callback: (grabQueueStream: Array<GrabQueueState>) => void): Subscription {
return this.websocket
.pipe(
map(e => JSON.parse(e)),
filter(e => e.length > 0 && e.filter(v => v.type === 'grabQueue').length > 0),
map(e => {
const grabQueue: Array<GrabQueueState> = [];
(<Array<any>>e).forEach(element => {
grabQueue.push(
new GrabQueueState(element.type, element.link, element.threadId, element.postId, element.removed)
);
});
return grabQueue;
})
)
.subscribe(e => {
callback(e);
});
}
send(wsMessage: WSMessage) {
this.websocket.next(wsMessage);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
export const environment = {
production: true,
localhost: '',
version: '2.10.1'
localhost: `${window.location.protocol}//${window.location.host}`,
ws: `${window.location.protocol === 'http:' ? 'ws:' : 'wss:'}//${window.location.host}`,
version: '2.10.8'
};
+2 -1
View File
@@ -5,7 +5,8 @@
export const environment = {
production: false,
localhost: 'http://localhost:8080',
version: '2.10.1'
ws: 'ws://localhost:8080',
version: '2.10.8'
};
/*
-1
View File
@@ -7,7 +7,6 @@
<meta content="width=device-width, initial-scale=1" name="viewport"/>
<link href="favicon.ico" rel="icon" type="image/x-icon"/>
<script src="assets/sockjs.min.js"></script>
</head>
<body class="mat-app-background">
<app-root></app-root>