Compare commits

..
14 Commits
Author SHA1 Message Date
death-claw d009aa3f8c v2.8.1 2019-12-24 20:21:29 +01:00
death-claw 078d49f883 v2.8.1 2019-12-24 20:18:22 +01:00
death-claw f1bf517ba5 Fix issue with path creation 2019-12-24 20:15:03 +01:00
death-claw 6269ed6f6e v2.8.0 2019-12-24 19:47:17 +01:00
death-claw 43b8dedbb1 Use pointer cursor on gallery items 2019-12-24 19:42:45 +01:00
death-claw 978be7bd49 Add view photos settings
Some improvements
2019-12-23 22:46:46 +01:00
death-claw 1b7f3aef62 Add missing css and script includes 2019-12-23 19:36:07 +01:00
death-claw 7d24ee4867 Use photoswipe for image gallery 2019-12-23 00:02:13 +01:00
death-claw 269a3475b0 Generate thumbnails on the fly and cache them 2019-12-21 19:17:51 +01:00
death-claw 18e2bc6afc Add Photo Viewer 2019-12-21 17:23:22 +01:00
death-claw c6ef54e115 v2.7.1 2019-12-21 11:24:03 +01:00
death-claw 441b2831fc Add version to title 2019-12-21 11:16:53 +01:00
death-claw 3cf4d60eca v2.7.0 2019-12-21 09:59:17 +01:00
death-claw d228c89a1c Add support for pixxxels
Add missing icon
2019-12-21 09:56:57 +01:00
38 changed files with 1263 additions and 349 deletions
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [2.8.1] - 2019-12-24
### Changed
- Fix issue with path creation
## [2.8.0] - 2019-12-24
### Added
- Photo gallery feature
## [2.7.1] - 2019-12-21
### Added
- Add version to title bar
## [2.7.0] - 2019-12-21
### Changed
- Add missing icon
### Added
- Add support for pixxxels
## [2.6.0] - 2019-12-21
### Changed
- Disable download button when nothing selected in Link collector grab menu
+1 -1
View File
@@ -4,7 +4,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.6.0</version>
<version>2.8.1</version>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vripper-electron",
"version": "2.6.0",
"version": "2.8.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "vripper-electron",
"version": "2.6.0",
"version": "2.8.1",
"description": "A ripper for vipergirls.to built using web technolgies",
"main": "main.js",
"author": "death-claw <53543762+death-claw@users.noreply.github.com>",
@@ -8,7 +8,7 @@
"license": "ISC",
"build": {
"appId": "tn.mnlr.vripper",
"productName": "V Ripper",
"productName": "VRipper",
"files": [
"**/*",
"!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.6.0</version>
<version>2.8.1</version>
</parent>
<artifactId>vripper-electron</artifactId>
<name>vripper-electron</name>
+6 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.6.0</version>
<version>2.8.1</version>
</parent>
<artifactId>vripper-server</artifactId>
<name>vripper-server</name>
@@ -72,6 +72,11 @@
<artifactId>guava</artifactId>
<version>28.1-jre</version>
</dependency>
<dependency>
<groupId>org.imgscalr</groupId>
<artifactId>imgscalr-lib</artifactId>
<version>4.2</version>
</dependency>
<dependency>
<groupId>tn.mnlr</groupId>
<artifactId>vripper-ui</artifactId>
@@ -30,6 +30,7 @@ import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Iterator;
@Service
@@ -97,11 +98,10 @@ abstract public class Host {
imageFileData.setImageName(formatImageFileName);
File destinationFolder = pathService.getDownloadDestinationFolder(image.getPostId());
logger.debug(String.format("Saving to %s", destinationFolder.getPath()));
logger.debug(String.format("Creating %s", destinationFolder.getPath()));
destinationFolder.mkdirs();
if (!destinationFolder.exists()) {
logger.debug(String.format("Creating %s", destinationFolder.getPath()));
if (destinationFolder.mkdirs()) {
logger.debug(String.format("Folder %s is created", destinationFolder.toString()));
}
logger.debug(String.format("Folder %s is created", destinationFolder.toString()));
}
HttpClient client = cm.getClient().build();
@@ -127,9 +127,11 @@ abstract public class Host {
image.increase(read);
downloadSpeedService.increase(read);
}
fos.flush();
EntityUtils.consumeQuietly(response.getEntity());
}
checkImageTypeAndRename(outputFile, imageFileData.getImageName(), image.getIndex());
File finalName = checkImageTypeAndRename(outputFile, imageFileData.getImageName(), image.getIndex());
imageFileData.setFileName(finalName.getName());
}
} catch (Exception e) {
if (Thread.interrupted()) {
@@ -139,27 +141,38 @@ abstract public class Host {
}
}
private void checkImageTypeAndRename(File outputFile, String imageName, int index) throws HostException {
String formatName;
private File checkImageTypeAndRename(File outputFile, String imageName, int index) throws HostException {
try (ImageInputStream iis = ImageIO.createImageInputStream(outputFile)) {
Iterator<ImageReader> it = ImageIO.getImageReaders(iis);
if (!it.hasNext()) {
throw new HostException("Image file is not recognized!");
}
ImageReader reader = it.next();
formatName = reader.getFormatName();
if (formatName.toUpperCase().equals("JPEG")) {
formatName = "jpg";
if (reader.getFormatName().toUpperCase().equals("JPEG")) {
String imageNameLC = imageName.toLowerCase();
if (!imageNameLC.endsWith("_jpg") && !imageNameLC.endsWith("_jpeg")) {
imageName += ".jpg";
} else {
String toReplace = null;
if (imageNameLC.endsWith("_jpg")) {
toReplace = "_jpg";
} else if (imageNameLC.endsWith("_jpeg")) {
toReplace = "_jpeg";
}
if (toReplace != null) {
imageName = imageName.substring(0, imageName.length() - toReplace.length()) + ".jpg";
}
}
}
} catch (Exception e) {
throw new HostException("Failed to guess image format", e);
}
try {
File outImage = new File(outputFile.getParent(), (appSettingsService.isForceOrder() ? String.format("%03d_", index) : "") + imageName + "." + formatName.toLowerCase());
File outImage = new File(outputFile.getParent(), (appSettingsService.isForceOrder() ? String.format("%03d_", index) : "") + imageName);
if (outImage.exists() && outImage.delete()) {
logger.debug(String.format("%s is deleted", outImage.toString()));
}
Files.move(outputFile.toPath(), outImage.toPath());
return Files.move(outputFile.toPath(), outImage.toPath(), StandardCopyOption.ATOMIC_MOVE).toFile();
} catch (Exception e) {
throw new HostException("Failed to rename the image", e);
}
@@ -0,0 +1,67 @@
package tn.mnlr.vripper.host;
import org.apache.http.client.protocol.HttpClientContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
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 tn.mnlr.vripper.services.ConnectionManager;
@Service
public class PixxxelsHost extends Host {
private static final Logger logger = LoggerFactory.getLogger(PixxxelsHost.class);
private static final String host = "pixxxels.cc";
private static final String IMG_XPATH = "//*[@id='download']";
private static final String TITLE_XPATH = "//*[contains(@class,'imagename')]";
@Autowired
private ConnectionManager cm;
@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://");
Response resp = getResponse(url, context);
Document doc = resp.getDocument();
Node imgNode, titleNode;
try {
logger.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url));
imgNode = xpathService.getAsNode(doc, IMG_XPATH);
logger.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url));
titleNode = xpathService.getAsNode(doc, TITLE_XPATH);
} catch (XpathException e) {
throw new HostException(e);
}
try {
logger.debug(String.format("Resolving name and image url for %s", url));
String imgTitle = titleNode.getTextContent().trim();
String imgUrl = imgNode.getAttributes().getNamedItem("href").getTextContent().trim();
imageFileData.setImageUrl(imgUrl);
imageFileData.setImageName(imgTitle.isEmpty() ? imgUrl.substring(imgUrl.lastIndexOf('/') + 1) : imgTitle);
} catch (Exception e) {
throw new HostException("Unexpected error occurred", e);
}
}
}
@@ -7,9 +7,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tn.mnlr.vripper.VripperApplication;
import tn.mnlr.vripper.entities.Image;
import tn.mnlr.vripper.services.AppSettingsService;
import tn.mnlr.vripper.services.AppStateService;
import tn.mnlr.vripper.services.ThumbnailGenerator;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
@@ -36,6 +38,12 @@ public class ExecutionService {
@Autowired
private AppStateService appStateService;
@Autowired
private ThumbnailGenerator thumbnailGenerator;
@Autowired
private AppSettingsService appSettingsService;
private final AtomicInteger threadCount = new AtomicInteger();
private ExecutorService executor = Executors.newFixedThreadPool(10);
@@ -130,6 +138,11 @@ public class ExecutionService {
.onComplete(e -> {
appStateService.doneDownloadJob(finalTake.getImage());
logger.debug(String.format("Finished downloading %s", finalTake.getImage().getUrl()));
if (appSettingsService.isViewPhotos()) {
VripperApplication.commonExecutor.submit(
() -> thumbnailGenerator.getThumbnails()
.get(new ThumbnailGenerator.CacheKey(finalTake.getImage().getPostId(), finalTake.getImageFileData().getFileName())));
}
synchronized (threadCount) {
threadCount.decrementAndGet();
running.remove(finalTake);
@@ -10,6 +10,7 @@ public class ImageFileData {
private String pageUrl;
private String imageName;
private String fileName;
private String imageUrl;
private HttpUriRequest imageRequest;
}
@@ -44,6 +44,7 @@ public class AppSettingsService {
private final String THREADSUBFOLDER = "THREADSUBFOLDER";
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;
@@ -58,6 +59,7 @@ public class AppSettingsService {
private boolean forceOrder;
private boolean clearCompleted;
private boolean darkTheme;
private boolean viewPhotos;
public void setVPassword(String vPassword) {
if(vPassword.isEmpty()) {
@@ -82,6 +84,7 @@ public class AppSettingsService {
threadSubLocation = prefs.getBoolean(THREADSUBFOLDER, false);
clearCompleted = prefs.getBoolean(CLEAR, false);
darkTheme = prefs.getBoolean(DARK_THEME, false);
viewPhotos = prefs.getBoolean(VIEW_PHOTOS, false);
}
@PreDestroy
@@ -100,6 +103,7 @@ public class AppSettingsService {
prefs.putBoolean(THREADSUBFOLDER, threadSubLocation);
prefs.putBoolean(CLEAR, clearCompleted);
prefs.putBoolean(DARK_THEME, darkTheme);
prefs.putBoolean(VIEW_PHOTOS, viewPhotos);
try {
prefs.sync();
@@ -175,8 +179,10 @@ public class AppSettingsService {
private boolean threadSubLocation;
@JsonProperty("clearCompleted")
private boolean clearCompleted;
@JsonProperty("viewPhotos")
private boolean viewPhotos;
public Settings(String downloadPath, int maxThreads, boolean autoStart, boolean vLogin, String vUsername, String vPassword, boolean vThanks, boolean desktopClipboard, boolean forceOrder, boolean subLocation, boolean threadSubLocation, boolean clearCompleted) {
public Settings(String downloadPath, int maxThreads, boolean autoStart, boolean vLogin, String vUsername, String vPassword, boolean vThanks, boolean desktopClipboard, boolean forceOrder, boolean subLocation, boolean threadSubLocation, boolean clearCompleted, boolean viewPhotos) {
this.downloadPath = downloadPath;
this.maxThreads = maxThreads;
this.autoStart = autoStart;
@@ -189,6 +195,7 @@ public class AppSettingsService {
this.subLocation = subLocation;
this.threadSubLocation = threadSubLocation;
this.clearCompleted = clearCompleted;
this.viewPhotos = viewPhotos;
}
}
}
@@ -41,13 +41,6 @@ public class PathService {
* @return Sanitized local path string
*/
public final String formatImageFileName(String imageName) {
int extensionIndex = imageName.lastIndexOf('.');
String fileName;
if (extensionIndex != -1) {
fileName = imageName.substring(0, extensionIndex);
} else {
fileName = imageName;
}
return sanitize(fileName);
return sanitize(imageName);
}
}
@@ -0,0 +1,103 @@
package tn.mnlr.vripper.services;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.imgscalr.Scalr;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.file.Files;
import java.util.Objects;
@Service
public class ThumbnailGenerator {
@Getter
private LoadingCache<CacheKey, byte[]> thumbnails;
@Value("${base.dir}")
private String baseDir;
@Autowired
private PathService pathService;
private File cacheFolder;
CacheLoader<CacheKey, byte[]> loader = new CacheLoader<>() {
@Override
public byte[] load(CacheKey key) throws Exception {
File destinationFolder = pathService.getDownloadDestinationFolder(key.getPostId());
if (!destinationFolder.exists() || !destinationFolder.isDirectory()) {
return null;
}
return Files.readAllBytes(generateThumbnail(new File(destinationFolder, key.getImgName()), key.getPostId()).toPath());
}
};
@PostConstruct
private void init() throws Exception {
cacheFolder = new File(baseDir + File.separator + ".vripper" + File.separator + "cache");
cacheFolder.mkdirs();
if (!cacheFolder.exists()) {
throw new Exception(String.format("%s could not be created", cacheFolder.toString()));
}
thumbnails = CacheBuilder.newBuilder()
.maximumSize(20000)
.build(loader);
}
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()));
}
File postsCacheFolder = new File(cacheFolder, postId);
postsCacheFolder.mkdirs();
if (!postsCacheFolder.exists()) {
throw new Exception(String.format("%s could not be created", postsCacheFolder.toString()));
}
File thumbFile = new File(postsCacheFolder, inputFile.getName());
if (thumbFile.exists()) {
return thumbFile;
}
BufferedImage image = ImageIO.read(inputFile);
BufferedImage resize = Scalr.resize(image, 350, 350);
ImageIO.write(resize, "jpg", thumbFile);
image.flush();
resize.flush();
return thumbFile;
}
@Getter
@Setter
@NoArgsConstructor
public static class CacheKey {
private String postId;
private String imgName;
public CacheKey(String postId, String imgName) {
this.postId = postId;
this.imgName = imgName;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CacheKey cacheKey = (CacheKey) o;
return Objects.equals(postId, cacheKey.postId) &&
Objects.equals(imgName, cacheKey.imgName);
}
@Override
public int hashCode() {
return Objects.hash(postId, imgName);
}
}
}
@@ -0,0 +1,143 @@
package tn.mnlr.vripper.web.restendpoints;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import tn.mnlr.vripper.services.AppSettingsService;
import tn.mnlr.vripper.services.PathService;
import tn.mnlr.vripper.services.ThumbnailGenerator;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import javax.servlet.http.HttpServletResponse;
import java.awt.*;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@RestController
@CrossOrigin(value = "*")
public class GalleryEndpoint {
private static final Logger logger = LoggerFactory.getLogger(GalleryEndpoint.class);
@Autowired
private PathService pathService;
@Autowired
private ThumbnailGenerator thumbnailGenerator;
private static final String cacheControl = CacheControl.maxAge(365, TimeUnit.DAYS).getHeaderValue();
@Autowired
private AppSettingsService appSettingsService;
@ExceptionHandler(Exception.class)
public ResponseEntity handleException(Exception e) {
logger.error("Error when process request", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(e.getMessage());
}
@GetMapping(value = "/image/{postId}/{imgName}", produces = {MediaType.IMAGE_JPEG_VALUE})
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity<byte[]> getImage(@PathVariable("postId") @NonNull String postId, @PathVariable("imgName") @NonNull String imgName, HttpServletResponse response) throws Exception {
if (!appSettingsService.isViewPhotos()) {
return new ResponseEntity("Gallery option is disabled", HttpStatus.BAD_REQUEST);
}
response.addHeader("Cache-Control", cacheControl);
File destinationFolder = pathService.getDownloadDestinationFolder(postId);
return ResponseEntity.ok(Files.readAllBytes(Paths.get(destinationFolder.toPath().toString(), imgName)));
}
@GetMapping(value = "/image/thumb/{postId}/{imgName}", produces = {MediaType.IMAGE_JPEG_VALUE})
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity<byte[]> getImageThumb(@PathVariable("postId") @NonNull String postId, @PathVariable("imgName") @NonNull String imgName, HttpServletResponse response) throws Exception {
if (!appSettingsService.isViewPhotos()) {
return new ResponseEntity("Gallery option is disabled", HttpStatus.BAD_REQUEST);
}
response.addHeader("Cache-Control", cacheControl);
File destinationFolder = pathService.getDownloadDestinationFolder(postId);
if (destinationFolder.exists() && destinationFolder.isDirectory()) {
return ResponseEntity.ok(thumbnailGenerator.getThumbnails().get(new ThumbnailGenerator.CacheKey(postId, imgName)));
}
return new ResponseEntity("Gallery does not exist in download location, you probably removed it", HttpStatus.BAD_REQUEST);
}
@GetMapping("/gallery/{postId}")
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity<List<GalleryImage>> getGallery(@PathVariable("postId") @NonNull String postId) {
if (!appSettingsService.isViewPhotos()) {
return new ResponseEntity("Gallery option is disabled", HttpStatus.BAD_REQUEST);
}
File destinationFolder = pathService.getDownloadDestinationFolder(postId);
if (destinationFolder.exists() && destinationFolder.isDirectory()) {
return ResponseEntity.ok(
Arrays.stream(Objects.requireNonNull(destinationFolder.listFiles()))
.filter(f -> !f.getName().endsWith("tmp"))
.sorted(Comparator.comparing(File::getName))
.map(GalleryImage::fromFile)
.filter(Objects::nonNull)
.collect(Collectors.toList())
);
}
return new ResponseEntity("Gallery does not exist in download location, you probably removed it", HttpStatus.BAD_REQUEST);
}
}
@Getter
@Setter
@NoArgsConstructor
class GalleryImage {
private static final Logger logger = LoggerFactory.getLogger(GalleryImage.class);
private String src;
private String msrc;
private double w;
private double h;
public GalleryImage(String src, String msrc, double w, double h) {
this.src = src;
this.msrc = msrc;
this.w = w;
this.h = h;
}
public static GalleryImage fromFile(File file) {
Dimension dimension;
try (ImageInputStream in = ImageIO.createImageInputStream(file)) {
final Iterator<ImageReader> readers = ImageIO.getImageReaders(in);
if (readers.hasNext()) {
ImageReader reader = readers.next();
try {
reader.setInput(in);
dimension = new Dimension(reader.getWidth(0), reader.getHeight(0));
} finally {
reader.dispose();
}
} else {
logger.error(String.format("No reader found for image %s", file.toString()));
return null;
}
} catch (Exception e) {
logger.error(String.format("Failed to create image object for %s", file.toString()), e);
return null;
}
return new GalleryImage(file.getName(), file.getName(), dimension.getWidth(), dimension.getHeight());
}
}
@@ -79,6 +79,7 @@ public class SettingsRestEndpoint {
this.settings.setSubLocation(settings.isSubLocation());
this.settings.setThreadSubLocation(settings.isThreadSubLocation());
this.settings.setClearCompleted(settings.isClearCompleted());
this.settings.setViewPhotos(settings.isViewPhotos());
this.settings.save();
@@ -103,7 +104,8 @@ public class SettingsRestEndpoint {
settings.isForceOrder(),
settings.isSubLocation(),
settings.isThreadSubLocation(),
settings.isClearCompleted()
settings.isClearCompleted(),
settings.isViewPhotos()
);
}
+14 -4
View File
@@ -27,9 +27,14 @@
"src/assets"
],
"styles": [
"src/styles.scss"
"src/styles.scss",
"./node_modules/photoswipe/dist/photoswipe.css",
"./node_modules/photoswipe/dist/default-skin/default-skin.css"
],
"scripts": []
"scripts": [
"./node_modules/photoswipe/dist/photoswipe.min.js",
"./node_modules/photoswipe/dist/photoswipe-ui-default.min.js"
]
},
"configurations": {
"production": {
@@ -83,9 +88,14 @@
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": [
"src/styles.scss"
"src/styles.scss",
"./node_modules/photoswipe/dist/photoswipe.css",
"./node_modules/photoswipe/dist/default-skin/default-skin.css"
],
"scripts": [
"./node_modules/photoswipe/dist/photoswipe.min.js",
"./node_modules/photoswipe/dist/photoswipe-ui-default.min.js"
],
"scripts": [],
"assets": [
"src/favicon.ico",
"src/assets"
+437 -244
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "vripper-ui",
"version": "2.6.0",
"version": "2.8.1",
"scripts": {
"ng": "ng",
"start": "ng serve",
@@ -29,12 +29,13 @@
"core-js": "^2.5.4",
"hammerjs": "^2.0.8",
"ngx-electron": "^2.2.0",
"photoswipe": "^4.1.3",
"rxjs": "~6.5.3",
"tslib": "^1.9.0",
"zone.js": "~0.8.26"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.803.20",
"@angular-devkit/build-angular": "^0.803.21",
"@angular/cli": "~8.3.20",
"@angular/compiler-cli": "^8.2.14",
"@angular/language-service": "~8.2.14",
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.6.0</version>
<version>2.8.1</version>
</parent>
<artifactId>vripper-ui</artifactId>
<name>vripper-ui</name>
+3 -4
View File
@@ -3,7 +3,7 @@ import { ClipboardService } from './clipboard.service';
import { ElectronService } from 'ngx-electron';
import { Component, OnDestroy, AfterViewInit, Renderer2, ChangeDetectionStrategy, NgZone } from '@angular/core';
import { MatDialog } from '@angular/material';
import { Subscription, BehaviorSubject, Subject } from 'rxjs';
import { Subscription, BehaviorSubject, Subject, merge } from 'rxjs';
import { WsConnectionService, WSState } from './ws-connection.service';
@Component({
@@ -39,9 +39,8 @@ export class AppComponent implements OnDestroy, AfterViewInit {
}), 500);
} else if (wsState === WSState.OPEN) {
this.clipboardService.init();
this.appService
.loadTheme()
.subscribe(() => this.ngZone.run(() => this.appState.next('CONNECTED')));
merge(this.appService.loadTheme(), this.appService.loadSettings())
.subscribe(() => this.ngZone.run(() => this.appState.next('CONNECTED')));
}
}));
}
+7 -2
View File
@@ -38,6 +38,8 @@ import { SelectionService } from './selection-service';
import { ToolbarComponent } from './toolbar/toolbar.component';
import { GrabQueueComponent } from './grab-queue/grab-queue.component';
import { UrlGrabRendererComponent } from './grab-queue/url-renderer.component';
import { GalleryComponent } from './gallery/gallery.component';
import { PhotoSwipeComponent } from './photo-swipe/photo-swipe.component';
@NgModule({
declarations: [
@@ -60,7 +62,9 @@ import { UrlGrabRendererComponent } from './grab-queue/url-renderer.component';
StatusBarComponent,
ToolbarComponent,
GrabQueueComponent,
PostContextMenuComponent
PostContextMenuComponent,
GalleryComponent,
PhotoSwipeComponent
],
entryComponents: [
PostDetailComponent,
@@ -69,7 +73,8 @@ import { UrlGrabRendererComponent } from './grab-queue/url-renderer.component';
AppPreviewComponent,
ScanComponent,
MultiPostComponent,
PostContextMenuComponent
PostContextMenuComponent,
GalleryComponent
],
imports: [
BrowserAnimationsModule,
+35 -7
View File
@@ -1,11 +1,14 @@
import { Settings } from './common/settings.model';
import { environment } from 'src/environments/environment';
import { MatDialog } from '@angular/material';
import { HttpClient } from '@angular/common/http';
import { Injectable, Renderer2 } from '@angular/core';
import { ServerService } from './server-service';
import { tap } from 'rxjs/operators';
import { ScanComponent } from './scan/scan.component';
import { Observable } from 'rxjs';
import { Observable, Subject } from 'rxjs';
import { BreakpointState, Breakpoints, BreakpointObserver } from '@angular/cdk/layout';
import { Title } from '@angular/platform-browser';
@Injectable()
export class AppService {
@@ -14,10 +17,25 @@ export class AppService {
private serverService: ServerService,
public dialog: MatDialog,
private breakpointObserver: BreakpointObserver,
) {}
private titleService: Title
) {
this.titleService.setTitle('VRipper ' + environment.version);
}
private _darkTheme = false;
private _settings: Settings;
private _renderer: Renderer2;
// private settings$: Subject<Settings> = new Subject();
get settings(): Settings {
return { ...this._settings };
}
get darkTheme(): boolean {
return this._darkTheme;
}
darkTheme = false;
_renderer: Renderer2;
set renderer(renderer: Renderer2) {
this._renderer = renderer;
}
@@ -25,8 +43,8 @@ export class AppService {
isExtraSmall: Observable<BreakpointState> = this.breakpointObserver.observe(Breakpoints.XSmall);
updateTheme(darkTheme: boolean) {
this.darkTheme = darkTheme;
if (this.darkTheme) {
this._darkTheme = darkTheme;
if (this._darkTheme) {
this._renderer.addClass(document.body, 'dark-theme');
this._renderer.removeClass(document.body, 'light-theme');
} else {
@@ -35,17 +53,27 @@ export class AppService {
}
this.httpClient
.post<Theme>(this.serverService.baseUrl + '/settings/theme', {
darkTheme: this.darkTheme
darkTheme: this._darkTheme
})
.subscribe();
}
updateSettings(settings: Settings) {
this._settings = settings;
}
loadTheme() {
return this.httpClient
.get<Theme>(this.serverService.baseUrl + '/settings/theme')
.pipe(tap(theme => this.updateTheme(theme.darkTheme)));
}
loadSettings() {
return this.httpClient
.get<Settings>(this.serverService.baseUrl + '/settings')
.pipe(tap(settings => this.updateSettings(settings)));
}
scan(url?: string) {
const scanDialog = this.dialog.open(ScanComponent, {
@@ -7,4 +7,5 @@ export interface Settings {
vPassword: string;
vThanks: boolean;
desktopClipboard: boolean;
viewPhotos: boolean;
}
@@ -0,0 +1,26 @@
<div class="dialog-container" fxLayout="column" style="height: 100%;">
<div fxFlex="nogrow">
<h2 class="no-wrap" mat-dialog-title>{{ dialogData.title }}</h2>
</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"/>
</figure>
</ng-container>
<app-photo-swipe #photoSwipe></app-photo-swipe>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" fxFlex="nogrow">
<button (click)="refresh()" color="primary" mat-raised-button>
<mat-icon>refresh</mat-icon>
<span>Refresh</span></button>
<button mat-dialog-close mat-raised-button>Close</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,25 @@
.container {
height: 100%;
}
.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;
}
img {
height: 250px;
width: auto;
}
figure {
}
@@ -0,0 +1,87 @@
import { IImage, PhotoSwipeComponent } from './../photo-swipe/photo-swipe.component';
import { Component, OnInit, ChangeDetectionStrategy, Inject, NgZone, OnDestroy, ViewChild } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { MatDialogRef, MAT_DIALOG_DATA, MatSnackBar } from '@angular/material';
import { ServerService } from '../server-service';
import { PostState } from '../posts/post-state.model';
import { BehaviorSubject, Subject } from 'rxjs';
class Image extends IImage {
constructor(public src: string, public msrc: string, public w: number, public h: number, public _initialized: boolean) {
super(src, msrc, w, h);
}
}
@Component({
selector: 'app-gallery',
templateUrl: './gallery.component.html',
styleUrls: ['./gallery.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GalleryComponent implements OnInit, OnDestroy {
constructor(
public dialogRef: MatDialogRef<GalleryComponent>,
@Inject(MAT_DIALOG_DATA) public dialogData: PostState,
private httpClient: HttpClient,
private serverService: ServerService,
private _snackBar: MatSnackBar,
private ngZone: NgZone
) {}
images: Subject<Image[]> = new BehaviorSubject([]);
_images: Image[] = [];
@ViewChild('photoSwipe', { static: true }) photoSwipe: PhotoSwipeComponent;
loaded(img: Image) {
img._initialized = true;
}
ngOnInit() {
this.refresh();
}
ngOnDestroy() {}
openSlideshow(index: number) {
this.photoSwipe.openGallery(this._images, {
index: index,
getThumbBoundsFn: this.getThumbBoundsFn,
showHideOpacity: true,
preload: [1, 8]
});
}
getThumbBoundsFn(index: number) {
// See Options -> getThumbBoundsFn section of documentation for more info
const thumbnail = document.querySelectorAll('.masonry-brick img')[index],
pageYScroll = window.pageYOffset || document.documentElement.scrollTop,
rect = thumbnail.getBoundingClientRect();
return { x: rect.left, y: rect.top + pageYScroll, w: rect.width };
}
refresh() {
this.httpClient.get<Image[]>(this.serverService.baseUrl + '/gallery/' + this.dialogData.postId).subscribe(
response => {
this.ngZone.run(() => {
response.forEach(i => {
i.src = this.serverService.baseUrl + '/image/' + this.dialogData.postId + '/' + i.src;
i.msrc = this.serverService.baseUrl + '/image/thumb/' + this.dialogData.postId + '/' + i.msrc;
i._initialized = false;
});
this._images = response;
this.images.next(this._images);
});
},
error => {
this.ngZone.run(() => {
this.dialogRef.close();
this._snackBar.open(error.error || 'Unexpected error, check log file', null, {
duration: 5000
});
});
}
);
}
}
@@ -5,3 +5,45 @@ table {
:host ::ng-deep .mat-tab-body-wrapper {
height: 100%;
}
.app-custom-plain-container-with-desc {
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
flex-wrap: wrap;
figure {
margin: 0;
position: relative;
img {
max-width: 100%;
height: auto;
cursor: pointer;
}
figcaption {
background: rgba(0, 0, 0, .5);
color: #fff;
font-size: 85%;
padding: 5px;
position: absolute;
bottom: 3px;
left: 0;
right: 0;
}
}
.description {
font-weight: 400;
text-align: center;
}
.app-custom-image-with-desc {
height: auto;
margin-right: 2px;
width: 200px;
align-self: start;
}
}
+4 -2
View File
@@ -16,7 +16,8 @@ import {
MatTabsModule,
MatDividerModule,
MatChipsModule,
MatListModule
MatListModule,
MatProgressSpinnerModule
} from '@angular/material';
@NgModule({
@@ -36,7 +37,8 @@ import {
MatTabsModule,
MatDividerModule,
MatChipsModule,
MatListModule
MatListModule,
MatProgressSpinnerModule
],
providers: [MatSnackBar]
})
@@ -0,0 +1,51 @@
<!-- Root element of PhotoSwipe. Must have class pswp. -->
<div #photoSwipe aria-hidden="true" class="pswp" role="dialog" tabindex="-1">
<!-- Background of PhotoSwipe.
It's a separate element as animating opacity is faster than rgba(). -->
<div class="pswp__bg"></div>
<!-- Slides wrapper with overflow:hidden. -->
<div class="pswp__scroll-wrap">
<!-- Container that holds slides.
PhotoSwipe keeps only 3 of them in the DOM to save memory.
Don't modify these 3 pswp__item elements, data is added later on. -->
<div class="pswp__container">
<div class="pswp__item"></div>
<div class="pswp__item"></div>
<div class="pswp__item"></div>
</div>
<!-- Default (PhotoSwipeUI_Default) interface on top of sliding area. Can be changed. -->
<div class="pswp__ui pswp__ui--hidden">
<div class="pswp__top-bar">
<!-- Controls are self-explanatory. Order can be changed. -->
<div class="pswp__counter"></div>
<button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
<button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
<button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
<!-- Preloader demo https://codepen.io/dimsemenov/pen/yyBWoR -->
<!-- element will get class pswp__preloader--active when preloader is running -->
<div class="pswp__preloader">
<div class="pswp__preloader__icn">
<div class="pswp__preloader__cut">
<div class="pswp__preloader__donut"></div>
</div>
</div>
</div>
</div>
<button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)"></button>
<button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)"></button>
<div class="pswp__caption">
<div class="pswp__caption__center"></div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,23 @@
import { Component, ViewChild, ElementRef, Input } from '@angular/core';
import PhotoSwipe from 'photoswipe';
import PhotoSwipeUI_Default from 'photoswipe/dist/photoswipe-ui-default';
export class IImage {
constructor(public src: string, public msrc: string, public w: number, public h: number) {}
}
@Component({
selector: 'app-photo-swipe',
templateUrl: './photo-swipe.component.html',
styleUrls: ['./photo-swipe.component.css']
})
export class PhotoSwipeComponent {
@ViewChild('photoSwipe', { static: true }) photoSwipe: ElementRef;
openGallery(images: IImage[], options?: any) {
options = { ...options, history: false };
const gallery = new PhotoSwipe(this.photoSwipe.nativeElement, PhotoSwipeUI_Default, images, options);
gallery.init();
}
}
@@ -1,11 +1,13 @@
import { AppService } from './../app.service';
import { GalleryComponent } from './../gallery/gallery.component';
import { ServerService } from './../server-service';
import { HttpClient } from '@angular/common/http';
import { ElectronService } from 'ngx-electron';
import { Component, ChangeDetectionStrategy, NgZone } from '@angular/core';
import { Component, ChangeDetectionStrategy, NgZone, OnDestroy } from '@angular/core';
import { trigger, state, style, transition, animate } from '@angular/animations';
import { MatDialog, MatSnackBar } from '@angular/material';
import { PostDetailComponent } from '../post-detail/post-detail.component';
import { Observable } from 'rxjs';
import { Observable, Subject, BehaviorSubject, Subscription } from 'rxjs';
import { BreakpointState, BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { PostState } from './post-state.model';
import { DownloadPath } from '../common/download-path.model';
@@ -17,7 +19,7 @@ import { RemoveResponse } from '../common/remove-response.model';
@Component({
selector: 'app-post-ctx-menu',
template: `
<mat-card>
<mat-card class="mat-elevation-z4">
<mat-action-list>
<button
(click)="restart()"
@@ -47,6 +49,10 @@ import { RemoveResponse } from '../common/remove-response.model';
<mat-icon>list</mat-icon>
<span>Files</span>
</button>
<button *ngIf="isViewEnabled | async" (click)="openGallery()" mat-list-item>
<mat-icon>photo_library</mat-icon>
<span>View Photos</span>
</button>
<button (click)="open()" *ngIf="electronService.isElectronApp" mat-list-item>
<mat-icon>open_in_new</mat-icon>
<span>Download Location</span>
@@ -85,16 +91,19 @@ export class PostContextMenuComponent {
private httpClient: HttpClient,
private serverService: ServerService,
private _snackBar: MatSnackBar,
private ngZone: NgZone
private ngZone: NgZone,
private appService: AppService
) {
if (this.electronService.isElectronApp) {
this.fs = this.electronService.remote.require('fs');
}
this.isViewEnabled.next(this.appService.settings.viewPhotos);
}
isViewEnabled: Subject<boolean> = new BehaviorSubject(false);
restart() {
this.contextMenuService.closePostCtxtMenu();
this.httpClient.post(this.serverService.baseUrl + '/post/restart', [ this.postState.postId ]).subscribe(
this.httpClient.post(this.serverService.baseUrl + '/post/restart', [this.postState.postId]).subscribe(
() => {
this._snackBar.open('Download started', null, {
duration: 5000
@@ -112,34 +121,34 @@ export class PostContextMenuComponent {
this.contextMenuService.closePostCtxtMenu();
this.ngZone.run(() => {
this.dialog
.open(ConfirmDialogComponent, {
maxHeight: '100vh',
maxWidth: '100vw',
height: '200px',
width: '60%',
data: { header: 'Confirmation', content: 'Are you sure you want to remove this item ?' }
})
.afterClosed()
.pipe(
filter(e => e === 'yes'),
flatMap(e =>
this.httpClient.post<RemoveResponse>(this.serverService.baseUrl + '/post/remove', [ this.postState.postId ])
.open(ConfirmDialogComponent, {
maxHeight: '100vh',
maxWidth: '100vw',
height: '200px',
width: '60%',
data: { header: 'Confirmation', content: 'Are you sure you want to remove this item ?' }
})
.afterClosed()
.pipe(
filter(e => e === 'yes'),
flatMap(e =>
this.httpClient.post<RemoveResponse>(this.serverService.baseUrl + '/post/remove', [this.postState.postId])
)
)
)
.subscribe(
() => {},
error => {
this._snackBar.open(error.error || 'Unexpected error, check log file', null, {
duration: 5000
});
}
);
.subscribe(
() => {},
error => {
this._snackBar.open(error.error || 'Unexpected error, check log file', null, {
duration: 5000
});
}
);
});
}
stop() {
this.contextMenuService.closePostCtxtMenu();
this.httpClient.post(this.serverService.baseUrl + '/post/stop', [ this.postState.postId ]).subscribe(
this.httpClient.post(this.serverService.baseUrl + '/post/stop', [this.postState.postId]).subscribe(
() => {
this._snackBar.open('Download stopped', null, {
duration: 5000
@@ -171,7 +180,34 @@ export class PostContextMenuComponent {
dialogRef.updateSize('90%', '90%');
}
});
dialogRef.afterClosed().subscribe(() => {
if (smallDialogSubscription != null) {
smallDialogSubscription.unsubscribe();
}
});
});
}
openGallery() {
this.contextMenuService.closePostCtxtMenu();
this.ngZone.run(() => {
const dialogRef = this.dialog.open(GalleryComponent, {
width: '90%',
height: '90%',
maxWidth: '100vw',
maxHeight: '100vh',
data: this.postState
});
const smallDialogSubscription = this.isExtraSmall.subscribe(result => {
if (result.matches) {
dialogRef.updateSize('100%', '100%');
} else {
dialogRef.updateSize('90%', '90%');
}
});
dialogRef.afterClosed().subscribe(() => {
if (smallDialogSubscription != null) {
smallDialogSubscription.unsubscribe();
@@ -42,8 +42,12 @@
>Save posts in sub folders
</mat-checkbox>
<mat-checkbox *ngIf="generalSettingsForm.get('subLocation').value" color="primary"
formControlName="threadSubLocation" name="threadSubLocation" style="margin-left: 15px"
<mat-checkbox
*ngIf="generalSettingsForm.get('subLocation').value"
color="primary"
formControlName="threadSubLocation"
name="threadSubLocation"
style="margin-left: 15px"
>Create a subfolder per thread
</mat-checkbox>
@@ -52,13 +56,17 @@
</mat-checkbox>
<mat-checkbox color="primary" formControlName="autoStart" name="autoStart"
>Auto start downloads</mat-checkbox
>
>Auto start downloads
</mat-checkbox>
<mat-checkbox color="primary" formControlName="clearCompleted" name="clearCompleted"
>Clear Finished
</mat-checkbox>
<mat-checkbox color="primary" formControlName="viewPhotos" name="viewPhotos">
Enable View photo in gallery option
</mat-checkbox>
<mat-slide-toggle color="primary" formControlName="vLogin" name="vLogin">
ViperGirls Authentication
</mat-slide-toggle>
@@ -66,7 +74,7 @@
<section *ngIf="generalSettingsForm.get('vLogin').value">
<div>
<mat-form-field>
<input formControlName="vUsername" matInput placeholder="ViperGirls Username" name="vUsername" />
<input formControlName="vUsername" matInput name="vUsername" placeholder="ViperGirls Username"/>
</mat-form-field>
<mat-form-field>
@@ -104,7 +112,8 @@
[disabled]="
(generalSettingsForm.pristine && desktopSettingsForm.pristine) ||
generalSettingsForm.invalid ||
desktopSettingsForm.invalid
desktopSettingsForm.invalid ||
loading
"
>
Apply
@@ -1,3 +1,4 @@
import { finalize } from 'rxjs/operators';
import { AppService } from './../app.service';
import { ClipboardService } from './../clipboard.service';
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
@@ -25,6 +26,8 @@ export class SettingsComponent implements OnInit {
private appService: AppService
) {}
loading = false;
generalSettingsForm = new FormGroup({
downloadPath: new FormControl(''),
maxThreads: new FormControl(''),
@@ -37,6 +40,7 @@ export class SettingsComponent implements OnInit {
vUsername: new FormControl(''),
vPassword: new FormControl(''),
vThanks: new FormControl(false),
viewPhotos: new FormControl(false)
});
desktopSettingsForm = new FormGroup({
@@ -49,6 +53,10 @@ export class SettingsComponent implements OnInit {
this.appService.updateTheme(this.darkTheme);
}
updateSettings(settings: Settings) {
this.appService.updateSettings(settings);
}
ngOnInit() {
this.darkTheme = this.appService.darkTheme;
this.httpClient.get<Settings>(this.serverService.baseUrl + '/settings').subscribe(
@@ -65,27 +73,27 @@ export class SettingsComponent implements OnInit {
}
browse() {
this.electronService.remote.dialog.showOpenDialog(
this.electronService.remote.getCurrentWindow(),
{
this.electronService.remote.dialog
.showOpenDialog(this.electronService.remote.getCurrentWindow(), {
properties: ['openDirectory']
}
).then((value: OpenDialogReturnValue) => {
if (value.filePaths !== undefined) {
this.generalSettingsForm.get('downloadPath').setValue(value.filePaths[0]);
this.generalSettingsForm.get('downloadPath').markAsDirty();
this.generalSettingsForm.get('downloadPath').markAsTouched();
}
});
})
.then((value: OpenDialogReturnValue) => {
if (value.filePaths !== undefined) {
this.generalSettingsForm.get('downloadPath').setValue(value.filePaths[0]);
this.generalSettingsForm.get('downloadPath').markAsDirty();
this.generalSettingsForm.get('downloadPath').markAsTouched();
}
});
}
onSubmit(): void {
this.loading = true;
this.httpClient
.post<Settings>(this.serverService.baseUrl + '/settings', {
...this.generalSettingsForm.value,
...this.desktopSettingsForm.value
})
.pipe(finalize(() => (this.loading = false)))
.subscribe(
data => {
this._snackBar.open('Settings updated', null, {
@@ -94,6 +102,7 @@ export class SettingsComponent implements OnInit {
this.generalSettingsForm.reset(data);
this.desktopSettingsForm.reset(data);
this.clipboardService.init(data);
this.updateSettings(data);
},
error => {
this._snackBar.open(error.error || 'Unexpected error, check log file', null, {
@@ -1,4 +1,5 @@
export const environment = {
production: true,
localhost: ''
localhost: '',
version: '2.8.1'
};
+2 -1
View File
@@ -4,7 +4,8 @@
export const environment = {
production: false,
localhost: 'http://localhost:8080'
localhost: 'http://localhost:8080',
version: '2.8.1'
};
/*
+7 -7
View File
@@ -1,15 +1,15 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>V Ripper</title>
<base href="/">
<meta charset="utf-8"/>
<title>VRipper</title>
<base href="/"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<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>
<app-root></app-root>
</body>
</html>