Compare commits

...
78 Commits
Author SHA1 Message Date
death-claw a75ce181ff v2.11.6 2020-05-04 21:07:16 +01:00
death-claw 37ae6b4ebf Merge branch 'master' of https://github.com/death-claw/vripper-project 2020-05-04 21:06:48 +01:00
death-claw 6631a6a95b PNG filenames are now correctly named
Fix issue with special characters in post titles
2020-05-04 21:04:51 +01:00
death-clawandGitHub 918f8578ad Update CHANGELOG.md 2020-05-03 16:40:26 +01:00
death-claw 10fa9f68ae v2.11.5 2020-05-03 15:18:28 +01:00
death-claw 6b5c9ccb0f Reduce rows buffer size 2020-05-03 15:16:31 +01:00
death-claw 652e8f1a9d v2.11.4 2020-05-03 14:24:27 +01:00
death-claw fe53ae733d Code cleanup
Dependencies update
2020-05-03 14:22:40 +01:00
death-claw 690dab2ae8 v2.11.3 2020-04-30 18:26:42 +01:00
death-claw 530542163e Add missing files 2020-04-30 18:26:23 +01:00
death-claw cf7f4c75c0 Source code restructure
UI fixes and enhancements
Add menu entry to rename a gallery to first alternative title
Add menu on toolbar for massive rename to first alternative title
2020-04-30 18:23:11 +01:00
death-claw c6b395cf4c v2.11.2 2020-04-26 22:06:34 +01:00
death-claw e72edeed62 Fix icon
Bug fixes
2020-04-26 22:02:51 +01:00
death-claw 3f22d53f89 v2.11.1 2020-04-25 16:32:25 +01:00
death-claw 08c0fa62b1 Add option to rename gallery
Code source maintenance
2020-04-25 16:28:56 +01:00
death-claw 4c79cca079 v2.11.0 2020-03-29 17:37:28 +01:00
death-clawandGitHub 67096242a2 Merge pull request #4 from life-claw/pixroute
Added Support for pixroute.com
2020-03-29 17:30:34 +01:00
death-claw f66112375d Merge branch 'master' of https://github.com/death-claw/vripper-project 2020-03-29 17:28:39 +01:00
death-claw c5a082e248 Upgrade Electron to v8
Upgrade Angular to v9
App settings are now portable in a json file
Add grab post title from thread option
General code source maintenance
2020-03-29 17:03:14 +01:00
life-claw c2a1b0aa33 Added Support for pixroute.com 2020-03-08 13:25:57 -05:00
death-clawandGitHub a89dcc96d8 Update README.md 2020-02-11 21:16:37 +01:00
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
death-claw cc1bbd1231 v2.10.1 2019-12-27 23:05:10 +01:00
death-claw b29cba2911 Bug fix 2019-12-27 22:58:33 +01:00
death-claw 13bde3aa2c 2.10.0 2019-12-27 21:27:27 +01:00
death-claw 35b4f5fc97 Add support for imgspice
Fix spring dependency bug
2019-12-27 21:25:33 +01:00
death-claw 1b0482b7d4 v2.9.1 2019-12-27 20:10:31 +01:00
death-claw c9ff6eeab6 Fix build issue 2019-12-27 20:09:16 +01:00
death-claw 5ac57b743d v2.9.0 2019-12-27 20:07:19 +01:00
death-claw f7b219701f Allow many hosts to run at the same time
Add title to gallery
Add global concurrent downloads
Decrease timeout for fast fail
Add counter badge in link collector
2019-12-27 20:03:35 +01:00
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
death-claw f3b592e81b v2.6.0 2019-12-21 08:39:04 +01:00
death-claw a5ff659fb4 Better quality app icon
Implement context menu on download tab
Add stop remove and start button to context menu
Link will not be removed automatically from link collector tab
Some UI fixes
2019-12-21 08:34:56 +01:00
death-claw ab80e2f3b3 v2.5.1 2019-12-09 20:26:52 +01:00
death-claw 2ef082f87e Add hosts column in Link collector grab menu
Disable download button when nothing selected in Link collector grab menu
2019-12-09 19:56:26 +01:00
death-claw a1b97e76a7 v2.5.0 2019-12-08 20:13:58 +01:00
death-claw 185a078b4d Missing change 2019-12-08 20:08:42 +01:00
death-claw 28a4c944ee Add support for multiple links at a time
Add the Link collector feature
Fix bug with previews
2019-12-08 20:07:13 +01:00
death-claw 23d3553d99 Include startup icon
Fix UI layout
2019-12-06 23:09:57 +01:00
death-claw fb09e4c46b v2.4.1 2019-11-26 20:03:10 +01:00
death-claw 4feb6f5fe1 Increase attempts for establishing ws connection between front and back end
Change default download folder to Home folder (installer), App folder (portable)
2019-11-26 20:01:08 +01:00
death-claw df1b9efbbb v2.4.0 2019-11-23 14:50:16 +01:00
death-claw 553924fa1d Pimpandhost Host support 2019-11-23 14:48:05 +01:00
death-claw a90dbd568f v2.3.2 2019-11-23 12:15:42 +01:00
death-claw ca7ca1ce8e Fix a bug with the scan component 2019-11-23 12:14:07 +01:00
190 changed files with 14907 additions and 9335 deletions
+160 -1
View File
@@ -1,5 +1,164 @@
# Changelog
## [2.11.6] - 2020-05-04
### Changed
- PNG filenames are now correctly named
- Fix issue with special characters in post titles
## [2.11.5] - 2020-05-03
### Changed
- Reduce rows buffer size
## [2.11.4] - 2020-05-03
### Changed
- Code cleanup
- Dependencies update
## [2.11.3] - 2020-04-30
### Changed
- Source code restructure
- UI fixes and enhancements
### Added
- Add menu entry to rename a gallery to first alternative title
- Add menu on toolbar for massive rename to first alternative title
## [2.11.2] - 2020-04-26
### Changed
- Bug fixes
## [2.11.1] - 2020-04-25
### Changed
- Code source maintenance
### Added
- Added option to rename gallery
## [2.11.0] - 2020-03-29
### Changed
- Upgrade Electron to v8
- Upgrade Angular to v9
- App settings are now portable in a json file
- General code source maintenance
### Added
- Added Support for pixroute.com
- Added grab post title from thread option
## [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
## [2.10.0] - 2019-12-27
### Changed
- Fix spring dependency bug
### Added
- Add support for imgspice
## [2.9.0] - 2019-12-27
### Changed
- Fix build issue
## [2.9.0] - 2019-12-27
### Changed
- Decrease timeout for fast fail
### Added
- Allow many hosts to run at the same time
- Add title to gallery
- Add global concurrent downloads
- Add counter badge in link collector
## [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
- Better quality app icon
- Implement context menu on download tab
- Add stop remove and start button to context menu
- Link will not be removed automatically from link collector tab
- Some UI fixes
## [2.5.1] - 2019-12-09
### Changed
- Disable download button when nothing selected in Link collector grab menu
### Added
- Add hosts column in Link collector grab menu
## [2.5.0] - 2019-12-08
### Changed
- Change loading screen
- UI Fixes
- Fix bug with previews
### Added
- Add link collector feature
- Add support for multiple links at a time
## [2.4.1] - 2019-11-26
### Changed
- Increase attempts for establishing ws connection between front and back end
- Change default download folder to Home folder (installer), App folder (portable)
## [2.4.0] - 2019-11-23
### Changed
- Pimpandhost Host support
## [2.3.2] - 2019-11-23
### Changed
- Fix a bug with the scan component
## [2.3.1] - 2019-11-22
### Changed
- Fix connexion reset issue
@@ -200,4 +359,4 @@
- Add real time communication for progress updates via websocket (SockJS)
- Add settings for download path, max concurrent download, enable/disable integration with vipergirls
- Add integration with vipergitls.to (possibility to auto leave thanks after grab post)
- Add support for acidimg.cc, imagezilla.net, imgbox.com, imx.to, pixhost.to, turboimagehost.com
- Add support for acidimg.cc, imagezilla.net, imgbox.com, imx.to, pixhost.to, turboimagehost.com
+6 -8
View File
@@ -1,26 +1,24 @@
# Viper Ripper!
This is my spin for a gallery ripper app for [vipergirls](https://vipergirls.to) website.
This is my spin for a cross platform gallery ripper app for [vipergirls](https://vipergirls.to) website.
The purpose of this project is to build a robust and clean application to conveniently download photo galleries using modern web technologies, Java + Spring boot for the back end and angular + electron for the front end.
## How to build
You need a recent version of maven 3.6.1+.
The application support 2 build modes:
- Desktop app (*Partially cross platform): Self contained app, built with electron, depends on Java runtimes.
- Server app (Fully cross platform): Depends on Java runtimes, you need a web browser to access the app UI.
- Desktop app: Self contained app, built with electron includes Java runtimes.
- Server app: Depends on Java runtimes, you need a web browser to access the app UI.
Most people will be interested only on the desktop app, however if you have a nas or a server, the server app is there for you.
To build the server app:
mvn clean install
mvn clean install -DskipTests
To build the desktop app:
mvn clean install -Pelectron
mvn clean install -Pelectron -DskipTests
Maven will automatically handle front end compilation. However, for development, you will need to install a recent version of nodejs on your system.
*Partially cross platform:
Technically cross platform, but the Desktop app relies on electron builder to make appropriate binaries for each platform. mac binaries can only be generated on a mac, and i don't own one. So i can only provide binaries for windows and linux.
The Server app however depends only on Java, so it will work just fine on a mac, you only need a browser to access it.
[![Donate with PayPal](https://raw.githubusercontent.com/stefan-niedermann/paypal-donate-button/master/paypal-donate-button.png)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=SC7M5LNWZ528Q)
+1 -1
View File
@@ -4,7 +4,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.3.1</version>
<version>2.11.6</version>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+121 -115
View File
@@ -1,3 +1,4 @@
require('v8-compile-cache');
const { app, BrowserWindow } = require("electron");
const path = require("path");
const url = require("url");
@@ -18,134 +19,139 @@ let terminationInteval;
let terminated = false;
process.on("uncaughtException", err => {
dialog.showErrorBox(err.message, err.stack);
process.exit(1);
dialog.showErrorBox(err.message, err.stack);
process.exit(1);
});
function createWindow() {
let icon;
if(process.platform === "win32") {
icon = __dirname + '/icon.ico';
} else {
icon = __dirname + '/icon.png';
}
win = new BrowserWindow({
width: 1024,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
nodeIntegration: true
},
icon: icon
});
win.removeMenu();
win.setMenu(null);
win.setMenuBarVisibility(false);
if (process.platform === 'win32') {
app.setAppUserModelId("tn.mnlr.vripper");
}
win.loadURL(
url.format({
pathname: path.join(__dirname, `vripper-ui/index.html`),
protocol: "file:",
slashes: true
})
);
let icon;
if(process.platform === "win32") {
icon = __dirname + '/icon.ico';
} else {
icon = __dirname + '/icon.png';
}
win = new BrowserWindow({
width: 1024,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
nodeIntegration: true
},
icon: icon
});
// win.webContents.openDevTools();
win.removeMenu();
win.setMenu(null);
win.setMenuBarVisibility(false);
win.on("closed", () => {
win = null;
});
win.loadURL(
url.format({
pathname: path.join(__dirname, `vripper-ui/index.html`),
protocol: "file:",
slashes: true
})
);
// win.webContents.openDevTools();
win.on("closed", () => {
win = null;
});
}
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",
"-Xmx1024m",
"-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();
app.quit();
} else {
app.on("second-instance", (event, commandLine, workingDirectory) => {
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
}
});
app.on("ready", createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
axios.post('http://localhost:' + serverPort + '/actuator/shutdown', {}, {
headers: { 'content-type': 'application/json' },
}).then((response) => {
terminationInteval= setInterval(() => {
terminationAttemps++;
if(terminated) {
console.log('viper server terminated');
clearInterval(terminationInteval);
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();
} else if(terminationAttemps > maxTerminationAttemps) {
console.log('viper server is not terminated');
console.log('Proceed to kill');
vripperServer.kill('SIGKILL');
clearInterval(terminationInteval);
app.quit();
}
}, 1000);
})
.catch((error) => {
// Terminate immediately
vripperServer.kill('SIGKILL');
terminated = true;
app.quit();
});
}
});
});
});
app.on("activate", () => {
if (win === null) {
createWindow();
}
});
app.on("second-instance", (event, commandLine, workingDirectory) => {
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
}
});
app.on("ready", createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
axios.post('http://localhost:' + serverPort + '/actuator/shutdown', {}, {
headers: { 'content-type': 'application/json' },
}).then((response) => {
terminationInteval= setInterval(() => {
terminationAttemps++;
if(terminated) {
console.log('viper server terminated');
clearInterval(terminationInteval);
app.quit();
} else if(terminationAttemps > maxTerminationAttemps) {
console.log('viper server is not terminated');
console.log('Proceed to kill');
vripperServer.kill('SIGKILL');
clearInterval(terminationInteval);
app.quit();
}
}, 1000);
})
.catch((error) => {
// Terminate immediately
console.log(error);
vripperServer.kill('SIGKILL');
terminated = true;
app.quit();
});
}
});
app.on("activate", () => {
if (win === null) {
createWindow();
}
});
}
+1011 -1507
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "vripper-electron",
"version": "2.3.1",
"version": "2.11.6",
"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": "vripper",
"productName": "VRipper",
"files": [
"**/*",
"!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
@@ -38,7 +38,7 @@
}
],
"win": {
"icon": "icon.png",
"icon": "icon.ico",
"target": [
"nsis"
]
@@ -62,7 +62,7 @@
"target": [
"dmg"
],
"icon": "icon.png"
"icon": "icon.icns"
}
},
"scripts": {
@@ -70,16 +70,16 @@
"dist": "node pre-build.js && electron-builder"
},
"devDependencies": {
"electron": "^6.0.9",
"electron-builder": "^21.2.0"
"electron": "8.2.4",
"electron-builder": "22.6.0"
},
"dependencies": {
"axios": "^0.19.0",
"cheerio": "^1.0.0-rc.3",
"copy-dir": "^1.1.0",
"custom-electron-titlebar": "^3.0.10",
"electron-context-menu": "^0.13.0",
"get-port": "^5.0.0",
"rimraf": "^2.6.3"
"rimraf": "^2.6.3",
"v8-compile-cache": "^2.1.0"
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.3.1</version>
<version>2.11.6</version>
</parent>
<artifactId>vripper-electron</artifactId>
<name>vripper-electron</name>
+11 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.3.1</version>
<version>2.11.6</version>
</parent>
<artifactId>vripper-server</artifactId>
<name>vripper-server</name>
@@ -67,6 +67,16 @@
<artifactId>failsafe</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<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>
@@ -0,0 +1,24 @@
package tn.mnlr.vripper;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import tn.mnlr.vripper.services.PersistenceService;
@Component
public class EventListenerBean {
@Autowired
private PersistenceService persistenceService;
@Getter
private static boolean init = false;
@EventListener
public void onApplicationEvent(ContextRefreshedEvent event) {
init = true;
persistenceService.restore();
}
}
@@ -27,7 +27,9 @@ public class SpringContext implements ApplicationContextAware {
public static void close() {
logger.info("Application terminating...");
context.close();
if (context != null) {
context.close();
}
}
@@ -1,77 +1,31 @@
package tn.mnlr.vripper;
import lombok.Getter;
import net.jodah.failsafe.RetryPolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.stereotype.Component;
import tn.mnlr.vripper.exception.VripperException;
import tn.mnlr.vripper.services.AppSettingsService;
import tn.mnlr.vripper.services.PersistenceService;
import tn.mnlr.vripper.services.VipergirlsAuthService;
import javax.annotation.PostConstruct;
import java.io.File;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.time.temporal.ChronoUnit;
@SpringBootApplication
public class VripperApplication {
private static final Logger logger = LoggerFactory.getLogger(VripperApplication.class);
public static final ExecutorService commonExecutor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
public static final RetryPolicy<Object> retryPolicy = new RetryPolicy<>()
.handleIf(e -> !(e instanceof InterruptedException))
.withDelay(1, 3, ChronoUnit.SECONDS)
.withMaxAttempts(5)
.abortOn(InterruptedException.class)
.onFailedAttempt(e -> logger.warn(String.format("#%d tries failed", e.getAttemptCount()), e.getLastFailure()));
public static void main(String[] args) {
try {
Runtime.getRuntime().addShutdownHook(new Thread(SpringContext::close));
SpringApplication.run(VripperApplication.class, args);
} catch (Exception e) {
logger.error("Failed to run the application", e);
}
}
@Component
public class AppCommandRunner implements CommandLineRunner {
@Autowired
private VipergirlsAuthService authService;
@Autowired
private PersistenceService persistenceService;
@Autowired
private AppSettingsService appSettingsService;
@Value("${base.dir}")
private String baseDir;
@Getter
private String dataPath;
@PostConstruct
public void init() {
dataPath = baseDir + File.separator + ".vripper" + File.separator + "data.json";
}
@Override
public void run(String... args) {
persistenceService.restore();
appSettingsService.restore();
registerShutdownHook();
try {
authService.authenticate();
} catch (VripperException e) {
logger.error("Cannot authenticate user with ViperGirls", e);
}
}
private void registerShutdownHook() {
Runtime.getRuntime().addShutdownHook(new Thread(SpringContext::close));
}
}
}
@@ -3,10 +3,11 @@ package tn.mnlr.vripper.entities;
import io.reactivex.disposables.Disposable;
import io.reactivex.processors.BehaviorProcessor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tn.mnlr.vripper.SpringContext;
import tn.mnlr.vripper.exception.PostParseException;
import tn.mnlr.vripper.host.Host;
import tn.mnlr.vripper.services.AppStateService;
@@ -14,48 +15,41 @@ import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
@Getter
@NoArgsConstructor
public class Image {
private static final Logger logger = LoggerFactory.getLogger(Image.class);
private AppStateService appStateService;
private Disposable subscription;
private final AppStateService appStateService;
private final String type = "img";
private String postId;
private String postName;
private Host host;
private String url;
private int index;
private AtomicLong current = new AtomicLong(0);
private Status status;
private BehaviorProcessor<Image> imageStateProcessor;
@Setter
private long total = 0;
private Status status;
private BehaviorProcessor<Image> imageStateProcessor;
private Disposable subscription;
public Image(String url, String postId, String postName, Host host, int index) {
private Image() {
this.appStateService = SpringContext.getBean(AppStateService.class);
}
public Image(String url, String postId, String postName, Host host, int index) throws PostParseException {
this();
this.url = url;
this.postId = postId;
this.postName = postName;
this.host = host;
this.index = index;
status = Status.STOPPED;
imageStateProcessor = BehaviorProcessor.create();
}
public void setAppStateService(AppStateService appStateService) {
this.appStateService = appStateService;
appStateService.getCurrentImages().put(this.url, this);
appStateService.getLiveImageUpdates().onNext(this);
if (!appStateService.newImage(this)) {
throw new PostParseException("Image already loaded");
}
}
public void setStatus(Status status) {
@@ -68,15 +62,11 @@ public class Image {
}
public void init() {
if (appStateService == null) {
logger.warn("Attempting to init the image whilst the App State is null, unexpected behaviour could occur from this");
return;
}
cleanup();
imageStateProcessor = BehaviorProcessor.create();
subscription = imageStateProcessor
.onBackpressureLatest()
.doOnNext(appStateService::onImageUpdate)
.onBackpressureBuffer()
.doOnNext(appStateService::imageUpdated)
.subscribe();
current.set(0);
@@ -104,7 +94,7 @@ public class Image {
}
private void update() {
if(imageStateProcessor == null) {
if (imageStateProcessor == null) {
return;
}
imageStateProcessor.onNext(this);
@@ -1,8 +1,10 @@
package tn.mnlr.vripper.entities;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import tn.mnlr.vripper.SpringContext;
import tn.mnlr.vripper.exception.PostParseException;
import tn.mnlr.vripper.host.Host;
import tn.mnlr.vripper.services.AppStateService;
@@ -15,10 +17,13 @@ import java.util.stream.Collectors;
@Getter
@ToString
@NoArgsConstructor
public class Post {
private AppStateService appStateService;
public enum METADATA {
PREVIEWS, RESOLVED_NAME, POSTED_BY, THANKED
}
private final AppStateService appStateService;
private Status status;
@@ -48,7 +53,15 @@ public class Post {
private String forum;
public Post(String title, String url, List<Image> images, Map<String, Object> metadata, String postId, String threadId, String threadTitle, String forum) {
@Setter
private String postFolderName;
private Post() {
this.appStateService = SpringContext.getBean(AppStateService.class);
}
public Post(String title, String url, List<Image> images, Map<String, Object> metadata, String postId, String threadId, String threadTitle, String forum) throws PostParseException {
this();
this.title = title;
this.url = url;
this.images = images;
@@ -60,12 +73,15 @@ public class Post {
this.hosts = this.images.stream().map(Image::getHost).map(Host::getHost).collect(Collectors.toSet());
total = images.size();
status = Status.STOPPED;
if (!this.appStateService.newPost(this)) {
throw new PostParseException("Post already loaded");
}
}
public void setAppStateService(AppStateService appStateService) {
this.appStateService = appStateService;
this.appStateService.getCurrentPosts().put(postId, this);
this.appStateService.getLivePostsState().onNext(this);
public void setTitle(String title) {
this.title = title;
updateNotification();
}
public void setRemoved(boolean removed) {
@@ -85,7 +101,7 @@ public class Post {
private void updateNotification() {
if (appStateService != null) {
appStateService.getLivePostsState().onNext(this);
appStateService.postUpdated(this);
}
}
@@ -34,6 +34,10 @@ public class AcidimgHost extends Host {
@Autowired
private ConnectionManager cm;
public AcidimgHost() {
super();
}
@Override
public String getHost() {
return host;
@@ -88,7 +92,7 @@ public class AcidimgHost extends Host {
throw new HostException(e);
}
if(imgNode == null) {
if (imgNode == null) {
throw new HostException("Cannot find the image node");
}
@@ -14,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import tn.mnlr.vripper.entities.Image;
import tn.mnlr.vripper.entities.Post;
import tn.mnlr.vripper.exception.DownloadException;
import tn.mnlr.vripper.exception.HostException;
import tn.mnlr.vripper.exception.HtmlProcessorException;
@@ -27,8 +28,12 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
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;
import java.util.Objects;
@Service
abstract public class Host {
@@ -58,6 +63,16 @@ abstract public class Host {
@Autowired
private PathService pathService;
@Autowired
private AppStateExchange appStateExchange;
@Autowired
private VipergirlsAuthService authService;
protected Host() {
}
abstract public String getHost();
abstract public String getLookup();
@@ -66,12 +81,21 @@ abstract public class Host {
return url.contains(getLookup());
}
public void download(Image image, ImageFileData imageFileData) throws DownloadException, InterruptedException {
public void download(final Post post, final Image image, final ImageFileData imageFileData) throws DownloadException, InterruptedException {
HttpClientContext context = HttpClientContext.create();
context.setCookieStore(new BasicCookieStore());
try {
File destinationFolder;
synchronized (appStateExchange.getPost(post.getPostId())) {
if (post.getPostFolderName() == null) {
pathService.createDefaultPostFolder(post);
}
destinationFolder = pathService.getDownloadDestinationFolder(post);
authService.leaveThanks(post);
}
appStateService.postDownloadingUpdate(image.getPostId());
imageFileData.setPageUrl(image.getUrl());
@@ -93,14 +117,7 @@ abstract public class Host {
String formatImageFileName = pathService.formatImageFileName(imageFileData.getImageName());
logger.debug(String.format("Sanitizing image name from %s to %s", imageFileData.getImageName(), formatImageFileName));
imageFileData.setImageName(formatImageFileName);
File destinationFolder = pathService.getDownloadDestinationFolder(image.getPostId());
logger.debug(String.format("Saving to %s", destinationFolder.getPath()));
if (!destinationFolder.exists()) {
logger.debug(String.format("Creating %s", destinationFolder.getPath()));
if (destinationFolder.mkdirs()) {
logger.debug(String.format("Folder %s is created", destinationFolder.toString()));
}
}
HttpClient client = cm.getClient().build();
@@ -112,7 +129,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()));
@@ -125,9 +142,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(post, outputFile, imageFileData.getImageName(), image.getIndex());
imageFileData.setFileName(finalName.getName());
}
} catch (Exception e) {
if (Thread.interrupted()) {
@@ -137,27 +156,52 @@ abstract public class Host {
}
}
private void checkImageTypeAndRename(File outputFile, String imageName, int index) throws HostException {
String formatName;
private File checkImageTypeAndRename(Post post, 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";
}
}
} else if (reader.getFormatName().toUpperCase().equals("PNG")) {
String imageNameLC = imageName.toLowerCase();
if (!imageNameLC.endsWith("_png")) {
imageName += ".png";
} else {
String toReplace = null;
if (imageNameLC.endsWith("_png")) {
toReplace = "_png";
}
if (toReplace != null) {
imageName = imageName.substring(0, imageName.length() - toReplace.length()) + ".png";
}
}
}
} 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 downloadDestinationFolder = pathService.getDownloadDestinationFolder(post);
File outImage = new File(downloadDestinationFolder, (appSettingsService.getSettings().getForceOrder() ? 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);
}
@@ -196,6 +240,20 @@ abstract public class Host {
}
}
protected String appendUri(String uri, String appendQuery) throws URISyntaxException {
URI oldUri = new URI(uri);
String newQuery = oldUri.getQuery();
if (newQuery == null) {
newQuery = appendQuery;
} else {
newQuery += "&" + appendQuery;
}
return new URI(oldUri.getScheme(), oldUri.getAuthority(),
oldUri.getPath(), newQuery, oldUri.getFragment()).toString();
}
private void setImageRequest(final ImageFileData imageFileData) {
HttpGet httpGet = cm.buildHttpGet(imageFileData.getImageUrl());
httpGet.addHeader("Referer", imageFileData.getPageUrl());
@@ -204,8 +262,22 @@ abstract public class Host {
protected abstract void setNameAndUrl(final String url, final ImageFileData imageFileData, final HttpClientContext context) throws HostException;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Host host = (Host) o;
return Objects.equals(getHost(), host.getHost());
}
@Override
public int hashCode() {
return Objects.hash(getHost());
}
@Getter
public static class Response {
protected Response(Document document, Header[] headers) {
this.document = document;
this.headers = headers;
@@ -31,6 +31,10 @@ public class ImageBamHost extends Host {
@Autowired
private ConnectionManager cm;
public ImageBamHost() {
super();
}
@Override
public String getHost() {
return host;
@@ -87,7 +91,7 @@ public class ImageBamHost extends Host {
imageFileData.setImageUrl(imgUrl);
imageFileData.setImageName(imgTitle.isEmpty() ? imgUrl.substring(imgUrl.lastIndexOf('/') + 1) : imgTitle);
} catch(Exception e) {
} catch (Exception e) {
throw new HostException("Unexpected error occurred", e);
}
}
@@ -3,14 +3,12 @@ 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;
import java.util.Optional;
@@ -20,8 +18,10 @@ public class ImageTwistHost extends Host {
private static final String IMG_XPATH = "//img[contains(@class, 'img')]";
private static final Logger logger = LoggerFactory.getLogger(ImageTwistHost.class);
private static final String host = "imagetwist.com";
@Autowired
private ConnectionManager cm;
public ImageTwistHost() {
super();
}
@Override
public String getHost() {
@@ -0,0 +1,83 @@
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']";
public ImageVenueHost() {
super();
}
@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);
}
}
}
@@ -3,14 +3,12 @@ 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 ImageZillaHost extends Host {
@@ -21,8 +19,9 @@ public class ImageZillaHost extends Host {
private static final String lookup = "imagezilla.net/show";
private static final String IMG_XPATH = "//img[@id='photo']";
@Autowired
private ConnectionManager cm;
public ImageZillaHost() {
super();
}
@Override
public String getHost() {
@@ -44,7 +43,7 @@ public class ImageZillaHost extends Host {
logger.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url));
Node titleNode = xpathService.getAsNode(doc, IMG_XPATH).getAttributes().getNamedItem("title");
logger.debug(String.format("Resolving name for %s", url));
if(titleNode != null) {
if (titleNode != null) {
title = titleNode.getTextContent().trim();
} else {
title = null;
@@ -53,7 +52,7 @@ public class ImageZillaHost extends Host {
throw new HostException(e);
}
if(title == null || title.isEmpty()) {
if (title == null || title.isEmpty()) {
title = getDefaultImageName(url);
}
@@ -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;
@Service
public class ImgSpiceHost extends Host {
private static final Logger logger = LoggerFactory.getLogger(ImgSpiceHost.class);
private static final String host = "imgspice.com";
private static final String IMG_XPATH = "//img[@id='imgpreview']";
public ImgSpiceHost() {
super();
}
@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;
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);
}
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();
imageFileData.setImageUrl(imgUrl);
imageFileData.setImageName(imgTitle.isEmpty() ? imgUrl.substring(imgUrl.lastIndexOf('/') + 1) : imgTitle);
} catch (Exception e) {
throw new HostException("Unexpected error occurred", e);
}
}
}
@@ -23,6 +23,10 @@ public class ImgboxHost extends Host {
@Autowired
private ConnectionManager cm;
public ImgboxHost() {
super();
}
@Override
public String getHost() {
return host;
@@ -103,7 +103,7 @@ public class ImxHost extends Host {
imageFileData.setImageUrl(imgUrl);
imageFileData.setImageName(imgTitle.isEmpty() ? imgUrl.substring(imgUrl.lastIndexOf('/') + 1) : imgTitle);
} catch(Exception e) {
} catch (Exception e) {
throw new HostException("Unexpected error occurred", 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.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;
@Service
public class PimpandhostHost extends Host {
private static final Logger logger = LoggerFactory.getLogger(PimpandhostHost.class);
private static final String host = "pimpandhost.com";
private static final String IMG_XPATH = "//img[contains(@class, 'original')]";
public PimpandhostHost() {
super();
}
@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;
try {
url = appendUri(_url.replace("http://", "https://"), "size=original");
} catch (Exception e) {
throw new HostException(e);
}
Response resp = getResponse(url, context);
Document doc = resp.getDocument();
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);
}
try {
logger.debug(String.format("Resolving name and image url for %s", url));
String imgTitle = imgNode.getAttributes().getNamedItem("alt").getTextContent().trim();
String imgUrl = "https:" + imgNode.getAttributes().getNamedItem("src").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);
}
}
}
@@ -0,0 +1,58 @@
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;
@Service
public class PixRouteHost extends Host {
private static final Logger logger = LoggerFactory.getLogger(PixRouteHost.class);
private static final String host = "pixroute.com";
private static final String IMG_XPATH = "//img[@id='imgpreview']";
@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 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));
imageFileData.setImageUrl(imgNode.getAttributes().getNamedItem("src").getTextContent().trim());
imageFileData.setImageName(imgNode.getAttributes().getNamedItem("alt").getTextContent().trim());
} catch (Exception e) {
throw new HostException("Unexpected error occurred", e);
}
}
}
@@ -3,14 +3,12 @@ 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 PixhostHost extends Host {
@@ -21,8 +19,9 @@ public class PixhostHost extends Host {
private static final String lookup = "pixhost.to/show";
private static final String IMG_XPATH = "//img[@id='image']";
@Autowired
private ConnectionManager cm;
public PixhostHost() {
super();
}
@Override
public String getHost() {
@@ -0,0 +1,66 @@
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;
@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')]";
public PixxxelsHost() {
super();
}
@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);
}
}
}
@@ -0,0 +1,65 @@
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']";
public PostImgHost() {
super();
}
@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);
}
}
}
@@ -3,14 +3,12 @@ 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 TurboImageHost extends Host {
@@ -21,8 +19,9 @@ public class TurboImageHost extends Host {
private static final String TITLE_XPATH = "//div[contains(@class,'titleFullS')]/h1";
private static final String IMG_XPATH = "//img[@id='uImage']";
@Autowired
private ConnectionManager cm;
public TurboImageHost() {
super();
}
@Override
public String getHost() {
@@ -44,7 +43,7 @@ public class TurboImageHost extends Host {
logger.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url));
Node titleNode = xpathService.getAsNode(doc, TITLE_XPATH);
logger.debug(String.format("Resolving name for %s", url));
if(titleNode != null) {
if (titleNode != null) {
title = titleNode.getTextContent().trim();
} else {
title = null;
@@ -53,7 +52,7 @@ public class TurboImageHost extends Host {
throw new HostException(e);
}
if(title == null || title.isEmpty()) {
if (title == null || title.isEmpty()) {
title = getDefaultImageName(url);
}
@@ -4,6 +4,7 @@ import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tn.mnlr.vripper.entities.Image;
import tn.mnlr.vripper.entities.Post;
import java.util.Objects;
import java.util.concurrent.Callable;
@@ -14,12 +15,14 @@ public class DownloadJob implements Callable<Image> {
@Getter
private final Image image;
private final Post post;
@Getter
private final ImageFileData imageFileData = new ImageFileData();
DownloadJob(Image image) {
DownloadJob(Post post, Image image) {
this.image = image;
this.post = post;
}
@Override
@@ -28,7 +31,7 @@ public class DownloadJob implements Callable<Image> {
logger.debug(String.format("Starting downloading %s", image.getUrl()));
image.setStatus(Image.Status.DOWNLOADING);
image.setCurrent(0);
image.getHost().download(image, imageFileData);
image.getHost().download(post, image, imageFileData);
image.setStatus(Image.Status.COMPLETE);
return image;
}
@@ -1,156 +1,87 @@
package tn.mnlr.vripper.q;
import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tn.mnlr.vripper.entities.Image;
import tn.mnlr.vripper.entities.Post;
import tn.mnlr.vripper.host.Host;
import tn.mnlr.vripper.services.AppSettingsService;
import tn.mnlr.vripper.services.AppStateService;
import java.util.Arrays;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.stream.Collectors;
import java.util.Map;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingDeque;
@Service
public class DownloadQ {
private static final List<Post.Status> FINISHED = Arrays.asList(Post.Status.ERROR, Post.Status.COMPLETE, Post.Status.STOPPED);
private static final Logger logger = LoggerFactory.getLogger(DownloadQ.class);
@Autowired
private AppStateService appStateService;
private final AppStateService appStateService;
private final AppSettingsService appSettingsService;
private final List<Host> hosts;
private final ConcurrentHashMap<Host, BlockingDeque<DownloadJob>> downloadQ = new ConcurrentHashMap<>();
@Autowired
private ExecutionService executionService;
public DownloadQ(AppStateService appStateService, AppSettingsService appSettingsService, List<Host> hosts) {
this.appStateService = appStateService;
this.appSettingsService = appSettingsService;
this.hosts = hosts;
}
private BlockingQueue<DownloadJob> downloadQ = new LinkedBlockingQueue<>();
@PostConstruct
private void init() {
hosts.forEach(host -> downloadQ.put(host, new LinkedBlockingDeque<>()));
}
@Getter
private boolean notPauseQ = true;
public void put(Post post, Image image) throws InterruptedException {
logger.debug(String.format("Enqueuing a job for %s", image.getUrl()));
image.init();
DownloadJob downloadJob = new DownloadJob(post, image);
downloadQ.get(downloadJob.getImage().getHost()).putLast(downloadJob);
appStateService.newDownloadJob(downloadJob);
}
public void put(Image image) throws InterruptedException {
synchronized (appStateService) {
logger.debug(String.format("Enqueuing a job for %s", image.getUrl()));
image.init();
DownloadJob downloadJob = new DownloadJob(image);
downloadQ.put(downloadJob);
appStateService.newDownloadJob(downloadJob);
public void remove(final DownloadJob downloadJob) {
downloadQ.get(downloadJob.getImage().getHost()).remove(downloadJob);
}
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.getSettings().getMaxThreads(); i++) {
DownloadJob downloadJob = it.hasNext() ? it.next() : null;
if (downloadJob != null) {
downloadJobs.add(downloadJob);
}
}
}
return downloadJobs;
}
DownloadJob take() throws InterruptedException {
DownloadJob downloadJob = downloadQ.take();
logger.debug(String.format("Retrieving a job for %s", downloadJob.getImage().getUrl()));
return downloadJob;
}
public synchronized void enqueue(Post post) throws InterruptedException {
public void enqueue(Post post) throws InterruptedException {
for (Image image : post.getImages()) {
put(image);
}
}
public void restart(String postId) throws InterruptedException {
synchronized (appStateService) {
if (appStateService.getRunningPosts().get(postId) != null && appStateService.getRunningPosts().get(postId).get() > 0) {
logger.warn(String.format("Cannot restart, jobs are currently running for post id %s", postId));
return;
}
List<Image> images = appStateService.getPost(postId)
.getImages()
.stream()
.filter(e -> !e.getStatus().equals(Image.Status.COMPLETE))
.collect(Collectors.toList());
if (images.isEmpty()) {
return;
}
appStateService.getPost(postId).setStatus(Post.Status.PENDING);
logger.debug(String.format("Restarting %d jobs for post id %s", images.size(), postId));
for (Image image : images) {
put(image);
}
}
}
private void removeScheduled(Image image) {
synchronized (appStateService) {
image.setStatus(Image.Status.STOPPED);
logger.debug(String.format("Removing scheduled job for %s", image.getUrl()));
Iterator<DownloadJob> iterator = downloadQ.iterator();
boolean removed = false;
while (iterator.hasNext()) {
DownloadJob next = iterator.next();
if (next.getImage().getPostId().equals(image.getPostId())) {
iterator.remove();
appStateService.doneDownloadJob(image);
logger.debug(String.format("Scheduled job for %s is removed", image.getUrl()));
removed = true;
break;
}
}
if (!removed) {
logger.debug(String.format("Job for %s does not exist", image.getUrl()));
}
image.cleanup();
}
}
private void removeRunning(String postId) {
logger.debug(String.format("Interrupting running jobs for post id %s", postId));
executionService.stop(postId);
}
public void stop(String postId) {
try {
synchronized (appStateService) {
if (FINISHED.contains(appStateService.getPost(postId).getStatus())) {
return;
}
notPauseQ = false;
appStateService.getPost(postId).setStatus(Post.Status.STOPPED);
List<Image> images = appStateService.getPost(postId)
.getImages()
.stream()
.filter(e -> !e.getStatus().equals(Image.Status.COMPLETE))
.collect(Collectors.toList());
if (images.isEmpty()) {
return;
}
logger.debug(String.format("Stopping %d jobs for post id %s", images.size(), postId));
images.forEach(this::removeScheduled);
removeRunning(postId);
}
} finally {
notPauseQ = true;
put(post, image);
}
}
public int size() {
return downloadQ.size();
return downloadQ.values().stream().mapToInt(BlockingDeque::size).sum();
}
public void stopAll() {
synchronized (appStateService) {
appStateService.getCurrentPosts().values().stream().map(Post::getPostId).forEach(this::stop);
}
}
public void restartAll() throws InterruptedException {
synchronized (appStateService) {
for (Post post : appStateService.getCurrentPosts().values()) {
String postId = post.getPostId();
restart(postId);
}
}
public Iterable<? extends Map.Entry<Host, BlockingDeque<DownloadJob>>> entries() {
return downloadQ.entrySet();
}
}
@@ -1,23 +1,21 @@
package tn.mnlr.vripper.q;
import lombok.NonNull;
import net.jodah.failsafe.Failsafe;
import net.jodah.failsafe.FailsafeException;
import net.jodah.failsafe.RetryPolicy;
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.entities.Post;
import tn.mnlr.vripper.host.Host;
import tn.mnlr.vripper.services.*;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@@ -26,39 +24,48 @@ import java.util.stream.Collectors;
public class ExecutionService {
private static final Logger logger = LoggerFactory.getLogger(ExecutionService.class);
private static final List<Post.Status> FINISHED = Arrays.asList(Post.Status.ERROR, Post.Status.COMPLETE, Post.Status.STOPPED);
@Autowired
private DownloadQ downloadQ;
private final DownloadQ downloadQ;
private final AppSettingsService settings;
private final AppStateService appStateService;
private final AppStateExchange appStateExchange;
private final ThumbnailGenerator thumbnailGenerator;
private final AppSettingsService appSettingsService;
private final CommonExecutor commonExecutor;
@Autowired
private AppSettingsService settings;
private final int MAX_POOL_SIZE = 12;
@Autowired
private AppStateService appStateService;
private final ConcurrentHashMap<Host, AtomicInteger> threadCount = new ConcurrentHashMap<>();
private final AtomicInteger threadCount = new AtomicInteger();
private ExecutorService executor = Executors.newFixedThreadPool(10);
private boolean notPauseQ = true;
private ExecutorService executor = Executors.newFixedThreadPool(MAX_POOL_SIZE);
private Thread executionThread;
private RetryPolicy<Object> retryPolicy;
private BlockingQueue<DownloadJob> queue = new LinkedBlockingQueue<>();
private List<DownloadJob> running = Collections.synchronizedList(new ArrayList<>());
private Map<String, Future<?>> futures = new ConcurrentHashMap<>();
private Thread pollThread;
@Autowired
public ExecutionService(DownloadQ downloadQ, AppSettingsService settings, AppStateService appStateService, AppStateExchange appStateExchange, ThumbnailGenerator thumbnailGenerator, AppSettingsService appSettingsService, CommonExecutor commonExecutor) {
this.downloadQ = downloadQ;
this.settings = settings;
this.appStateService = appStateService;
this.appStateExchange = appStateExchange;
this.thumbnailGenerator = thumbnailGenerator;
this.appSettingsService = appSettingsService;
this.commonExecutor = commonExecutor;
}
@PostConstruct
private void init() {
retryPolicy = new RetryPolicy<>()
.handleIf(e -> !(e instanceof InterruptedException))
.withBackoff(10, 60, ChronoUnit.SECONDS)
.withMaxRetries(4)
.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();
}
@@ -66,15 +73,16 @@ public class ExecutionService {
private void destroy() throws InterruptedException {
logger.info("Shutting down ExecutionService");
executionThread.interrupt();
pollThread.interrupt();
executor.shutdown();
appStateService.getCurrentPosts().keySet().forEach(p -> {
appStateExchange.getPosts().keySet().forEach(p -> {
logger.debug(String.format("Stopping download jobs for %s", p));
this.stop(p);
this.stopRunning(p);
});
executor.awaitTermination(10, TimeUnit.SECONDS);
}
public void stop(String postId) {
private void stopRunning(@NonNull String postId) {
List<DownloadJob> data = running
.stream()
.filter(e -> e.getImage().getPostId().equals(postId))
@@ -84,76 +92,198 @@ public class ExecutionService {
data.forEach(e -> {
futures.get(e.getImage().getUrl()).cancel(true);
if(e.getImageFileData().getImageRequest() != null) {
if (e.getImageFileData().getImageRequest() != null) {
e.getImageFileData().getImageRequest().abort();
}
e.getImage().cleanup();
});
}
private boolean canRun() {
boolean canRun = threadCount.get() < settings.getMaxThreads();
if (canRun && downloadQ.isNotPauseQ()) {
threadCount.incrementAndGet();
public synchronized void stopAll(List<String> posIds) {
if (posIds != null) {
posIds.forEach(this::stop);
} else {
appStateExchange.getPosts().values().forEach(p -> this.stop(p.getPostId()));
}
}
public synchronized void restartAll(List<String> posIds) {
if (posIds != null) {
posIds.forEach(this::restart);
} else {
appStateExchange.getPosts().values().forEach(p -> this.restart(p.getPostId()));
}
}
private void restart(@NonNull String postId) {
if (appStateService.isRunning(postId)) {
logger.warn(String.format("Cannot restart, jobs are currently running for post id %s", postId));
return;
}
Post post = appStateExchange.getPost(postId);
List<Image> images = post
.getImages()
.stream()
.filter(e -> !e.getStatus().equals(Image.Status.COMPLETE))
.collect(Collectors.toList());
if (images.isEmpty()) {
return;
}
post.setStatus(Post.Status.PENDING);
logger.debug(String.format("Restarting %d jobs for post id %s", images.size(), postId));
for (Image image : images) {
try {
downloadQ.put(post, image);
} catch (InterruptedException e) {
logger.warn("Thread was interrupted", e);
Thread.currentThread().interrupt();
}
}
}
private void removeScheduled(Image image) {
image.setStatus(Image.Status.STOPPED);
logger.debug(String.format("Removing scheduled job for %s", image.getUrl()));
boolean removed = false;
main:
for (Map.Entry<Host, BlockingDeque<DownloadJob>> entry : downloadQ.entries()) {
Iterator<DownloadJob> iterator = entry.getValue().iterator();
while (iterator.hasNext()) {
DownloadJob next = iterator.next();
if (next.getImage().getPostId().equals(image.getPostId())) {
iterator.remove();
appStateService.doneDownloadJob(image);
logger.debug(String.format("Scheduled job for %s is removed", image.getUrl()));
removed = true;
break main;
}
}
}
if (!removed) {
logger.debug(String.format("Job for %s does not exist", image.getUrl()));
}
image.cleanup();
}
private void removeRunning(String postId) {
logger.debug(String.format("Interrupting running jobs for post id %s", postId));
stopRunning(postId);
}
private void stop(String postId) {
try {
final Post post = appStateExchange.getPost(postId);
if (post == null) {
return;
}
if (FINISHED.contains(post.getStatus())) {
return;
}
notPauseQ = false;
post.setStatus(Post.Status.STOPPED);
List<Image> images = post
.getImages()
.stream()
.filter(e -> !e.getStatus().equals(Image.Status.COMPLETE))
.collect(Collectors.toList());
if (images.isEmpty()) {
return;
}
logger.debug(String.format("Stopping %d jobs for post id %s", images.size(), postId));
images.forEach(this::removeScheduled);
removeRunning(postId);
} finally {
notPauseQ = true;
}
}
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.getSettings().getMaxThreads() && (settings.getSettings().getMaxTotalThreads() == 0 ? threadCount.values().stream().mapToInt(AtomicInteger::get).sum() < MAX_POOL_SIZE : threadCount.values().stream().mapToInt(AtomicInteger::get).sum() < settings.getSettings().getMaxTotalThreads());
if (canRun && notPauseQ) {
threadCount.get(host).incrementAndGet();
return true;
}
return false;
}
public void start() {
private void poll() {
while (!Thread.interrupted()) {
if (canRun()) {
DownloadJob take;
try {
take = downloadQ.take();
if (take == null) {
continue;
try {
List<DownloadJob> peek = downloadQ.peek();
for (DownloadJob downloadJob : peek) {
if (canRun(downloadJob.getImage().getHost())) {
queue.offer(downloadJob);
downloadQ.remove(downloadJob);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
DownloadJob finalTake = take;
Runnable task = () -> {
running.add(finalTake);
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", finalTake.getImage().getUrl(), e.getAttemptCount()), e.getFailure());
finalTake.getImage().setStatus(Image.Status.ERROR);
})
.onComplete(e -> {
appStateService.doneDownloadJob(finalTake.getImage());
logger.debug(String.format("Finished downloading %s", finalTake.getImage().getUrl()));
synchronized (threadCount) {
threadCount.decrementAndGet();
running.remove(finalTake);
futures.remove(finalTake.getImage().getUrl());
threadCount.notify();
}
})
.get(finalTake::call);
};
logger.debug(String.format("Scheduling a job for %s", finalTake.getImage().getUrl()));
futures.put(finalTake.getImage().getUrl(), executor.submit(task));
} else {
synchronized (threadCount) {
try {
threadCount.wait(2_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
threadCount.wait(2_000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
private 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;
}
}
}
private void push(DownloadJob take) {
Runnable task = () -> {
running.add(take);
Failsafe.with(VripperApplication.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.getSettings().getViewPhotos()) {
commonExecutor.getGeneralExecutor().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() {
return running.size();
}
@@ -10,6 +10,7 @@ public class ImageFileData {
private String pageUrl;
private String imageName;
private String fileName;
private String imageUrl;
private HttpUriRequest imageRequest;
}
@@ -1,105 +1,161 @@
package tn.mnlr.vripper.services;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.commons.codec.digest.DigestUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tn.mnlr.vripper.SpringContext;
import tn.mnlr.vripper.exception.ValidationException;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.prefs.BackingStoreException;
import java.util.prefs.Preferences;
import static java.nio.file.StandardOpenOption.*;
@Service
@Getter
@Setter
public class AppSettingsService {
private static final Logger logger = LoggerFactory.getLogger(AppSettingsService.class);
private final String MAX_TOTAL_THREADS = "MAX_TOTAL_THREADS";
private final String baseDir;
private Preferences prefs = Preferences.userNodeForPackage(AppSettingsService.class);
private final Path configPath;
private final Logger logger = LoggerFactory.getLogger(AppSettingsService.class);
private final ObjectMapper om = new ObjectMapper();
private final String DOWNLOAD_PATH = "DOWNLOAD_PATH";
private final String MAX_THREADS = "MAX_THREADS";
private final String AUTO_START = "AUTO_START";
private final String V_LOGIN = "VLOGIN";
private final String V_USERNAME = "VUSERNAME";
private final String V_PASSWORD = "VPASSWORD";
private final String V_THANKS = "VTHANKS";
private final String DESKTOP_CLIPBOARD = "DESKTOP_CLIPBOARD";
private final String FORCE_ORDER = "FORCE_ORDER";
private final String SUBFOLDER = "SUBFOLDER";
private final String THREADSUBFOLDER = "THREADSUBFOLDER";
private final String CLEAR = "CLEAR";
private final String DARK_THEME = "DARK_THEME";
private Settings settings = new Settings();
private String downloadPath;
private int maxThreads;
private boolean autoStart;
private boolean vLogin;
private String vUsername;
private String vPassword;
private boolean vThanks;
private boolean desktopClipboard;
private boolean subLocation;
private boolean threadSubLocation;
private boolean forceOrder;
private boolean clearCompleted;
private boolean darkTheme;
public AppSettingsService(@Value("${base.dir}") String baseDir) {
this.baseDir = baseDir;
this.configPath = Paths.get(baseDir, ".vripper", "config.json");
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
public void setVPassword(String vPassword) {
if(vPassword.isEmpty()) {
this.vPassword = "";
@PostConstruct
private void init() {
restore();
}
public void newSettings(Settings settings) {
if (settings.getVLogin()) {
if (!this.settings.getVPassword().equals(settings.getVPassword())) {
settings.setVPassword(settings.getVPassword());
}
} else {
this.vPassword = DigestUtils.md5Hex(vPassword);
settings.setVUsername("");
settings.setVPassword("");
settings.setVThanks(false);
}
this.settings = settings;
save();
}
public void restore() {
try {
settings = om.readValue(configPath.toFile(), Settings.class);
try {
check(this.settings);
} catch (ValidationException e) {
logger.error(String.format("Your settings are invalid, either remove %s, or fix it", configPath.toString()), e);
SpringContext.close();
}
} catch (IOException e) {
logger.error("Failed restore user settings", e);
settings = new Settings();
}
downloadPath = prefs.get(DOWNLOAD_PATH, System.getProperty("user.dir"));
maxThreads = prefs.getInt(MAX_THREADS, 4);
autoStart = prefs.getBoolean(AUTO_START, true);
vLogin = prefs.getBoolean(V_LOGIN, false);
vUsername = prefs.get(V_USERNAME, "");
vPassword = prefs.get(V_PASSWORD, "");
vThanks = prefs.getBoolean(V_THANKS, false);
desktopClipboard = prefs.getBoolean(DESKTOP_CLIPBOARD, false);
forceOrder = prefs.getBoolean(FORCE_ORDER, false);
subLocation = prefs.getBoolean(SUBFOLDER, false);
threadSubLocation = prefs.getBoolean(THREADSUBFOLDER, false);
clearCompleted = prefs.getBoolean(CLEAR, false);
darkTheme = prefs.getBoolean(DARK_THEME, false);
if (settings.getDownloadPath() == null) {
settings.setDownloadPath(baseDir);
}
if (settings.getMaxThreads() == null) {
settings.setMaxThreads(4);
}
if (settings.getMaxTotalThreads() == null) {
settings.setMaxTotalThreads(0);
}
if (settings.getAutoStart() == null) {
settings.setAutoStart(true);
}
if (settings.getVLogin() == null) {
settings.setVLogin(false);
}
if (settings.getVUsername() == null) {
settings.setVUsername("");
}
if (settings.getVPassword() == null) {
settings.setVPassword("");
}
if (settings.getVThanks() == null) {
settings.setVThanks(false);
}
if (settings.getDesktopClipboard() == null) {
settings.setDesktopClipboard(false);
}
if (settings.getForceOrder() == null) {
settings.setForceOrder(false);
}
if (settings.getSubLocation() == null) {
settings.setSubLocation(false);
}
if (settings.getThreadSubLocation() == null) {
settings.setThreadSubLocation(false);
}
if (settings.getClearCompleted() == null) {
settings.setClearCompleted(false);
}
if (settings.getDarkTheme() == null) {
settings.setDarkTheme(false);
}
if (settings.getViewPhotos() == null) {
settings.setViewPhotos(false);
}
if (settings.getNotification() == null) {
settings.setNotification(false);
}
save();
}
@PreDestroy
public void save() {
prefs.put(DOWNLOAD_PATH, downloadPath);
prefs.putInt(MAX_THREADS, maxThreads);
prefs.putBoolean(AUTO_START, autoStart);
prefs.putBoolean(V_LOGIN, vLogin);
prefs.put(V_USERNAME, vUsername);
prefs.put(V_PASSWORD, vPassword);
prefs.putBoolean(V_THANKS, vThanks);
prefs.putBoolean(DESKTOP_CLIPBOARD, desktopClipboard);
prefs.putBoolean(FORCE_ORDER, forceOrder);
prefs.putBoolean(SUBFOLDER, subLocation);
prefs.putBoolean(THREADSUBFOLDER, threadSubLocation);
prefs.putBoolean(CLEAR, clearCompleted);
prefs.putBoolean(DARK_THEME, darkTheme);
try {
prefs.sync();
} catch (BackingStoreException e) {
// force disable gallery
settings.setViewPhotos(false);
Files.write(configPath, om.writeValueAsBytes(settings), CREATE, WRITE, TRUNCATE_EXISTING, SYNC);
} catch (IOException e) {
logger.error("Failed to store user settings", e);
}
}
@@ -118,17 +174,21 @@ public class AppSettingsService {
throw new ValidationException(String.format("%s is not a directory", settings.getDownloadPath()));
}
if (settings.getMaxTotalThreads() < 0 || settings.getMaxTotalThreads() > 12) {
throw new ValidationException(String.format("Invalid max global concurrent download settings, values must be in [%d,%d]", 0, 12));
}
if (settings.getMaxThreads() < 1 || settings.getMaxThreads() > 4) {
throw new ValidationException(String.format("Invalid max concurrent download settings, values must be in [%d,%d]", 1, 4));
}
}
public Theme getTheme() {
return new Theme(this.darkTheme);
return new Theme(this.settings.getDarkTheme());
}
public void setTheme(Theme theme) {
this.darkTheme = theme.darkTheme;
this.settings.setDarkTheme(theme.darkTheme);
save();
}
@@ -145,46 +205,50 @@ public class AppSettingsService {
}
@Getter
@Setter
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Settings {
@JsonProperty("downloadPath")
private String downloadPath;
@JsonProperty("maxThreads")
private int maxThreads;
private Integer maxThreads;
@JsonProperty("maxTotalThreads")
private Integer maxTotalThreads;
@JsonProperty("autoStart")
private boolean autoStart;
private Boolean autoStart;
@JsonProperty("vLogin")
private boolean vLogin;
private Boolean vLogin;
@JsonProperty("vUsername")
private String vUsername;
@JsonProperty("vPassword")
private String vPassword;
@JsonProperty("vThanks")
private boolean vThanks;
private Boolean vThanks;
@JsonProperty("desktopClipboard")
private boolean desktopClipboard;
private Boolean desktopClipboard;
@JsonProperty("forceOrder")
private boolean forceOrder;
private Boolean forceOrder;
@JsonProperty("subLocation")
private boolean subLocation;
private Boolean subLocation;
@JsonProperty("threadSubLocation")
private boolean threadSubLocation;
private Boolean threadSubLocation;
@JsonProperty("clearCompleted")
private boolean clearCompleted;
private Boolean clearCompleted;
@JsonProperty("viewPhotos")
private Boolean viewPhotos;
@JsonProperty("notification")
private Boolean notification;
@JsonProperty("darkTheme")
private Boolean darkTheme;
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) {
this.downloadPath = downloadPath;
this.maxThreads = maxThreads;
this.autoStart = autoStart;
this.vLogin = vLogin;
this.vUsername = vUsername;
this.vPassword = vPassword;
this.vThanks = vThanks;
this.desktopClipboard = desktopClipboard;
this.forceOrder = forceOrder;
this.subLocation = subLocation;
this.threadSubLocation = threadSubLocation;
this.clearCompleted = clearCompleted;
public void setVPassword(String vPassword) {
if (vPassword == null || vPassword.isEmpty()) {
this.vPassword = "";
} else {
this.vPassword = DigestUtils.md5Hex(vPassword);
}
}
}
}
@@ -0,0 +1,73 @@
package tn.mnlr.vripper.services;
import io.reactivex.processors.PublishProcessor;
import org.springframework.stereotype.Service;
import tn.mnlr.vripper.entities.Image;
import tn.mnlr.vripper.entities.Post;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Service
public class AppStateExchange {
private Map<String, Image> currentImages = new ConcurrentHashMap<>();
private Map<String, Post> currentPosts = new ConcurrentHashMap<>();
private Map<String, AtomicInteger> runningPosts = new ConcurrentHashMap<>();
private Map<String, QueuedVGLink> grabQueue = new ConcurrentHashMap<>();
private PublishProcessor<QueuedVGLink> liveGrabQueue = PublishProcessor.create();
private PublishProcessor<Image> liveImageUpdates = PublishProcessor.create();
private PublishProcessor<Post> livePostsState = PublishProcessor.create();
public Map<String, AtomicInteger> running() {
return runningPosts;
}
public Map<String, Post> getPosts() {
return this.currentPosts;
}
public Post getPost(String postId) {
return currentPosts.get(postId);
}
public Map<String, Image> getImages() {
return currentImages;
}
public PublishProcessor<Image> liveImage() {
return liveImageUpdates;
}
public PublishProcessor<Post> livePost() {
return livePostsState;
}
public Map<String, QueuedVGLink> getQueue() {
return grabQueue;
}
public PublishProcessor<QueuedVGLink> liveQueue() {
return liveGrabQueue;
}
public synchronized void restore(Map<String, Post> read) {
currentPosts.clear();
currentPosts.putAll(read);
currentPosts.values().forEach(p -> {
if (Arrays.asList(Post.Status.DOWNLOADING, Post.Status.PARTIAL, Post.Status.PENDING).contains(p.getStatus())) {
p.setStatus(Post.Status.STOPPED);
}
});
currentImages.clear();
read.values().stream().flatMap(e -> e.getImages().stream()).forEach(e -> currentImages.put(e.getUrl(), e));
}
}
@@ -1,10 +1,8 @@
package tn.mnlr.vripper.services;
import io.reactivex.processors.PublishProcessor;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tn.mnlr.vripper.entities.Image;
@@ -13,76 +11,105 @@ import tn.mnlr.vripper.q.DownloadJob;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@Service
@Getter
public class AppStateService {
private static final Logger logger = LoggerFactory.getLogger(AppStateService.class);
private Map<String, Image> currentImages = new ConcurrentHashMap<>();
private Map<String, Post> currentPosts = new ConcurrentHashMap<>();
private Map<String, AtomicInteger> runningPosts = new ConcurrentHashMap<>();
private PublishProcessor<Image> liveImageUpdates = PublishProcessor.create();
private PublishProcessor<Post> livePostsState = PublishProcessor.create();
private final PersistenceService persistenceService;
private final AppSettingsService appSettingsService;
private final AppStateExchange appStateExchange;
@Autowired
private PersistenceService persistenceService;
public AppStateService(PersistenceService persistenceService, AppSettingsService appSettingsService, AppStateExchange appStateExchange) {
this.appStateExchange = appStateExchange;
this.persistenceService = persistenceService;
this.appSettingsService = appSettingsService;
}
@Autowired
private AppSettingsService appSettingsService;
public synchronized void newQueueLink(QueuedVGLink queuedVGLink) {
appStateExchange.getQueue().put(queuedVGLink.getThreadId(), queuedVGLink);
appStateExchange.liveQueue().onNext(queuedVGLink);
}
public void onImageUpdate(Image imageState) {
public synchronized void queueLinkUpdated(QueuedVGLink queuedVGLink) {
appStateExchange.liveQueue().onNext(queuedVGLink);
}
persistenceService.getProcessor().onNext(currentPosts);
public synchronized void removeQueueLink(String threadId) {
Optional.ofNullable(appStateExchange.getQueue().remove(threadId)).ifPresent(QueuedVGLink::remove);
}
liveImageUpdates.onNext(imageState);
if (imageState.isCompleted()) {
public synchronized boolean newPost(@NonNull Post post) {
if (appStateExchange.getPosts().containsKey(post.getPostId())) {
return false;
} else {
appStateExchange.getPosts().put(post.getPostId(), post);
appStateExchange.livePost().onNext(post);
persistenceService.getProcessor().onNext(appStateExchange.getPosts());
return true;
}
}
Post postState = currentPosts.get(imageState.getPostId());
public synchronized void postUpdated(@NonNull Post post) {
appStateExchange.livePost().onNext(post);
persistenceService.getProcessor().onNext(appStateExchange.getPosts());
}
public boolean newImage(Image image) {
if (appStateExchange.getImages().containsKey(image.getUrl())) {
return false;
} else {
appStateExchange.getImages().put(image.getUrl(), image);
appStateExchange.liveImage().onNext(image);
persistenceService.getProcessor().onNext(appStateExchange.getPosts());
return true;
}
}
public synchronized void imageUpdated(Image image) {
persistenceService.getProcessor().onNext(appStateExchange.getPosts());
appStateExchange.liveImage().onNext(image);
if (image.isCompleted()) {
Post postState = appStateExchange.getPosts().get(image.getPostId());
postState.increase();
}
}
public Post getPost(String postId) {
return currentPosts.get(postId);
}
public synchronized void newDownloadJob(DownloadJob downloadJob) {
public synchronized void newDownloadJob(@NonNull DownloadJob downloadJob) {
String postId = downloadJob.getImage().getPostId();
checkKeyRunningPosts(postId);
runningPosts.get(postId).incrementAndGet();
appStateExchange.running().get(postId).incrementAndGet();
}
public synchronized void postDownloadingUpdate(String postId) {
Post post = currentPosts.get(postId);
if (!post.getStatus().equals(Post.Status.DOWNLOADING)) {
public synchronized void postDownloadingUpdate(@NonNull String postId) {
Post post = appStateExchange.getPosts().get(postId);
if (!post.getStatus().equals(Post.Status.DOWNLOADING) && !post.getStatus().equals(Post.Status.PARTIAL)) {
post.setStatus(Post.Status.DOWNLOADING);
livePostsState.onNext(post);
appStateExchange.livePost().onNext(post);
}
}
public void doneDownloadJob(Image image) {
public synchronized void doneDownloadJob(@NonNull Image image) {
String postId = image.getPostId();
int i = runningPosts.get(postId).decrementAndGet();
Post post = currentPosts.get(postId);
if(image.getStatus().equals(Image.Status.ERROR)) {
int i = appStateExchange.running().get(postId).decrementAndGet();
Post post = appStateExchange.getPosts().get(postId);
if (image.getStatus().equals(Image.Status.ERROR)) {
post.setStatus(Post.Status.PARTIAL);
}
if (i == 0) {
appStateExchange.running().remove(postId);
if (post.getImages().stream().map(Image::getStatus).anyMatch(e -> e.equals(Image.Status.ERROR))) {
post.setStatus(Post.Status.ERROR);
} else {
if (!Post.Status.STOPPED.equals(post.getStatus())) {
post.setStatus(Post.Status.COMPLETE);
if (appSettingsService.isClearCompleted()) {
if (appSettingsService.getSettings().getClearCompleted()) {
remove(image.getPostId());
}
}
@@ -90,41 +117,52 @@ public class AppStateService {
}
}
private synchronized void checkKeyRunningPosts(String key) {
if (!runningPosts.containsKey(key)) {
runningPosts.put(key, new AtomicInteger(0));
}
}
public synchronized void remove(String postId) {
currentPosts.get(postId).setRemoved(true);
runningPosts.remove(postId);
currentPosts.remove(postId);
currentImages.entrySet().removeIf(entry -> entry.getValue().getPostId().equals(postId));
persistenceService.getProcessor().onNext(currentPosts);
public boolean isRunning(String postId) {
AtomicInteger runningCount = appStateExchange.running().get(postId);
return runningCount != null && runningCount.get() > 0;
}
public synchronized List<String> clearAll() {
List<String> toRemove = this.currentPosts
return this.appStateExchange.getPosts()
.values()
.stream()
.filter(e -> e.getStatus().equals(Post.Status.COMPLETE) && e.getDone().get() >= e.getTotal())
.map(Post::getPostId)
.peek(this::remove)
.collect(Collectors.toList());
toRemove.forEach(this::remove);
return toRemove;
}
public synchronized List<String> removeAll() {
List<String> toRemove = this.currentPosts
.values()
.stream()
.map(Post::getPostId)
.collect(Collectors.toList());
toRemove.forEach(this::remove);
return toRemove;
public synchronized List<String> removeAll(List<String> postIds) {
if (postIds != null && !postIds.isEmpty()) {
postIds.forEach(this::remove);
return postIds;
} else {
return this.appStateExchange.getPosts()
.values()
.stream()
.map(Post::getPostId)
.peek(this::remove)
.collect(Collectors.toList());
}
}
private void checkKeyRunningPosts(@NonNull String key) {
if (!appStateExchange.running().containsKey(key)) {
appStateExchange.running().put(key, new AtomicInteger(0));
}
}
private void remove(@NonNull String postId) {
final Post post = appStateExchange.getPosts().get(postId);
if (post == null) {
return;
}
post.setRemoved(true);
appStateExchange.running().remove(postId);
appStateExchange.getPosts().remove(postId);
appStateExchange.getImages().entrySet().removeIf(entry -> entry.getValue().getPostId().equals(postId));
persistenceService.getProcessor().onNext(appStateExchange.getPosts());
}
@Getter
public static class CachedThread {
@@ -0,0 +1,29 @@
package tn.mnlr.vripper.services;
import io.reactivex.Scheduler;
import io.reactivex.schedulers.Schedulers;
import lombok.Getter;
import org.springframework.stereotype.Service;
import javax.annotation.PreDestroy;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@Service
public class CommonExecutor {
@Getter
private final ExecutorService generalExecutor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
private final ExecutorService schedulerExecutor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
@Getter
private final Scheduler scheduler = Schedulers.from(schedulerExecutor);
@PreDestroy
private void destroy() throws InterruptedException {
generalExecutor.shutdown();
generalExecutor.awaitTermination(10, TimeUnit.SECONDS);
schedulerExecutor.shutdown();
schedulerExecutor.awaitTermination(10, TimeUnit.SECONDS);
}
}
@@ -26,9 +26,9 @@ public class ConnectionManager {
private PoolingHttpClientConnectionManager pcm;
private RequestConfig rc = RequestConfig.custom()
.setConnectionRequestTimeout(10_000)
.setConnectTimeout(10_000)
.setSocketTimeout(10_000)
.setConnectionRequestTimeout(5_000)
.setConnectTimeout(5_000)
.setSocketTimeout(5_000)
.setCookieSpec(CookieSpecs.STANDARD)
.build();
@@ -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) {
@@ -14,14 +14,9 @@ import tn.mnlr.vripper.q.ExecutionService;
@EnableScheduling
public class GlobalStateService {
@Autowired
private DownloadQ downloadQ;
@Autowired
private ExecutionService executionService;
@Autowired
private AppStateService appStateService;
private final DownloadQ downloadQ;
private final ExecutionService executionService;
private final AppStateExchange appStateExchange;
@Getter
private GlobalState currentState;
@@ -29,7 +24,11 @@ public class GlobalStateService {
@Getter
private PublishProcessor<GlobalState> liveGlobalState = PublishProcessor.create();
public GlobalStateService() {
@Autowired
public GlobalStateService(DownloadQ downloadQ, ExecutionService executionService, AppStateExchange appStateExchange) {
this.downloadQ = downloadQ;
this.executionService = executionService;
this.appStateExchange = appStateExchange;
}
@Scheduled(fixedDelay = 3000)
@@ -37,12 +36,12 @@ public class GlobalStateService {
GlobalState newGlobalState = new GlobalState(
executionService.runningCount(),
downloadQ.size(),
appStateService.getCurrentImages()
appStateExchange.getImages()
.values()
.stream()
.filter(e -> e.getTotal() == 0 || e.getTotal() != e.getCurrent().get())
.count(),
appStateService.getCurrentImages()
appStateExchange.getImages()
.values()
.stream()
.filter(e -> e.getStatus().equals(Image.Status.ERROR))
@@ -1,5 +1,6 @@
package tn.mnlr.vripper.services;
import lombok.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -7,28 +8,100 @@ import org.springframework.stereotype.Service;
import tn.mnlr.vripper.entities.Post;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
public class PathService {
private static final Logger logger = LoggerFactory.getLogger(PathService.class);
@Autowired
private AppStateService appStateService;
private final AppSettingsService appSettingsService;
private final CommonExecutor commonExecutor;
private final int MAX_DELETE_ATTEMPTS = 180;
@Autowired
private AppSettingsService appSettingsService;
public final File getDownloadDestinationFolder(String postId) {
Post post = appStateService.getCurrentPosts().get(postId);
String postTitle = post.getTitle();
String threadTitle = post.getThreadTitle();
File sourceFolder = appSettingsService.isSubLocation() ? new File(appSettingsService.getDownloadPath(), sanitize(post.getForum())) : new File(appSettingsService.getDownloadPath());
sourceFolder = appSettingsService.isThreadSubLocation() ? new File(sourceFolder, threadTitle) : sourceFolder;
return new File(sourceFolder, sanitize(postTitle + "_" + postId));
public PathService(AppSettingsService appSettingsService, CommonExecutor commonExecutor) {
this.appSettingsService = appSettingsService;
this.commonExecutor = commonExecutor;
}
public final String sanitize(final String folderName) {
public final File getDownloadDestinationFolder(Post post) {
return _getDownloadDestinationFolder(post.getForum(), post.getThreadTitle(), post.getPostFolderName());
}
private File _getDownloadDestinationFolder(@NonNull String forum, @NonNull String threadTitle, @NonNull String title) {
File sourceFolder = appSettingsService.getSettings().getSubLocation() ? new File(appSettingsService.getSettings().getDownloadPath(), sanitize(forum)) : new File(appSettingsService.getSettings().getDownloadPath());
sourceFolder = appSettingsService.getSettings().getThreadSubLocation() ? new File(sourceFolder, threadTitle) : sourceFolder;
return new File(sourceFolder, sanitize(title));
}
public synchronized final void createDefaultPostFolder(Post post) {
File sourceFolder = _getDownloadDestinationFolder(post.getForum(), post.getThreadTitle(), post.getTitle());
File destFolder = makeDirs(sourceFolder);
post.setPostFolderName(destFolder.getName());
}
public synchronized final void rename(Post post, String altName) {
post.setTitle(altName);
if (post.getPostFolderName() == null) {
return;
}
File newDestFolder = makeDirs(_getDownloadDestinationFolder(post.getForum(), post.getThreadTitle(), altName));
File currentDesFolder = getDownloadDestinationFolder(post);
post.setPostFolderName(newDestFolder.getName());
List<File> files = Optional.ofNullable(currentDesFolder.listFiles()).stream().flatMap(Arrays::stream).filter(e -> !e.getName().endsWith(".tmp")).collect(Collectors.toList());
for (File f : files) {
try {
Files.move(f.toPath(), Paths.get(newDestFolder.toString(), f.toPath().getFileName().toString()), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
logger.error(String.format("Failed to move files from %s to %s", currentDesFolder.toString(), newDestFolder.toString()), e);
return;
}
}
commonExecutor.getGeneralExecutor().submit(() -> {
int attemptCount = 0;
while (Objects.requireNonNull(currentDesFolder.listFiles()).length != 0 && attemptCount < MAX_DELETE_ATTEMPTS) {
try {
attemptCount++;
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
if (!currentDesFolder.delete()) {
logger.warn(String.format("Failed to remove %s", currentDesFolder.toString()));
}
});
}
private synchronized File makeDirs(@NonNull final File sourceFolder) {
int counter = 1;
File folder = sourceFolder;
while (folder.exists()) {
folder = new File(sourceFolder.toString() + '.' + counter++);
}
if (!folder.mkdirs()) {
throw new RuntimeException(String.format("Failed to create the folder %s", sourceFolder.toString()));
}
return folder;
}
private String sanitize(final String folderName) {
String sanitizedFolderName = folderName.replaceAll("\\.|\\\\|/|\\||:|\\?|\\*|\"|<|>|\\p{Cntrl}", "_");
logger.debug(String.format("%s sanitized to %s", folderName, sanitizedFolderName));
return sanitizedFolderName;
@@ -41,13 +114,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);
}
}
@@ -7,9 +7,10 @@ import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tn.mnlr.vripper.EventListenerBean;
import tn.mnlr.vripper.SpringContext;
import tn.mnlr.vripper.VripperApplication;
import tn.mnlr.vripper.entities.Image;
import tn.mnlr.vripper.entities.Post;
import tn.mnlr.vripper.entities.mixin.persistance.ImagePersistanceMixin;
@@ -24,7 +25,6 @@ import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@@ -35,11 +35,13 @@ public class PersistenceService {
private static final Logger logger = LoggerFactory.getLogger(PersistenceService.class);
@Autowired
private AppStateService stateService;
private final AppStateExchange appStateExchange;
@Autowired
private VripperApplication.AppCommandRunner appCommandRunner;
@Value("${base.dir}")
private String baseDir;
@Getter
private String dataPath;
private ObjectMapper om;
@@ -48,22 +50,24 @@ public class PersistenceService {
@Getter
private PublishProcessor<Map<String, Post>> processor = PublishProcessor.create();
private PersistenceService() {
@Autowired
private PersistenceService(AppStateExchange appStateExchange) {
this.appStateExchange = appStateExchange;
om = new ObjectMapper();
om.addMixIn(Image.class, ImagePersistanceMixin.class);
om.addMixIn(Post.class, PostPersistanceMixin.class);
subscription = processor
.onBackpressureLatest()
.buffer(10, TimeUnit.SECONDS)
.onBackpressureBuffer()
.buffer(1, TimeUnit.SECONDS)
.filter(e -> !e.isEmpty())
.map(e -> e.get(0))
.doOnNext(this::persist)
.doOnNext(e -> this.persist(e.get(0)))
.subscribe();
}
@PostConstruct
public void init() {
File dataFile = new File(appCommandRunner.getDataPath());
dataPath = baseDir + File.separator + ".vripper" + File.separator + "data.json";
File dataFile = new File(dataPath);
if (!dataFile.exists()) {
try {
if (dataFile.getParentFile().mkdirs()) {
@@ -94,12 +98,14 @@ public class PersistenceService {
this.subscription.dispose();
logger.info(String.format("Destroying %s", PersistenceService.class.getSimpleName()));
logger.info("Persisting data before destroying");
this.persist(stateService.getCurrentPosts());
if (EventListenerBean.isInit()) {
this.persist(appStateExchange.getPosts());
}
}
private void persist(Map<String, Post> currentPosts) {
try (PrintWriter out = new PrintWriter(appCommandRunner.getDataPath(), StandardCharsets.UTF_8)) {
try (PrintWriter out = new PrintWriter(dataPath, StandardCharsets.UTF_8)) {
out.print(om.writeValueAsString(currentPosts));
} catch (IOException e) {
logger.error("Failed to persist app state", e);
@@ -113,11 +119,11 @@ public class PersistenceService {
} catch (IOException e) {
logger.error("Failed to read app state", e);
long timestamp = new Date().getTime();
logger.warn(String.format("trying to rename old data file from %s to %s", appCommandRunner.getDataPath(), appCommandRunner.getDataPath() + "." + timestamp + ".old"));
logger.warn(String.format("trying to rename old data file from %s to %s", dataPath, dataPath + "." + timestamp + ".old"));
try {
Files.move(new File(appCommandRunner.getDataPath()).toPath(), new File(appCommandRunner.getDataPath() + "." + timestamp + ".old").toPath());
Files.move(new File(dataPath).toPath(), new File(dataPath + "." + timestamp + ".old").toPath());
} catch (IOException ex) {
logger.error(String.format("Failed to rename %s to %s", appCommandRunner.getDataPath(), appCommandRunner.getDataPath() + ".old"));
logger.error(String.format("Failed to rename %s to %s", dataPath, dataPath + ".old"));
SpringContext.close();
}
}
@@ -129,28 +135,12 @@ public class PersistenceService {
String jsonContent = null;
try {
jsonContent = String.join("", Files.readAllLines(Paths.get(appCommandRunner.getDataPath()), StandardCharsets.UTF_8));
jsonContent = String.join("", Files.readAllLines(Paths.get(dataPath), StandardCharsets.UTF_8));
} catch (Exception e) {
logger.error("data file cannot be read, previous state cannot be restored", e);
SpringContext.close();
}
Map<String, Post> read = read(jsonContent);
stateService.getCurrentPosts().clear();
stateService.getCurrentPosts().putAll(read);
stateService.getCurrentPosts().values().forEach(p -> {
if (Arrays.asList(Post.Status.DOWNLOADING, Post.Status.PARTIAL, Post.Status.PENDING).contains(p.getStatus())) {
p.setStatus(Post.Status.STOPPED);
}
});
stateService.getCurrentImages().clear();
read.values().stream().flatMap(e -> e.getImages().stream()).forEach(e -> {
e.setAppStateService(stateService);
stateService.getCurrentImages().put(e.getUrl(), e);
});
read.values().forEach(e -> e.setAppStateService(stateService));
appStateExchange.restore(read(jsonContent));
}
}
@@ -6,12 +6,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tn.mnlr.vripper.entities.Post;
import tn.mnlr.vripper.exception.PostParseException;
import tn.mnlr.vripper.host.Host;
import tn.mnlr.vripper.q.DownloadQ;
import javax.annotation.PostConstruct;
import java.util.List;
@Service
public class PostParser {
@@ -19,46 +15,28 @@ public class PostParser {
static final String VR_API = "https://vipergirls.to/vr.php";
@Autowired
private ConnectionManager cm;
private final AppStateExchange appStateExchange;
private final AppSettingsService appSettingsService;
private final DownloadQ downloadQ;
@Autowired
private AppStateService appStateService;
@Autowired
private VipergirlsAuthService authService;
@Autowired
private AppSettingsService appSettingsService;
@Autowired
private DownloadQ downloadQ;
@Autowired
private VipergirlsAuthService vipergirlsAuthService;
@Autowired
private List<Host> supportedHosts;
@PostConstruct
private void init() {
public PostParser(AppStateExchange appStateExchange, AppSettingsService appSettingsService, DownloadQ downloadQ) {
this.appStateExchange = appStateExchange;
this.appSettingsService = appSettingsService;
this.downloadQ = downloadQ;
}
public void addPost(String postId, String threadId) throws PostParseException {
public synchronized void addPost(String postId, String threadId) throws PostParseException {
if (appStateService.getCurrentPosts().containsKey(postId)) {
if (appStateExchange.getPosts().containsKey(postId)) {
logger.warn(String.format("skipping %s, already loaded", postId));
return;
}
VRPostParser vrPostParser = new VRPostParser(threadId, postId, cm, vipergirlsAuthService, supportedHosts);
VRPostParser vrPostParser = new VRPostParser(threadId, postId);
Post post = vrPostParser.parse();
post.setAppStateService(appStateService);
post.getImages().forEach(e -> e.setAppStateService(appStateService));
authService.leaveThanks(post.getUrl(), post.getPostId());
if (appSettingsService.isAutoStart()) {
if (appSettingsService.getSettings().getAutoStart()) {
logger.debug("Auto start downloads option is enabled");
logger.debug(String.format("Starting to enqueue %d jobs for %s", post.getImages().size(), post.getUrl()));
post.setStatus(Post.Status.PENDING);
@@ -76,7 +54,7 @@ public class PostParser {
}
}
public VRThreadParser createVRThreadParser(String threadId) {
return new VRThreadParser(threadId, cm, vipergirlsAuthService);
public VRThreadParser createVRThreadParser(QueuedVGLink queuedVGLink) {
return new VRThreadParser(queuedVGLink);
}
}
@@ -0,0 +1,64 @@
package tn.mnlr.vripper.services;
import lombok.Getter;
import lombok.ToString;
import tn.mnlr.vripper.SpringContext;
import java.util.Objects;
@ToString
@Getter
public class QueuedVGLink {
private final String type = "grabQueue";
private final AppStateService appStateService;
private final String link;
private final String threadId;
private final String postId;
private int count = 0;
private boolean loading = true;
private boolean removed = false;
public QueuedVGLink(String link, String threadId, String postId) {
this.appStateService = SpringContext.getBean(AppStateService.class);
this.link = link;
this.threadId = threadId;
this.postId = postId;
}
public void done() {
this.loading = false;
this.appStateService.queueLinkUpdated(this);
}
public void increment() {
this.count++;
this.appStateService.queueLinkUpdated(this);
}
public void setCount(int count) {
this.count = count;
}
public void remove() {
this.removed = true;
appStateService.queueLinkUpdated(this);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
QueuedVGLink that = (QueuedVGLink) o;
return Objects.equals(threadId, that.threadId);
}
@Override
public int hashCode() {
return Objects.hash(threadId);
}
}
@@ -0,0 +1,173 @@
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;
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 tn.mnlr.vripper.entities.Post;
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;
private final PathService pathService;
private final AppStateExchange appStateExchange;
@Getter
private File cacheFolder;
CacheLoader<CacheKey, byte[]> loader = new CacheLoader<>() {
@Override
public byte[] load(CacheKey key) throws Exception {
Post post = appStateExchange.getPost(key.getPostId());
File destinationFolder = pathService.getDownloadDestinationFolder(post);
if (!destinationFolder.exists() || !destinationFolder.isDirectory()) {
return null;
}
return Files.readAllBytes(generateThumbnail(new File(destinationFolder, key.getImgName()), key.getPostId()).toPath());
}
};
@Autowired
public ThumbnailGenerator(PathService pathService, AppStateExchange appStateExchange) {
this.pathService = pathService;
this.appStateExchange = appStateExchange;
}
@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()
.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()));
}
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,75 @@
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.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
@Service
public class VGHandler {
private static final Logger logger = LoggerFactory.getLogger(VGHandler.class);
private final AppStateService appStateService;
private final PostParser postParser;
private final CommonExecutor commonExecutor;
@Getter
private final LoadingCache<QueuedVGLink, List<VRPostState>> cache;
@Autowired
public VGHandler(AppStateService appStateService, PostParser postParser, CommonExecutor commonExecutor) {
this.appStateService = appStateService;
this.postParser = postParser;
this.commonExecutor = commonExecutor;
CacheLoader<QueuedVGLink, List<VRPostState>> loader = new CacheLoader<>() {
@Override
public List<VRPostState> load(@NonNull QueuedVGLink queuedVGLink) throws Exception {
VRThreadParser vrThreadParser = postParser.createVRThreadParser(queuedVGLink);
return vrThreadParser.parse();
}
};
cache = CacheBuilder.newBuilder()
.expireAfterWrite(30, TimeUnit.MINUTES)
.build(loader);
}
public void handle(List<QueuedVGLink> queuedVGLinks) throws Exception {
for (QueuedVGLink queuedVGLink : queuedVGLinks) {
if (queuedVGLink.getPostId() != null) {
postParser.addPost(queuedVGLink.getPostId(), queuedVGLink.getThreadId());
} else {
Callable<Void> cl = () -> {
List<VRPostState> vrPostStates = cache.get(queuedVGLink);
queuedVGLink.setCount(vrPostStates.size());
queuedVGLink.done();
logger.debug(String.format("%d found for %s", vrPostStates.size(), queuedVGLink.getLink()));
if (vrPostStates.size() == 1) {
queuedVGLink.remove();
postParser.addPost(vrPostStates.get(0).getPostId(), vrPostStates.get(0).getThreadId());
logger.debug(String.format("threadId %s, postId %s is added automatically for download", queuedVGLink.getThreadId(), queuedVGLink.getPostId()));
} else {
appStateService.newQueueLink(queuedVGLink);
}
return null;
};
commonExecutor.getGeneralExecutor().submit(cl);
}
}
}
public void remove(String threadId) {
appStateService.removeQueueLink(threadId);
}
}
@@ -1,8 +1,6 @@
package tn.mnlr.vripper.services;
import lombok.Getter;
import net.jodah.failsafe.Failsafe;
import net.jodah.failsafe.RetryPolicy;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
@@ -10,8 +8,12 @@ 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.SpringContext;
import tn.mnlr.vripper.VripperApplication;
import tn.mnlr.vripper.entities.Image;
import tn.mnlr.vripper.entities.Post;
import tn.mnlr.vripper.exception.DownloadException;
@@ -19,14 +21,8 @@ import tn.mnlr.vripper.exception.PostParseException;
import tn.mnlr.vripper.host.Host;
import javax.xml.parsers.SAXParserFactory;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.net.URISyntaxException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import static tn.mnlr.vripper.services.PostParser.VR_API;
@@ -37,25 +33,22 @@ class VRPostParser {
private static SAXParserFactory factory = SAXParserFactory.newInstance();
private static RetryPolicy<Object> retryPolicy = new RetryPolicy<>()
.handleIf(e -> e instanceof IOException)
.withBackoff(5, 30, ChronoUnit.SECONDS)
.withMaxRetries(4)
.abortOn(InterruptedException.class)
.onFailedAttempt(e -> logger.warn(String.format("#%d tries failed", e.getAttemptCount()), e.getLastFailure()));
private static List<String> dictionary = Arrays.asList("download", "link", "rapidgator", "filefactory", "filefox");
private final String threadId;
private final String postId;
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) {
this.threadId = threadId;
this.postId = postId;
this.cm = cm;
this.vipergirlsAuthService = vipergirlsAuthService;
this.supportedHosts = supportedHosts;
this.cm = SpringContext.getBean(ConnectionManager.class);
this.vipergirlsAuthService = SpringContext.getBean(VipergirlsAuthService.class);
this.htmlProcessorService = SpringContext.getBean(HtmlProcessorService.class);
this.xpathService = SpringContext.getBean(XpathService.class);
}
public Post parse() throws PostParseException {
@@ -69,10 +62,29 @@ class VRPostParser {
} catch (URISyntaxException e) {
throw new PostParseException(e);
}
VRPostHandler handler = new VRPostHandler(threadId, postId, supportedHosts);
AtomicReference<Throwable> thr = new AtomicReference<>();
HashMap<String, Object> metadata = new HashMap<>();
String postUrl = String.format("https://vipergirls.to/threads/%s/?p=%s&viewfull=1#post%s", threadId, postId, postId);
getPostExtraMetadata(postId, metadata, postUrl, thr);
if (thr.get() != null) {
logger.warn(String.format("Failed to get metadata for thread %s, post %s", threadId, postId), thr.get());
thr.set(null);
}
VRApiPostHandler handler = new VRApiPostHandler(threadId, postId, postUrl, metadata);
logger.debug(String.format("Requesting %s", httpGet));
Post post = Failsafe.with(retryPolicy)
Optional<Post> post = getPost(httpGet, handler, thr);
if (thr.get() != null || post.isEmpty()) {
logger.error(String.format("parsing failed for thread %s, post %s", threadId, postId), thr.get());
throw new PostParseException(thr.get());
}
return post.get();
}
private Optional<Post> getPost(HttpGet httpGet, VRApiPostHandler handler, AtomicReference<Throwable> thr) {
return Failsafe.with(VripperApplication.retryPolicy)
.onFailure(e -> thr.set(e.getFailure()))
.get(() -> {
HttpClient connection = cm.getClient().build();
@@ -81,9 +93,33 @@ class VRPostParser {
throw new DownloadException(String.format("Unexpected response code '%d' for %s", response.getStatusLine().getStatusCode(), httpGet));
}
factory.newSAXParser().parse(response.getEntity().getContent(), handler);
EntityUtils.consumeQuietly(response.getEntity());
return handler.getParsedPost();
}
});
}
private void getPostExtraMetadata(String postId, HashMap<String, Object> metadata, String url, AtomicReference<Throwable> thr) {
HttpGet httpGet = cm.buildHttpGet(url);
Failsafe.with(VripperApplication.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 {
factory.newSAXParser().parse(new BufferedInputStream(response.getEntity().getContent()), handler);
return handler.getParsedPost();
Document document = htmlProcessorService.clean(EntityUtils.toString(response.getEntity()));
Node postNode = xpathService.getAsNode(document, String.format("//li[@id='post_%s']/div[contains(@class, 'postdetails')]", postId));
String postedBy = xpathService.getAsNode(postNode, "./div[contains(@class, 'userinfo')]//a[contains(@class, 'username')]//font").getTextContent().trim();
metadata.put(Post.METADATA.POSTED_BY.name(), postedBy);
Node node = xpathService.getAsNode(document, String.format("//div[@id='post_message_%s']", postId));
metadata.put(Post.METADATA.RESOLVED_NAME.name(), findTitleInContent(node));
return null;
} catch (Exception e) {
throw new PostParseException(String.format("Failed to parse thread %s, post %s", threadId, postId), e);
} finally {
@@ -91,64 +127,66 @@ 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());
}
private List<String> findTitleInContent(Node node) {
List<String> altTitle = new ArrayList<>();
findTitle(node, altTitle);
return altTitle;
}
private boolean findTitle(Node node, List<String> altTitle) {
if (node.getNodeName().equals("a") || node.getNodeName().equals("img")) {
return false;
}
return post;
if (node.getNodeType() == Node.ELEMENT_NODE) {
for (int i = 0; i < node.getChildNodes().getLength(); i++) {
Node item = node.getChildNodes().item(i);
findTitle(item, altTitle);
}
} else if (node.getNodeType() == Node.TEXT_NODE) {
String text = node.getTextContent().trim();
if (!text.isEmpty() && dictionary.stream().noneMatch(e -> text.toLowerCase().contains(e.toLowerCase()))) {
altTitle.add(text);
}
}
return true;
}
}
@Getter
class VRPostParseState extends VRPostState {
class VRApiPostHandler extends DefaultHandler {
private final String type = "postParse";
private String postId;
private int number;
private String title;
private int imageCount;
private String url;
private List<String> previews;
private static final Logger logger = LoggerFactory.getLogger(VRApiPostHandler.class);
VRPostParseState(String threadId, String postId, int number, String title, int imageCount, String url, List<String> previews) {
super(threadId);
this.postId = postId;
this.number = number;
this.title = title;
this.imageCount = imageCount;
this.previews = previews;
this.url = url;
}
}
class VRPostHandler extends DefaultHandler {
private static final Logger logger = LoggerFactory.getLogger(VRPostHandler.class);
private final List<Host> supportedHosts;
private final Collection<Host> supportedHosts;
private final String threadId;
private final String postId;
private final HashMap<String, Object> metadata;
private final String postUrl;
private List<Image> images = new ArrayList<>();
private List<String> previews = new ArrayList<>();
private String threadTitle;
private String postTitle;
private String forum;
private int previewCounter = 0;
private int index = 0;
private String postTitle;
private int imageCount;
private String forum;
private List<String> previews = new ArrayList<>();
private List<Image> images = new ArrayList<>();
@Getter
private Post parsedPost;
VRPostHandler(String threadId, String postId, List<Host> supportedHosts) {
VRApiPostHandler(String threadId, String postId, String postUrl, HashMap<String, Object> metadata) {
this.threadId = threadId;
this.postId = postId;
this.supportedHosts = supportedHosts;
this.postUrl = postUrl;
this.metadata = metadata;
this.supportedHosts = SpringContext.getBeansOfType(Host.class).values();
}
public Optional<Post> getParsedPost() {
return Optional.ofNullable(parsedPost);
}
@Override
@@ -172,7 +210,7 @@ class VRPostHandler extends DefaultHandler {
case "image":
index++;
if (previewCounter++ < 4) {
Optional.ofNullable(attributes.getValue("thumb_url")).map(String::trim).ifPresent(thumbUrl -> previews.add(thumbUrl));
Optional.ofNullable(attributes.getValue("thumb_url")).map(String::trim).ifPresent(previews::add);
}
String mainUrl = Optional.ofNullable(attributes.getValue("main_url")).map(String::trim).orElse(null);
@@ -180,7 +218,11 @@ class VRPostHandler extends DefaultHandler {
Host foundHost = supportedHosts.stream().filter(host -> host.isSupported(mainUrl)).findFirst().orElse(null);
if (foundHost != null) {
logger.debug(String.format("Found supported host %s for %s", foundHost.getClass().getSimpleName(), mainUrl));
images.add(new Image(mainUrl, postId, postTitle, foundHost, index));
try {
images.add(new Image(mainUrl, postId, postTitle, foundHost, index));
} catch (PostParseException e) {
logger.error(String.format("Error occurred while parsing postId %s", postId));
}
} else {
logger.warn(String.format("unsupported host for %s, skipping", mainUrl));
}
@@ -193,18 +235,24 @@ class VRPostHandler extends DefaultHandler {
public void endElement(String uri, String localName, String qName) {
if ("post".equals(qName.toLowerCase())) {
if (imageCount != 0) {
HashMap<String, Object> metadata = new HashMap<>();
metadata.put("PREVIEWS", previews);
parsedPost = new Post(
postTitle,
String.format("https://vipergirls.to/threads/%s/?p=%s&viewfull=1#post%s", threadId, postId, postId),
images,
metadata,
postId,
threadId,
threadTitle,
forum
);
metadata.put(Post.METADATA.PREVIEWS.name(), previews);
AtomicReference<Throwable> thr = new AtomicReference<>();
if (thr.get() != null) {
logger.error(String.format("Failed to get extra metadata for %s", postUrl), thr.get());
}
try {
parsedPost = new Post(
postTitle,
postUrl,
images,
metadata,
postId,
threadId,
threadTitle,
forum);
} catch (PostParseException e) {
logger.error(String.format("Error occurred while parsing postId %s", postId));
}
}
index = 0;
previewCounter = 0;
@@ -2,11 +2,28 @@ package tn.mnlr.vripper.services;
import lombok.Getter;
@Getter
public abstract class VRPostState {
private final String threadId;
import java.util.List;
VRPostState(String threadId) {
@Getter
public class VRPostState {
private final String type = "postParse";
private final String threadId;
private String postId;
private int number;
private String title;
private int imageCount;
private String url;
private List<String> previews;
private String hosts;
VRPostState(String threadId, String postId, int number, String title, int imageCount, String url, List<String> previews, String hosts) {
this.threadId = threadId;
this.postId = postId;
this.number = number;
this.title = title;
this.imageCount = imageCount;
this.previews = previews;
this.url = url;
this.hosts = hosts;
}
}
@@ -1,9 +1,7 @@
package tn.mnlr.vripper.services;
import io.reactivex.processors.PublishProcessor;
import lombok.Getter;
import net.jodah.failsafe.Failsafe;
import net.jodah.failsafe.RetryPolicy;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
@@ -13,18 +11,19 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.Attributes;
import org.xml.sax.helpers.DefaultHandler;
import tn.mnlr.vripper.SpringContext;
import tn.mnlr.vripper.VripperApplication;
import tn.mnlr.vripper.exception.DownloadException;
import tn.mnlr.vripper.exception.PostParseException;
import tn.mnlr.vripper.host.Host;
import javax.xml.parsers.SAXParserFactory;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.net.URISyntaxException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static tn.mnlr.vripper.services.PostParser.VR_API;
@@ -32,42 +31,33 @@ public class VRThreadParser {
private static final Logger logger = LoggerFactory.getLogger(VRThreadParser.class);
private static RetryPolicy<Object> retryPolicy = new RetryPolicy<>()
.handleIf(e -> e instanceof IOException)
.withBackoff(5, 30, ChronoUnit.SECONDS)
.withMaxRetries(4)
.abortOn(InterruptedException.class)
.onFailedAttempt(e -> logger.warn(String.format("#%d tries failed", e.getAttemptCount()), e.getLastFailure()));
private static SAXParserFactory factory = SAXParserFactory.newInstance();
@Getter
private final PublishProcessor<VRPostState> postPublishProcessor = PublishProcessor.create();
private final String threadId;
private final QueuedVGLink queuedVGLink;
private final ConnectionManager cm;
private final VipergirlsAuthService vipergirlsAuthService;
VRThreadParser(String threadId, ConnectionManager cm, VipergirlsAuthService vipergirlsAuthService) {
this.threadId = threadId;
this.cm = cm;
this.vipergirlsAuthService = vipergirlsAuthService;
VRThreadParser(QueuedVGLink queuedVGLink) {
this.queuedVGLink = queuedVGLink;
this.cm = SpringContext.getBean(ConnectionManager.class);
this.vipergirlsAuthService = SpringContext.getBean(VipergirlsAuthService.class);
}
public Void parse() throws PostParseException {
public List<VRPostState> parse() throws PostParseException {
logger.debug(String.format("Parsing thread %s", threadId));
logger.debug(String.format("Parsing thread %s", queuedVGLink));
HttpGet httpGet;
try {
URIBuilder uriBuilder = new URIBuilder(VR_API);
uriBuilder.setParameter("t", threadId);
uriBuilder.setParameter("t", queuedVGLink.getThreadId());
httpGet = cm.buildHttpGet(uriBuilder.build());
} catch (URISyntaxException e) {
throw new PostParseException(e);
}
VRThreadHandler handler = new VRThreadHandler(threadId, postPublishProcessor);
VRThreadHandler handler = new VRThreadHandler(queuedVGLink);
AtomicReference<Throwable> thr = new AtomicReference<>();
logger.debug(String.format("Requesting %s", httpGet));
Failsafe.with(retryPolicy)
List<VRPostState> posts = Failsafe.with(VripperApplication.retryPolicy)
.onFailure(e -> thr.set(e.getFailure()))
.get(() -> {
HttpClient connection = cm.getClient().build();
@@ -78,42 +68,45 @@ public class VRThreadParser {
try {
factory.newSAXParser().parse(new BufferedInputStream(response.getEntity().getContent()), handler);
return handler.getPosts();
} catch (Exception e) {
throw new PostParseException(String.format("Failed to parse thread %s", threadId), e);
throw new PostParseException(String.format("Failed to parse thread %s", queuedVGLink), e);
} finally {
EntityUtils.consumeQuietly(response.getEntity());
}
}
return null;
});
if (thr.get() != null) {
logger.error(String.format("parsing failed for thread %s", threadId), thr.get());
logger.error(String.format("parsing failed for thread %s", queuedVGLink), thr.get());
throw new PostParseException(thr.get());
}
return null;
return posts;
}
}
class VRThreadHandler extends DefaultHandler {
private final String threadId;
private final PublishProcessor<VRPostState> vrPostPublishProcessor;
private final QueuedVGLink queuedVGLink;
private final Collection<Host> supportedHosts;
private final Map<Host, AtomicInteger> hostMap = new HashMap<>();
private List<String> previews = new ArrayList<>();
private String threadTitle;
private String postId;
private String postTitle;
private int imageCount;
private int postCounter;
private int previewCounter = 0;
private List<String> previews = new ArrayList<>();
VRThreadHandler(String threadId, PublishProcessor<VRPostState> vrPostPublishProcessor) {
this.vrPostPublishProcessor = vrPostPublishProcessor;
this.threadId = threadId;
@Getter
private List<VRPostState> posts = new ArrayList<>();
VRThreadHandler(QueuedVGLink queuedVGLink) {
this.queuedVGLink = queuedVGLink;
this.supportedHosts = SpringContext.getBeansOfType(Host.class).values();
}
@Override
public void startDocument() {
vrPostPublishProcessor.onNext(new VRThreadParseState(threadId, "START"));
}
@Override
@@ -130,8 +123,12 @@ class VRThreadHandler extends DefaultHandler {
postTitle = Optional.ofNullable(attributes.getValue("title")).map(e -> e.trim().isEmpty() ? null : e.trim()).orElse(threadTitle);
break;
case "image":
Optional.ofNullable(attributes.getValue("main_url"))
.map(String::trim)
.flatMap(mainUrl -> supportedHosts.stream().filter(host -> host.isSupported(mainUrl)).findFirst())
.ifPresent(host -> Optional.ofNullable(hostMap.get(host)).ifPresentOrElse(AtomicInteger::incrementAndGet, () -> hostMap.put(host, new AtomicInteger(0))));
if (previewCounter++ < 4) {
Optional.ofNullable(attributes.getValue("thumb_url")).map(String::trim).ifPresent(thumbUrl -> previews.add(thumbUrl));
Optional.ofNullable(attributes.getValue("thumb_url")).map(String::trim).ifPresent(previews::add);
}
break;
}
@@ -141,36 +138,26 @@ class VRThreadHandler extends DefaultHandler {
public void endElement(String uri, String localName, String qName) {
if ("post".equals(qName.toLowerCase())) {
if (imageCount != 0) {
vrPostPublishProcessor.onNext(new VRPostParseState(
threadId,
posts.add(new VRPostState(
queuedVGLink.getThreadId(),
postId,
postCounter,
postTitle,
imageCount,
String.format("https://vipergirls.to/threads/%s/?p=%s&viewfull=1#post%s", threadId, postId, postId),
previews
String.format("https://vipergirls.to/threads/%s/?p=%s&viewfull=1#post%s", queuedVGLink, postId, postId),
previews,
hostMap.entrySet().stream().filter(v -> v.getValue().get() > 0).map(e -> e.getKey().getHost() + " (" + e.getValue().get() + ")").collect(Collectors.joining(", "))
));
queuedVGLink.increment();
}
previewCounter = 0;
previews = new ArrayList<>();
hostMap.clear();
}
}
@Override
public void endDocument() {
vrPostPublishProcessor.onNext(new VRThreadParseState(threadId, "END"));
vrPostPublishProcessor.onComplete();
queuedVGLink.done();
}
}
@Getter
class VRThreadParseState extends VRPostState {
private final String type = "threadParseState";
private final String state;
VRThreadParseState(String threadId, String state) {
super(threadId);
this.state = state;
}
}
@@ -18,36 +18,29 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import tn.mnlr.vripper.VripperApplication;
import tn.mnlr.vripper.entities.Post;
import tn.mnlr.vripper.exception.VripperException;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Service
public class VipergirlsAuthService {
private static final Logger logger = LoggerFactory.getLogger(VipergirlsAuthService.class);
@Autowired
private ConnectionManager cm;
@Autowired
private HtmlProcessorService htmlProcessorService;
@Autowired
private XpathService xpathService;
@Autowired
private AppSettingsService appSettingsService;
private final ConnectionManager cm;
private final HtmlProcessorService htmlProcessorService;
private final XpathService xpathService;
private final AppSettingsService appSettingsService;
private final CommonExecutor commonExecutor;
@Getter
private HttpClientContext context = HttpClientContext.create();
@Getter
private boolean authenticated = false;
@Getter
@@ -56,16 +49,23 @@ public class VipergirlsAuthService {
@Getter
private PublishProcessor<String> loggedInUser = PublishProcessor.create();
@Autowired
public VipergirlsAuthService(ConnectionManager cm, HtmlProcessorService htmlProcessorService, XpathService xpathService, AppSettingsService appSettingsService, CommonExecutor commonExecutor) {
this.cm = cm;
this.htmlProcessorService = htmlProcessorService;
this.xpathService = xpathService;
this.appSettingsService = appSettingsService;
this.commonExecutor = commonExecutor;
}
@PostConstruct
private void init() {
context.setCookieStore(new BasicCookieStore());
}
@PreDestroy
private void destroy() throws InterruptedException {
logger.info("Shutting down VipergirlsAuthService");
VripperApplication.commonExecutor.shutdown();
VripperApplication.commonExecutor.awaitTermination(10, TimeUnit.SECONDS);
try {
authenticate();
} catch (VripperException e) {
logger.error("Cannot authenticate user with ViperGirls", e);
}
}
public void authenticate() throws VripperException {
@@ -73,7 +73,7 @@ public class VipergirlsAuthService {
logger.info("Authenticating using ViperGirls credentials");
authenticated = false;
if(!appSettingsService.isVLogin()) {
if (!appSettingsService.getSettings().getVLogin()) {
logger.debug("Authentication option is disabled");
context.getCookieStore().clear();
loggedUser = "";
@@ -81,8 +81,8 @@ public class VipergirlsAuthService {
return;
}
String username = appSettingsService.getVUsername();
String password = appSettingsService.getVPassword();
String username = appSettingsService.getSettings().getVUsername();
String password = appSettingsService.getSettings().getVPassword();
if (username == null || password == null || username.isEmpty() || password.isEmpty()) {
logger.error("Cannot authenticate with ViperGirls credentials, username or password is empty");
@@ -144,24 +144,28 @@ public class VipergirlsAuthService {
loggedInUser.onNext(loggedUser);
}
void leaveThanks(String postUrl, String postId) {
VripperApplication.commonExecutor.submit(() -> {
if (!appSettingsService.isVLogin()) {
logger.debug("Authentication with ViperGirls option is disabled");
return;
}
if (!appSettingsService.isVThanks()) {
logger.debug("Leave thanks option is disabled");
return;
}
if (!authenticated) {
logger.error("You are not authenticated");
return;
}
public void leaveThanks(Post post) {
if (!appSettingsService.getSettings().getVLogin()) {
logger.debug("Authentication with ViperGirls option is disabled");
return;
}
if (!appSettingsService.getSettings().getVThanks()) {
logger.debug("Leave thanks option is disabled");
return;
}
if (!authenticated) {
logger.error("You are not authenticated");
return;
}
if (post.getMetadata().get(Post.METADATA.THANKED.name()) != null && (boolean) post.getMetadata().get(Post.METADATA.THANKED.name())) {
logger.debug("Already left a thanks");
return;
}
commonExecutor.getGeneralExecutor().submit(() -> {
try {
postThanks(postId, getSecurityToken(postUrl));
postThanks(post, getSecurityToken(post.getUrl()));
} catch (Exception e) {
logger.error(String.format("Failed to leave a thanks for url %s, post id %s", postUrl, postId), e);
logger.error(String.format("Failed to leave a thanks for url %s, post id %s", post.getUrl(), post.getPostId()), e);
}
});
}
@@ -201,13 +205,13 @@ public class VipergirlsAuthService {
return securityToken;
}
private void postThanks(String postId, String securityKey) throws VripperException {
private void postThanks(Post post, String securityKey) throws VripperException {
HttpPost postThanks = cm.buildHttpPost("https://vipergirls.to/post_thanks.php");
List<NameValuePair> params = new ArrayList<>();
params.add(new BasicNameValuePair("do", "post_thanks_add"));
params.add(new BasicNameValuePair("using_ajax", "1"));
params.add(new BasicNameValuePair("p", postId));
params.add(new BasicNameValuePair("p", post.getPostId()));
params.add(new BasicNameValuePair("securitytoken", securityKey));
try {
postThanks.setEntity(new UrlEncodedFormEntity(params));
@@ -221,6 +225,7 @@ public class VipergirlsAuthService {
CloseableHttpClient client = cm.getClient().build();
try (CloseableHttpResponse response = client.execute(postThanks, context)) {
post.getMetadata().put(Post.METADATA.THANKED.name(), true);
EntityUtils.consumeQuietly(response.getEntity());
} catch (Exception e) {
throw new VripperException(e);
@@ -2,6 +2,7 @@ package tn.mnlr.vripper.services;
import org.springframework.stereotype.Service;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import tn.mnlr.vripper.exception.XpathException;
import javax.xml.xpath.XPath;
@@ -21,4 +22,12 @@ public class XpathService {
}
}
public NodeList getAsNodeList(Node source, String xpathExpression) throws XpathException {
try {
return (NodeList) xpath.compile(xpathExpression).evaluate(source, XPathConstants.NODESET);
} catch (Exception e) {
throw new XpathException(e);
}
}
}
@@ -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();
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;
}
}
@@ -0,0 +1,212 @@
package tn.mnlr.vripper.web.restendpoints;
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.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.entities.Post;
import tn.mnlr.vripper.services.AppSettingsService;
import tn.mnlr.vripper.services.AppStateExchange;
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.ExecutionException;
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);
private static final String cacheControl = CacheControl.maxAge(365, TimeUnit.DAYS).getHeaderValue();
private final PathService pathService;
private final ThumbnailGenerator thumbnailGenerator;
private final AppSettingsService appSettingsService;
private final AppStateExchange appStateExchange;
@Autowired
public GalleryEndpoint(PathService pathService, ThumbnailGenerator thumbnailGenerator, AppSettingsService appSettingsService, AppStateExchange appStateExchange) {
this.pathService = pathService;
this.thumbnailGenerator = thumbnailGenerator;
this.appSettingsService = appSettingsService;
this.appStateExchange = appStateExchange;
}
@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.getSettings().getViewPhotos()) {
return new ResponseEntity("Gallery option is disabled", HttpStatus.BAD_REQUEST);
}
response.addHeader("Cache-Control", cacheControl);
Post post = appStateExchange.getPost(postId);
File destinationFolder = pathService.getDownloadDestinationFolder(post);
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.getSettings().getViewPhotos()) {
return new ResponseEntity("Gallery option is disabled", HttpStatus.BAD_REQUEST);
}
response.addHeader("Cache-Control", cacheControl);
Post post = appStateExchange.getPost(postId);
File destinationFolder = pathService.getDownloadDestinationFolder(post);
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.getSettings().getViewPhotos()) {
return new ResponseEntity("Gallery option is disabled", HttpStatus.BAD_REQUEST);
}
Post post = appStateExchange.getPost(postId);
File destinationFolder = pathService.getDownloadDestinationFolder(post);
if (destinationFolder.exists() && destinationFolder.isDirectory()) {
return ResponseEntity.ok(
Arrays.stream(Objects.requireNonNull(destinationFolder.listFiles()))
.filter(f -> !f.getName().endsWith("tmp"))
.filter(f -> f.getName().toLowerCase().endsWith(".jpg") || f.getName().toLowerCase().endsWith(".jpeg"))
.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);
}
@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
@Setter
@NoArgsConstructor
class GalleryImage {
private static final CacheLoader<File, Dimension> loader = new CacheLoader<>() {
@Override
public Dimension load(File file) {
try (ImageInputStream in = ImageIO.createImageInputStream(file)) {
final Iterator<ImageReader> readers = ImageIO.getImageReaders(in);
if (readers.hasNext()) {
ImageReader reader = readers.next();
try {
reader.setInput(in);
return 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;
}
}
};
private static final LoadingCache<File, Dimension> cache = CacheBuilder.newBuilder()
.maximumSize(20000)
.build(loader);
private static final Logger logger = LoggerFactory.getLogger(GalleryImage.class);
private String title;
private String src;
private String msrc;
private double w;
private double h;
public GalleryImage(String title, String src, String msrc, double w, double h) {
this.title = title;
this.src = src;
this.msrc = msrc;
this.w = w;
this.h = h;
}
public static GalleryImage fromFile(File file) {
Dimension dimension = null;
try {
dimension = cache.get(file);
} catch (ExecutionException e) {
logger.error(String.format("Failed to get image dimensions for %s", file.toString()), e);
}
if (dimension == null) {
return null;
}
return new GalleryImage(file.getName(), file.getName(), file.getName(), dimension.getWidth(), dimension.getHeight());
}
}
@@ -1,28 +1,27 @@
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.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import tn.mnlr.vripper.VripperApplication;
import tn.mnlr.vripper.entities.Post;
import tn.mnlr.vripper.entities.Post.METADATA;
import tn.mnlr.vripper.exception.PostParseException;
import tn.mnlr.vripper.q.DownloadQ;
import tn.mnlr.vripper.services.AppSettingsService;
import tn.mnlr.vripper.services.AppStateService;
import tn.mnlr.vripper.services.PathService;
import tn.mnlr.vripper.services.PostParser;
import tn.mnlr.vripper.q.ExecutionService;
import tn.mnlr.vripper.services.*;
import tn.mnlr.vripper.web.restendpoints.domain.posts.*;
import tn.mnlr.vripper.web.restendpoints.exceptions.BadRequestException;
import tn.mnlr.vripper.web.restendpoints.exceptions.NotFoundException;
import tn.mnlr.vripper.web.restendpoints.exceptions.ServerErrorException;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@RestController
@CrossOrigin(value = "*")
@@ -32,67 +31,68 @@ public class PostRestEndpoint {
private static final Pattern VG_URL_PATTERN = Pattern.compile("https://vipergirls\\.to/threads/(\\d+)((.*p=)(\\d+))?");
@Autowired
private AppStateService appStateService;
private final AppStateService appStateService;
private final AppStateExchange appStateExchange;
private final PathService pathService;
private final VGHandler vgHandler;
private final ExecutionService executionService;
private final PostParser postParser;
private final CommonExecutor commonExecutor;
@Autowired
private AppSettingsService appSettingsService;
@Autowired
private PathService pathService;
@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());
public PostRestEndpoint(AppStateService appStateService, AppStateExchange appStateExchange, PathService pathService, VGHandler vgHandler, ExecutionService executionService, PostParser postParser, CommonExecutor commonExecutor) {
this.appStateService = appStateService;
this.appStateExchange = appStateExchange;
this.pathService = pathService;
this.vgHandler = vgHandler;
this.executionService = executionService;
this.postParser = postParser;
this.commonExecutor = commonExecutor;
}
@Autowired
private DownloadQ downloadQ;
@Autowired
private PostParser postParser;
@PostMapping("/post")
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity processPost(@RequestBody ThreadUrl url) throws Exception {
logger.debug(String.format("Starting to process thread: %s", url.getUrl()));
if (url.getUrl() == null || url.getUrl().isEmpty()) {
return new ResponseEntity<>("Failed to process empty request", HttpStatus.BAD_REQUEST);
} else if (!url.getUrl().startsWith("https://vipergirls.to")) {
return new ResponseEntity<>("ViperGirls only links are supported", HttpStatus.BAD_REQUEST);
@ResponseStatus(code = HttpStatus.OK)
public void processPost(@RequestBody ThreadUrl _url) {
if (_url.getUrl() == null || _url.getUrl().isEmpty()) {
throw new BadRequestException("Failed to process empty 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")) {
logger.error(String.format("Unsupported link %s", url));
continue;
}
String threadId, postId;
try {
Matcher m = VG_URL_PATTERN.matcher(url.getUrl());
String threadId, postId;
Matcher m = VG_URL_PATTERN.matcher(url);
if (m.find()) {
threadId = m.group(1);
postId = m.group(4);
} else {
throw new PostParseException(String.format("Cannot retrieve thread id from URL %s", url));
throw new BadRequestException(String.format("Cannot retrieve thread id from URL %s", url));
}
queuedVGLinks.add(new QueuedVGLink(url, threadId, postId));
}
try {
vgHandler.handle(queuedVGLinks);
} catch (Exception e) {
throw new PostParseException(String.format("Cannot retrieve thread id from URL %s", url), e);
throw new ServerErrorException(e.getMessage());
}
return ResponseEntity.ok(new PairThreadIdPostId(threadId, postId));
}
@PostMapping("/post/restart")
@ResponseStatus(value = HttpStatus.OK)
public synchronized void restartPost(@RequestBody @NonNull List<PostId> postIds) throws Exception {
for (PostId postId : postIds) {
downloadQ.restart(postId.getPostId());
}
public void restartPost(@RequestBody @NonNull List<PostId> postIds) {
executionService.restartAll(postIds.stream().map(PostId::getPostId).collect(Collectors.toList()));
}
@PostMapping("/post/add")
@ResponseStatus(value = HttpStatus.OK)
public void addPost(@RequestBody List<PostToAdd> posts) {
for (PostToAdd post : posts) {
VripperApplication.commonExecutor.submit(() -> {
commonExecutor.getGeneralExecutor().submit(() -> {
try {
postParser.addPost(post.getPostId(), post.getThreadId());
} catch (PostParseException e) {
@@ -104,135 +104,99 @@ public class PostRestEndpoint {
@GetMapping("/post/path/{postId}")
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity<DownloadPath> folderPath(@PathVariable("postId") String postId) {
File destinationFolder = pathService.getDownloadDestinationFolder(postId);
return ResponseEntity.ok(new DownloadPath(destinationFolder.getPath()));
public DownloadPath folderPath(@PathVariable("postId") String postId) {
Post post = appStateExchange.getPost(postId);
if (post.getPostFolderName() == null) {
throw new NotFoundException("Download has not been started yet for this post");
} else {
return new DownloadPath(pathService.getDownloadDestinationFolder(post).getPath());
}
}
@PostMapping("/post/restart/all")
@ResponseStatus(value = HttpStatus.OK)
public void restartPost() throws Exception {
downloadQ.restartAll();
public void restartPost() {
executionService.restartAll(null);
}
@PostMapping("/post/stop")
@ResponseStatus(value = HttpStatus.OK)
public synchronized void stop(@RequestBody @NonNull List<PostId> postIds) {
for (PostId postId : postIds) {
downloadQ.stop(postId.getPostId());
}
public void stop(@RequestBody @NonNull List<PostId> postIds) {
executionService.stopAll(postIds.stream().map(PostId::getPostId).collect(Collectors.toList()));
}
@PostMapping("/post/stop/all")
@ResponseStatus(value = HttpStatus.OK)
public void stopAll() {
downloadQ.stopAll();
executionService.stopAll(null);
}
@PostMapping("/post/remove")
@ResponseStatus(value = HttpStatus.OK)
public synchronized ResponseEntity remove(@RequestBody @NonNull List<PostId> postIds) {
public List<RemoveResult> remove(@RequestBody @NonNull List<PostId> postIds) {
List<RemoveResult> result = new ArrayList<>();
for (PostId postId : postIds) {
downloadQ.stop(postId.getPostId());
appStateService.remove(postId.getPostId());
result.add(new RemoveResult(postId.getPostId()));
List<String> collect = postIds.stream().map(PostId::getPostId).peek(e -> result.add(new RemoveResult(e))).collect(Collectors.toList());
executionService.stopAll(collect);
appStateService.removeAll(collect);
return result;
}
@PostMapping("/post/rename")
@ResponseStatus(value = HttpStatus.OK)
public List<AltPostName> rename(@RequestBody @NonNull List<AltPostName> postToRename) {
for (AltPostName altPostName : postToRename) {
Post post = appStateExchange.getPost(altPostName.getPostId());
try {
pathService.rename(post, altPostName.getAltName());
} catch (Exception e) {
throw new ServerErrorException(e.getMessage());
}
}
return ResponseEntity.ok(result);
return postToRename;
}
@PostMapping("/post/rename/first")
@ResponseStatus(value = HttpStatus.OK)
public List<PostId> renameFirst(@RequestBody @NonNull List<PostId> postToRename) {
for (PostId postId : postToRename) {
Post post = appStateExchange.getPost(postId.getPostId());
if (post.getMetadata().get(METADATA.RESOLVED_NAME.name()) != null) {
List<String> resolvedNames = ((List<String>) post.getMetadata().get(METADATA.RESOLVED_NAME.name()));
if (!resolvedNames.isEmpty()) {
try {
pathService.rename(post, resolvedNames.get(0));
} catch (Exception e) {
throw new ServerErrorException(e.getMessage());
}
}
}
}
return postToRename;
}
@PostMapping("/post/clear/all")
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity clearAll() {
return ResponseEntity.ok(new RemoveAllResult(appStateService.clearAll()));
public RemoveAllResult clearAll() {
return new RemoveAllResult(appStateService.clearAll());
}
@PostMapping("/post/remove/all")
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity removeAll() {
return ResponseEntity.ok(new RemoveAllResult(appStateService.removeAll()));
public RemoveAllResult removeAll() {
return new RemoveAllResult(appStateService.removeAll(null));
}
@GetMapping("/grab/{threadId}")
@ResponseStatus(value = HttpStatus.OK)
public List<VRPostState> grab(@PathVariable("threadId") @NonNull String threadId) throws Exception {
QueuedVGLink queuedVGLink = appStateExchange.getQueue().values().stream().filter(e -> e.getThreadId().equals(threadId)).findFirst().orElseThrow();
return vgHandler.getCache().get(queuedVGLink);
}
@PostMapping("/grab/remove")
@ResponseStatus(value = HttpStatus.OK)
public ThreadId grabRemove(@RequestBody @NonNull ThreadId threadId) {
vgHandler.remove(threadId.getThreadId());
return threadId;
}
}
@Getter
@Setter
@NoArgsConstructor
class ThreadUrl {
private String url;
public ThreadUrl(String url) {
this.url = url;
}
}
@Getter
@Setter
@NoArgsConstructor
class PostId {
private String postId;
public PostId(String postId) {
this.postId = postId;
}
}
@Getter
@Setter
@NoArgsConstructor
class PairThreadIdPostId {
private String threadId;
private String postId;
PairThreadIdPostId(String threadId, String postId) {
this.threadId = threadId;
this.postId = postId;
}
}
@Getter
@Setter
@NoArgsConstructor
class PostToAdd {
private String threadId;
private String postId;
public PostToAdd(String threadId, String postId) {
this.threadId = threadId;
this.postId = postId;
}
}
@Getter
@Setter
@NoArgsConstructor
class RemoveAllResult {
private List<String> postIds;
private int removed;
RemoveAllResult(List<String> postIds) {
this.removed = postIds.size();
this.postIds = postIds;
}
}
@Getter
@Setter
@NoArgsConstructor
class RemoveResult {
private String postId;
RemoveResult(String postId) {
this.postId = postId;
}
}
@Getter
@Setter
@NoArgsConstructor
class DownloadPath {
private String path;
DownloadPath(String path) {
this.path = path;
}
}
@@ -1,118 +1,62 @@
package tn.mnlr.vripper.web.restendpoints;
import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import tn.mnlr.vripper.exception.ValidationException;
import tn.mnlr.vripper.services.AppSettingsService;
import tn.mnlr.vripper.services.VipergirlsAuthService;
import tn.mnlr.vripper.web.restendpoints.exceptions.BadRequestException;
@RestController
@CrossOrigin(value = "*")
public class SettingsRestEndpoint {
private static final Logger logger = LoggerFactory.getLogger(SettingsRestEndpoint.class);
private final AppSettingsService appSettingsService;
private final VipergirlsAuthService vipergirlsAuthService;
@Autowired
private AppSettingsService settings;
@Autowired
private VipergirlsAuthService vipergirlsAuthService;
@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());
public SettingsRestEndpoint(AppSettingsService appSettingsService, VipergirlsAuthService vipergirlsAuthService) {
this.appSettingsService = appSettingsService;
this.vipergirlsAuthService = vipergirlsAuthService;
}
@PostMapping("/settings/theme")
@ResponseStatus(value = HttpStatus.OK)
public AppSettingsService.Theme postTheme(@RequestBody AppSettingsService.Theme theme) {
synchronized (this.settings) {
this.settings.setTheme(theme);
return settings.getTheme();
synchronized (this.appSettingsService) {
this.appSettingsService.setTheme(theme);
return appSettingsService.getTheme();
}
}
@GetMapping("/settings/theme")
@ResponseStatus(value = HttpStatus.OK)
public AppSettingsService.Theme getTheme() {
return settings.getTheme();
return appSettingsService.getTheme();
}
@PostMapping("/settings")
@ResponseStatus(value = HttpStatus.OK)
public ResponseEntity postSettings(@RequestBody AppSettingsService.Settings settings) throws Exception {
public AppSettingsService.Settings postSettings(@RequestBody AppSettingsService.Settings settings) throws Exception {
synchronized (this.settings) {
synchronized (this.appSettingsService) {
try {
this.settings.check(settings);
this.appSettingsService.check(settings);
} catch (ValidationException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new Response(e.getMessage()));
throw new BadRequestException(e.getMessage());
}
this.settings.setDownloadPath(settings.getDownloadPath());
this.settings.setMaxThreads(settings.getMaxThreads());
this.settings.setAutoStart(settings.isAutoStart());
this.settings.setVLogin(settings.isVLogin());
if (settings.isVLogin()) {
this.settings.setVUsername(settings.getVUsername());
if (!this.settings.getVPassword().equals(settings.getVPassword())) {
this.settings.setVPassword(settings.getVPassword());
}
this.settings.setVThanks(settings.isVThanks());
} else {
this.settings.setVUsername("");
this.settings.setVPassword("");
this.settings.setVThanks(false);
}
this.settings.setDesktopClipboard(settings.isDesktopClipboard());
this.settings.setForceOrder(settings.isForceOrder());
this.settings.setSubLocation(settings.isSubLocation());
this.settings.setThreadSubLocation(settings.isThreadSubLocation());
this.settings.setClearCompleted(settings.isClearCompleted());
this.settings.save();
this.appSettingsService.newSettings(settings);
vipergirlsAuthService.authenticate();
}
return ResponseEntity.ok(getSettings());
return getAppSettingsService();
}
@GetMapping("/settings")
@ResponseStatus(value = HttpStatus.OK)
public AppSettingsService.Settings getSettings() {
public AppSettingsService.Settings getAppSettingsService() {
return new AppSettingsService.Settings(
settings.getDownloadPath(),
settings.getMaxThreads(),
settings.isAutoStart(),
settings.isVLogin(),
settings.getVUsername(),
settings.getVPassword(),
settings.isVThanks(),
settings.isDesktopClipboard(),
settings.isForceOrder(),
settings.isSubLocation(),
settings.isThreadSubLocation(),
settings.isClearCompleted()
);
}
@Getter
static class Response {
String message;
Response(String message) {
this.message = message;
}
return appSettingsService.getSettings();
}
}
@@ -0,0 +1,15 @@
package tn.mnlr.vripper.web.restendpoints.domain.posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@ToString
public class AltPostName {
private String postId;
private String altName;
}
@@ -0,0 +1,18 @@
package tn.mnlr.vripper.web.restendpoints.domain.posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@ToString
public class DownloadPath {
private String path;
public DownloadPath(String path) {
this.path = path;
}
}
@@ -0,0 +1,18 @@
package tn.mnlr.vripper.web.restendpoints.domain.posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@ToString
public class PostId {
private String postId;
public PostId(String postId) {
this.postId = postId;
}
}
@@ -0,0 +1,15 @@
package tn.mnlr.vripper.web.restendpoints.domain.posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@ToString
public class PostToAdd {
private String threadId;
private String postId;
}
@@ -0,0 +1,22 @@
package tn.mnlr.vripper.web.restendpoints.domain.posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.util.List;
@Getter
@Setter
@NoArgsConstructor
@ToString
public class RemoveAllResult {
private List<String> postIds;
private int removed;
public RemoveAllResult(List<String> postIds) {
this.removed = postIds.size();
this.postIds = postIds;
}
}
@@ -0,0 +1,18 @@
package tn.mnlr.vripper.web.restendpoints.domain.posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@ToString
public class RemoveResult {
private String postId;
public RemoveResult(String postId) {
this.postId = postId;
}
}
@@ -0,0 +1,14 @@
package tn.mnlr.vripper.web.restendpoints.domain.posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@ToString
public class ThreadId {
private String threadId;
}
@@ -0,0 +1,14 @@
package tn.mnlr.vripper.web.restendpoints.domain.posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@ToString
public class ThreadUrl {
private String url;
}
@@ -0,0 +1,11 @@
package tn.mnlr.vripper.web.restendpoints.exceptions;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
}
@@ -0,0 +1,11 @@
package tn.mnlr.vripper.web.restendpoints.exceptions;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(code = HttpStatus.NOT_FOUND)
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}
@@ -0,0 +1,11 @@
package tn.mnlr.vripper.web.restendpoints.exceptions;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
public class ServerErrorException extends RuntimeException {
public ServerErrorException(String message) {
super(message);
}
}
@@ -2,7 +2,6 @@ package tn.mnlr.vripper.web.wsendpoints;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -14,7 +13,6 @@ import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import tn.mnlr.vripper.VripperApplication;
import tn.mnlr.vripper.entities.Image;
import tn.mnlr.vripper.entities.Post;
import tn.mnlr.vripper.entities.mixin.ui.ImageUIMixin;
@@ -23,6 +21,7 @@ import tn.mnlr.vripper.services.*;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@@ -35,42 +34,41 @@ public class WebSocketHandler extends TextWebSocketHandler {
private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);
public WebSocketHandler() {
private final GlobalStateService globalStateService;
private final AppStateExchange appStateExchange;
private final DownloadSpeedService downloadSpeedService;
private final VipergirlsAuthService vipergirlsAuthService;
private final CommonExecutor commonExecutor;
private final Map<String, Disposable> postsSubscriptions = new ConcurrentHashMap<>();
private final Map<String, Disposable> postDetailsSubscriptions = new ConcurrentHashMap<>();
private final Map<String, Disposable> stateSubscriptions = new ConcurrentHashMap<>();
private final Map<String, Disposable> downloadSpeedSubscriptions = new ConcurrentHashMap<>();
private final Map<String, Disposable> userSubscriptions = new ConcurrentHashMap<>();
private final Map<String, Disposable> grabQueueSubscriptions = new ConcurrentHashMap<>();
private final Map<String, Future<Void>> threadParseRequests = new ConcurrentHashMap<>();
private final ObjectMapper om = new ObjectMapper();
@Autowired
public WebSocketHandler(GlobalStateService globalStateService, AppStateExchange appStateExchange, DownloadSpeedService downloadSpeedService, VipergirlsAuthService vipergirlsAuthService, CommonExecutor commonExecutor) {
this.globalStateService = globalStateService;
this.appStateExchange = appStateExchange;
this.downloadSpeedService = downloadSpeedService;
this.vipergirlsAuthService = vipergirlsAuthService;
this.commonExecutor = commonExecutor;
om.addMixIn(Image.class, ImageUIMixin.class).addMixIn(Post.class, PostUIMixin.class);
}
@Autowired
private GlobalStateService globalStateService;
@Autowired
private AppStateService appStateService;
@Autowired
private DownloadSpeedService downloadSpeedService;
@Autowired
private VipergirlsAuthService vipergirlsAuthService;
@Autowired
private PostParser postParser;
private Map<String, Disposable> postsSubscriptions = new ConcurrentHashMap<>();
private Map<String, Disposable> postDetailsSubscriptions = new ConcurrentHashMap<>();
private Map<String, Disposable> vrPostParserSubscriptions = new ConcurrentHashMap<>();
private Map<String, Disposable> stateSubscriptions = new ConcurrentHashMap<>();
private Map<String, Disposable> downloadSpeedSubscriptions = new ConcurrentHashMap<>();
private Map<String, Disposable> userSubscriptions = new ConcurrentHashMap<>();
private Map<String, Future<Void>> threadParseRequests = new ConcurrentHashMap<>();
private ObjectMapper om = new ObjectMapper();
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
WSMessage wsMessage = om.readValue(message.getPayload(), WSMessage.class);
WSMessage.CMD cmd = WSMessage.CMD.valueOf(wsMessage.getCmd());
switch (cmd) {
case GRAB_QUEUE_SUB:
subscribeForGrabQueue(session);
break;
case GLOBAL_STATE_SUB:
subscribeForGlobalState(session);
break;
@@ -83,9 +81,6 @@ public class WebSocketHandler extends TextWebSocketHandler {
case POSTS_SUB:
subscribeForPosts(session);
break;
case THREAD_PARSING_SUB:
subscribeForThreadParsing(session, wsMessage.getPayload());
break;
case POST_DETAILS_SUB:
subscribeForPostDetails(session, wsMessage.getPayload());
break;
@@ -93,11 +88,6 @@ public class WebSocketHandler extends TextWebSocketHandler {
logger.debug(String.format("Client %s unsubscribed from post details", session.getId()));
Optional.ofNullable(postDetailsSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
break;
case THREAD_PARSING_UNSUB:
logger.debug(String.format("Client %s unsubscribed from thread parsing", session.getId()));
Optional.ofNullable(vrPostParserSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
Optional.ofNullable(threadParseRequests.remove(session.getId())).ifPresent(d -> d.cancel(true));
break;
case POSTS_UNSUB:
logger.debug(String.format("Client %s unsubscribed from posts", session.getId()));
Optional.ofNullable(postsSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
@@ -114,6 +104,10 @@ public class WebSocketHandler extends TextWebSocketHandler {
logger.debug(String.format("Client %s unsubscribed from user info", session.getId()));
Optional.ofNullable(userSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
break;
case GRAB_QUEUE_UNSUB:
logger.debug(String.format("Client %s unsubscribed from grab queue", session.getId()));
Optional.ofNullable(grabQueueSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
break;
}
}
@@ -132,11 +126,9 @@ public class WebSocketHandler extends TextWebSocketHandler {
stateSubscriptions.put(session.getId(),
globalStateService.getLiveGlobalState()
.subscribeOn(commonExecutor.getScheduler())
.onBackpressureBuffer()
.observeOn(Schedulers.io())
.buffer(5000, TimeUnit.MILLISECONDS)
.filter(e -> !e.isEmpty())
.map(e -> e.subList(e.size() - 1, e.size()))
.map(Collections::singleton)
.map(om::writeValueAsString)
.map(TextMessage::new)
.subscribe(msg -> send(session, msg), e -> logger.error("Failed to send data to client", e))
@@ -158,12 +150,9 @@ public class WebSocketHandler extends TextWebSocketHandler {
downloadSpeedSubscriptions.put(session.getId(),
downloadSpeedService.getReadBytesPerSecond()
.subscribeOn(commonExecutor.getScheduler())
.onBackpressureBuffer()
.observeOn(Schedulers.io())
.buffer(5000, TimeUnit.MILLISECONDS)
.filter(e -> !e.isEmpty())
.map(e -> e.subList(e.size() - 1, e.size()))
.map(e -> e.stream().map(DownloadSpeed::new).collect(Collectors.toList()))
.map(e -> Collections.singleton(new DownloadSpeed(e)))
.map(om::writeValueAsString)
.map(TextMessage::new)
.subscribe(msg -> send(session, msg), e -> logger.error("Failed to send data to client", e))
@@ -185,9 +174,9 @@ public class WebSocketHandler extends TextWebSocketHandler {
userSubscriptions.put(session.getId(),
vipergirlsAuthService.getLoggedInUser()
.subscribeOn(commonExecutor.getScheduler())
.onBackpressureBuffer()
.observeOn(Schedulers.io())
.map(e -> Collections.singletonList(new LoggedUser(e)))
.map(e -> Collections.singleton(new LoggedUser(e)))
.map(om::writeValueAsString)
.map(TextMessage::new)
.subscribe(msg -> send(session, msg), e -> logger.error("Failed to send data to client", e))
@@ -202,48 +191,48 @@ public class WebSocketHandler extends TextWebSocketHandler {
}
try {
send(session, new TextMessage(om.writeValueAsString(appStateService.getCurrentPosts().values())));
send(session, new TextMessage(om.writeValueAsString(appStateExchange.getPosts().values())));
} catch (Exception e) {
logger.error("Unexpected error occurred", e);
}
postsSubscriptions.put(session.getId(),
appStateService.getLivePostsState()
appStateExchange.livePost()
.subscribeOn(commonExecutor.getScheduler())
.onBackpressureBuffer()
.observeOn(Schedulers.io())
.buffer(2000, TimeUnit.MILLISECONDS, 200)
.filter(e -> !e.isEmpty())
.map(e -> e.stream().distinct().collect(Collectors.toList()))
.map(HashSet::new)
.map(om::writeValueAsString)
.map(TextMessage::new)
.subscribe(msg -> send(session, msg), e -> logger.error("Failed to send data to client", e))
);
}
private void subscribeForThreadParsing(WebSocketSession session, String threadId) {
private void subscribeForGrabQueue(WebSocketSession session) {
logger.debug(String.format("Client %s subscribed for thread parsing with threadId = %s", session.getId(), threadId));
if (vrPostParserSubscriptions.containsKey(session.getId())) {
vrPostParserSubscriptions.get(session.getId()).dispose();
logger.debug(String.format("Client %s subscribed for grab queue", session.getId()));
if (grabQueueSubscriptions.containsKey(session.getId())) {
grabQueueSubscriptions.get(session.getId()).dispose();
}
if (threadParseRequests.containsKey(session.getId())) {
threadParseRequests.get(session.getId()).cancel(true);
try {
send(session, new TextMessage(om.writeValueAsString(appStateExchange.getQueue().values())));
} catch (Exception e) {
logger.error("Unexpected error occurred", e);
}
VRThreadParser vrThreadParser = postParser.createVRThreadParser(threadId);
vrPostParserSubscriptions.put(session.getId(), vrThreadParser.getPostPublishProcessor()
.onBackpressureBuffer()
.observeOn(Schedulers.io())
.buffer(200)
.filter(e -> !e.isEmpty())
.map(om::writeValueAsString)
.map(TextMessage::new)
.subscribe(msg -> send(session, msg), e -> logger.error("Failed to send data to client", e))
grabQueueSubscriptions.put(session.getId(),
appStateExchange.liveQueue()
.subscribeOn(commonExecutor.getScheduler())
.onBackpressureBuffer()
.buffer(2000, TimeUnit.MILLISECONDS, 200)
.filter(e -> !e.isEmpty())
.map(HashSet::new)
.map(om::writeValueAsString)
.map(TextMessage::new)
.subscribe(msg -> send(session, msg), e -> logger.error("Failed to send data to client", e))
);
threadParseRequests.put(session.getId(), VripperApplication.commonExecutor.submit(vrThreadParser::parse));
}
private void subscribeForPostDetails(WebSocketSession session, String postId) {
@@ -255,23 +244,23 @@ public class WebSocketHandler extends TextWebSocketHandler {
try {
send(session, new TextMessage(om.writeValueAsString(
appStateService.getCurrentImages()
appStateExchange.getImages()
.values()
.stream()
.filter(e -> e.getPostId().equals(postId))
.collect(Collectors.toList())))
.collect(Collectors.toSet())))
);
} catch (Exception e) {
logger.error("Unexpected error occurred", e);
}
postDetailsSubscriptions.put(session.getId(), appStateService.getLiveImageUpdates()
postDetailsSubscriptions.put(session.getId(), appStateExchange.liveImage()
.subscribeOn(commonExecutor.getScheduler())
.onBackpressureBuffer()
.observeOn(Schedulers.io())
.filter(e -> e.getPostId().equals(postId))
.buffer(2000, TimeUnit.MILLISECONDS, 500)
.filter(e -> !e.isEmpty())
.map(e -> e.stream().distinct().collect(Collectors.toList()))
.map(HashSet::new)
.map(om::writeValueAsString)
.map(TextMessage::new)
.subscribe(msg -> send(session, msg), e -> logger.error("Failed to send data to client", e))
@@ -295,7 +284,7 @@ public class WebSocketHandler extends TextWebSocketHandler {
Optional.ofNullable(stateSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
Optional.ofNullable(downloadSpeedSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
Optional.ofNullable(userSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
Optional.ofNullable(vrPostParserSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
Optional.ofNullable(grabQueueSubscriptions.remove(session.getId())).ifPresent(Disposable::dispose);
Optional.ofNullable(threadParseRequests.remove(session.getId())).ifPresent(d -> d.cancel(true));
logger.debug(String.format("Connection closed for client id: %s", session.getId()));
@@ -318,10 +307,10 @@ public class WebSocketHandler extends TextWebSocketHandler {
GLOBAL_STATE_UNSUB,
SPEED_SUB,
SPEED_UNSUB,
THREAD_PARSING_SUB,
THREAD_PARSING_UNSUB,
USER_SUB,
USER_UNSUB
USER_UNSUB,
GRAB_QUEUE_SUB,
GRAB_QUEUE_UNSUB
}
}
@@ -5,6 +5,5 @@ logging.file=${base.dir}/.vripper/vripper.log
server.port=${vripper.server.port:8080}
management.endpoints.web.exposure.include=shutdown
management.endpoint.shutdown.enabled=true
endpoints.shutdown.enabled=true
spring.profiles.active=portable
#spring.profiles.active=installer
@@ -1,17 +0,0 @@
package tn.mnlr.vripper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class VripperApplicationTests {
@Test
public void contextLoads() {
}
}
+7 -4
View File
@@ -17,6 +17,7 @@
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"aot": true,
"outputPath": "dist/vripper-ui",
"index": "src/index.html",
"main": "src/main.ts",
@@ -28,8 +29,7 @@
],
"styles": [
"src/styles.scss"
],
"scripts": []
]
},
"configurations": {
"production": {
@@ -53,6 +53,10 @@
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
]
}
@@ -85,7 +89,6 @@
"styles": [
"src/styles.scss"
],
"scripts": [],
"assets": [
"src/favicon.ico",
"src/assets"
@@ -136,4 +139,4 @@
}
},
"defaultProject": "vripper-ui"
}
}
+8323 -4927
View File
File diff suppressed because it is too large Load Diff
+40 -40
View File
@@ -1,6 +1,6 @@
{
"name": "vripper-ui",
"version": "2.3.1",
"version": "2.11.6",
"scripts": {
"ng": "ng",
"start": "ng serve",
@@ -13,46 +13,46 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^7.2.3",
"@angular/cdk": "^7.3.1",
"@angular/common": "~7.2.0",
"@angular/compiler": "~7.2.0",
"@angular/core": "~7.2.0",
"@angular/flex-layout": "^7.0.0-beta.23",
"@angular/forms": "~7.2.0",
"@angular/material": "^7.3.0",
"@angular/platform-browser": "~7.2.0",
"@angular/platform-browser-dynamic": "~7.2.0",
"@angular/router": "~7.2.0",
"ag-grid-angular": "^21.2.1",
"ag-grid-community": "^21.2.1",
"core-js": "^2.5.4",
"hammerjs": "^2.0.8",
"ngx-electron": "^2.1.1",
"rxjs": "~6.3.3",
"tslib": "^1.9.0",
"zone.js": "~0.8.26"
"@angular/animations": "9.1.4",
"@angular/cdk": "9.2.1",
"@angular/common": "9.1.4",
"@angular/compiler": "9.1.4",
"@angular/core": "9.1.4",
"@angular/flex-layout": "9.0.0-beta.29",
"@angular/forms": "9.1.4",
"@angular/material": "9.2.1",
"@angular/platform-browser": "9.1.4",
"@angular/platform-browser-dynamic": "9.1.4",
"@angular/router": "9.1.4",
"ag-grid-angular": "23.0.3",
"ag-grid-community": "23.0.2",
"core-js": "3.6.5",
"ngx-clipboard": "13.0.0",
"ngx-electron": "2.2.0",
"rxjs": "6.5.5",
"tslib": "1.11.1",
"zone.js": "0.10.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.13.9",
"@angular/cli": "~7.2.2",
"@angular/compiler-cli": "^7.2.15",
"@angular/language-service": "~7.2.0",
"@types/jasmine": "~2.8.8",
"@types/jasminewd2": "~2.0.3",
"@types/node": "~8.9.4",
"codelyzer": "~4.5.0",
"electron": "^6.0.9",
"jasmine-core": "~2.99.1",
"jasmine-spec-reporter": "~4.2.1",
"karma": "^4.2.0",
"karma-chrome-launcher": "~2.2.0",
"karma-coverage-istanbul-reporter": "~2.0.1",
"karma-jasmine": "~1.1.2",
"karma-jasmine-html-reporter": "^0.2.2",
"protractor": "~5.4.0",
"ts-node": "~7.0.0",
"tslint": "~5.11.0",
"typescript": "~3.2.2"
"@angular-devkit/build-angular": "0.901.4",
"@angular/cli": "9.1.4",
"@angular/compiler-cli": "9.1.4",
"@angular/language-service": "9.1.4",
"@types/jasmine": "3.5.10",
"@types/jasminewd2": "2.0.8",
"@types/node": "12.12.37",
"codelyzer": "5.2.2",
"electron": "8.2.4",
"jasmine-core": "3.5.0",
"jasmine-spec-reporter": "5.0.2",
"karma": "5.0.4",
"karma-chrome-launcher": "3.1.0",
"karma-coverage-istanbul-reporter": "2.1.1",
"karma-jasmine": "3.1.1",
"karma-jasmine-html-reporter": "1.5.3",
"protractor": "5.4.4",
"ts-node": "8.9.1",
"tslint": "6.1.2",
"typescript": "3.8.3"
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>tn.mnlr</groupId>
<artifactId>vripper</artifactId>
<version>2.3.1</version>
<version>2.11.6</version>
</parent>
<artifactId>vripper-ui</artifactId>
<name>vripper-ui</name>
+17 -12
View File
@@ -1,17 +1,22 @@
<div fxLayout="column" *ngIf="connecting()" class="overlay loading" fxLayoutAlign="center center">
<mat-spinner></mat-spinner>
<h2>Loading...</h2>
</div>
<div fxLayout="column" *ngIf="noConnectionState()" class="overlay no-connection" fxLayoutAlign="center center">
<mat-icon class="overlay-icon">error</mat-icon>
<h2>The app seems to be shutdown</h2>
</div>
<div [ngClass]="{ electron: electronService.isElectronApp }" fxLayout="column" id="app-container">
<div id="app-header" fxFlex="nogrow">
<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 id="app-body" fxFlex="grow">
<div fxFlex="grow" id="app-body" style="margin-bottom: 20px;">
<router-outlet></router-outlet>
<app-status-bar style="width: 100%"></app-status-bar>
</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>
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
</div>
</div>
</ng-template>
+72 -9
View File
@@ -1,13 +1,76 @@
#title {
user-select: none
}
.overlay-icon {
font-size: 128px;
width: 128px;
height: 128px;
font-size: 64px;
width: 64px;
height: 64px;
}
.no-connection {
color: #eb6060;
.no-connection, .loading {
background-color: #2c3e50;
}
.overlay {
position: absolute;
display: block;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
}
.sk-chase {
width: 60px;
height: 60px;
position: relative;
animation: sk-chase 2.5s infinite linear both;
}
.sk-chase-dot {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
animation: sk-chase-dot 2.0s infinite ease-in-out both;
}
.sk-chase-dot:before {
content: '';
display: block;
width: 25%;
height: 25%;
background-color: #fff;
border-radius: 100%;
animation: sk-chase-dot-before 2.0s infinite ease-in-out both;
}
.sk-chase-dot:nth-child(1) { animation-delay: -1.1s; }
.sk-chase-dot:nth-child(2) { animation-delay: -1.0s; }
.sk-chase-dot:nth-child(3) { animation-delay: -0.9s; }
.sk-chase-dot:nth-child(4) { animation-delay: -0.8s; }
.sk-chase-dot:nth-child(5) { animation-delay: -0.7s; }
.sk-chase-dot:nth-child(6) { animation-delay: -0.6s; }
.sk-chase-dot:nth-child(1):before { animation-delay: -1.1s; }
.sk-chase-dot:nth-child(2):before { animation-delay: -1.0s; }
.sk-chase-dot:nth-child(3):before { animation-delay: -0.9s; }
.sk-chase-dot:nth-child(4):before { animation-delay: -0.8s; }
.sk-chase-dot:nth-child(5):before { animation-delay: -0.7s; }
.sk-chase-dot:nth-child(6):before { animation-delay: -0.6s; }
@keyframes sk-chase {
100% { transform: rotate(360deg); }
}
@keyframes sk-chase-dot {
80%, 100% { transform: rotate(360deg); }
}
@keyframes sk-chase-dot-before {
50% {
transform: scale(0.4);
} 100%, 0% {
transform: scale(1.0);
}
}
+39 -44
View File
@@ -1,63 +1,58 @@
import { AppService } from './app.service';
import { ClipboardService } from './clipboard.service';
import { ElectronService } from 'ngx-electron';
import { Component, OnInit, OnDestroy, NgZone, AfterViewInit, Renderer2 } from '@angular/core';
import { MatDialog } from '@angular/material';
import { Subscription } from 'rxjs';
import { WsConnectionService, WSState } from './ws-connection.service';
import { WsHandler } from './ws-handler';
import {AppService} from './app.service';
import {ClipboardService} from './clipboard.service';
import {ElectronService} from 'ngx-electron';
import {AfterViewInit, ChangeDetectionStrategy, Component, NgZone, OnDestroy, Renderer2} from '@angular/core';
import {MatDialog} from '@angular/material/dialog';
import {BehaviorSubject, merge, Subject, Subscription} from 'rxjs';
import {WsConnectionService} from './ws-connection.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
styleUrls: ['./app.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent implements OnInit, OnDestroy, AfterViewInit {
export class AppComponent implements OnDestroy, AfterViewInit {
constructor(
public dialog: MatDialog,
private ws: WsConnectionService,
private dialog: MatDialog,
public ws: WsConnectionService,
public electronService: ElectronService,
private clipboardService: ClipboardService,
private ngZone: NgZone,
private appService: AppService,
private renderer: Renderer2
) {}
subscriptions: Subscription[] = [];
websocketHandlerPromise: Promise<WsHandler>;
currentState: WSState;
themeLoaded = false;
noConnectionState(): boolean {
return this.currentState === WSState.CLOSE || this.currentState === WSState.ERROR;
private renderer: Renderer2,
private ngZone: NgZone
) {
this.electron = new BehaviorSubject(electronService.isElectronApp);
}
connecting(): boolean {
return (this.currentState === WSState.INIT || this.currentState === WSState.CONNECTING) && !this.themeLoaded ;
}
private subscriptions: Subscription[] = [];
electron: Subject<boolean>;
loaded: Subject<boolean> = new BehaviorSubject(false);
ngAfterViewInit() {
this.appService.renderer = this.renderer;
}
ngOnInit() {
this.currentState = WSState.INIT;
this.subscriptions.push(this.ws.state.subscribe(e => {
this.currentState = e;
if (this.currentState === WSState.CLOSE || this.currentState === WSState.ERROR) {
this.dialog.closeAll();
} else if (this.currentState === WSState.OPEN) {
this.clipboardService.init();
this.appService
.loadTheme()
.subscribe(() => this.ngZone.run(() => this.themeLoaded = true));
}
}));
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());
}
}
+53 -52
View File
@@ -1,39 +1,38 @@
import { PostsDataService } from './posts-data.service';
import { AppPreviewDirective } from './common/preview-tooltip.directive';
import { WsConnectionService } from './ws-connection.service';
import { AppService } from './app.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NgModule } from '@angular/core';
import {PostContextMenuComponent} from './posts/context-menu/post-context-menu.component';
import {PostDetailsContextMenuComponent} from './post-progress/context-menu/post-details-context-menu.component';
import {AppPreviewDirective} from './domain/preview-tooltip.directive';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {NgModule} from '@angular/core';
import { AppComponent } from './app.component';
import { MaterialModule } from './material.module';
import { PostsComponent } from './posts/posts.component';
import { PostDetailComponent } from './post-detail/post-detail.component';
import { FlexLayoutModule } from '@angular/flex-layout';
import { AppRoutingModule } from './app-routing.module';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AgGridModule } from 'ag-grid-angular';
import { PostProgressRendererComponent } from './posts/post-progress.renderer.component';
import { PostDetailsProgressRendererComponent } from './post-detail/post-details-progress.component';
import { LoginComponent } from './login/login.component';
import { XhrInterceptorService } from './xhr-interceptor.service';
import { HomeComponent } from './home/home.component';
import { SettingsComponent } from './settings/settings.component';
import {AppComponent} from './app.component';
import {MaterialModule} from './material.module';
import {PostsComponent} from './posts/posts.component';
import {PostDetailComponent} from './post-progress/post-detail.component';
import {FlexLayoutModule} from '@angular/flex-layout';
import {AppRoutingModule} from './app-routing.module';
import {HttpClientModule} from '@angular/common/http';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {AgGridModule} from 'ag-grid-angular';
import {PostProgressRendererComponent} from './posts/renderer/post-progress.renderer.component';
import {PostDetailsProgressRendererComponent} from './post-progress/renderer/post-details-progress.component';
import {LoginComponent} from './login/login.component';
import {HomeComponent} from './home/home.component';
import {SettingsComponent} from './settings/settings.component';
import { NgxElectronModule } from 'ngx-electron';
import { ServerService } from './server-service';
import { ConfirmDialogComponent } from './common/confirmation-component/confirmation-dialog';
import { SharedService } from './posts/shared.service';
import { MultiPostComponent } from './multi-post/multi-post.component';
import { OverlayModule } from '@angular/cdk/overlay';
import { AppPreviewComponent } from './common/preview-tooltip.component';
import { UrlRendererComponent } from './multi-post/url-renderer.component';
import { FilterComponent } from './filter/filter.component';
import { ScanComponent } from './scan/scan.component';
import { StatusBarComponent } from './status-bar/status-bar.component';
import { SelectionService } from './selection-service';
import { ToolbarComponent } from './toolbar/toolbar.component';
import {NgxElectronModule} from 'ngx-electron';
import {ConfirmDialogComponent} from './confirmation-component/confirmation-dialog';
import {MultiPostComponent} from './multi-post/multi-post.component';
import {OverlayModule} from '@angular/cdk/overlay';
import {AppPreviewComponent} from './domain/preview-tooltip.component';
import {UrlRendererComponent} from './multi-post/renderer/url-renderer.component';
import {FilterComponent} from './filter/filter.component';
import {ScanComponent} from './scan/scan.component';
import {StatusBarComponent} from './status-bar/status-bar.component';
import {ToolbarComponent} from './toolbar/toolbar.component';
import {GrabQueueComponent} from './grab-queue/grab-queue.component';
import {UrlGrabRendererComponent} from './grab-queue/renderer/url-renderer.component';
import {ClipboardModule} from 'ngx-clipboard';
import {AlternativeTitleComponent} from './posts/alternative-title/alternative-title.component';
@NgModule({
declarations: [
@@ -50,17 +49,25 @@ import { ToolbarComponent } from './toolbar/toolbar.component';
AppPreviewComponent,
AppPreviewDirective,
UrlRendererComponent,
UrlGrabRendererComponent,
FilterComponent,
ScanComponent,
StatusBarComponent,
ToolbarComponent
ToolbarComponent,
GrabQueueComponent,
PostContextMenuComponent,
PostDetailsContextMenuComponent,
AlternativeTitleComponent
],
entryComponents: [
PostDetailComponent,
SettingsComponent,
ConfirmDialogComponent,
AppPreviewComponent,
ScanComponent
// PostDetailComponent,
// SettingsComponent,
// ConfirmDialogComponent,
// AppPreviewComponent,
// ScanComponent,
// MultiPostComponent,
// PostContextMenuComponent,
// PostDetailsContextMenuComponent
],
imports: [
BrowserAnimationsModule,
@@ -74,19 +81,13 @@ import { ToolbarComponent } from './toolbar/toolbar.component';
AgGridModule.withComponents([
PostProgressRendererComponent,
PostDetailsProgressRendererComponent,
UrlRendererComponent
UrlRendererComponent,
UrlGrabRendererComponent
]),
OverlayModule
],
providers: [
AppService,
WsConnectionService,
{ provide: HTTP_INTERCEPTORS, useClass: XhrInterceptorService, multi: true },
ServerService,
SharedService,
SelectionService,
PostsDataService
OverlayModule,
ClipboardModule
],
bootstrap: [AppComponent]
})
export class AppModule {}
export class AppModule {
}
+43 -19
View File
@@ -1,33 +1,50 @@
import { MatDialog } from '@angular/material';
import { HttpClient } from '@angular/common/http';
import { Injectable, Renderer2, NgZone } from '@angular/core';
import { ServerService } from './server-service';
import { tap } from 'rxjs/operators';
import { ScanComponent } from './scan/scan.component';
import { Observable } from 'rxjs';
import { BreakpointState, Breakpoints, BreakpointObserver } from '@angular/cdk/layout';
import {Settings} from './domain/settings.model';
import {environment} from 'src/environments/environment';
import {MatDialog} from '@angular/material/dialog';
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 {BreakpointObserver, Breakpoints, BreakpointState} from '@angular/cdk/layout';
import {Title} from '@angular/platform-browser';
@Injectable()
@Injectable({
providedIn: 'root'
})
export class AppService {
constructor(
private httpClient: HttpClient,
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;
get settings(): Settings {
return { ...this._settings };
}
get darkTheme(): boolean {
return this._darkTheme;
}
darkTheme = false;
_renderer: Renderer2;
set renderer(renderer: Renderer2) {
this._renderer = renderer;
}
isExtraSmall: Observable<BreakpointState> = this.breakpointObserver.observe(Breakpoints.XSmall);
isScanOpen = false;
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 {
@@ -36,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, {
@@ -65,11 +92,8 @@ export class AppService {
}
});
scanDialog.afterOpened().subscribe(() => this.isScanOpen = true);
scanDialog.afterClosed().subscribe(() => {
smallDialogSubscription.unsubscribe();
this.isScanOpen = false;
});
}
}
@@ -1,12 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ClipboardService } from './clipboard.service';
describe('ClipboardService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: ClipboardService = TestBed.get(ClipboardService);
expect(service).toBeTruthy();
});
});
+8 -7
View File
@@ -1,10 +1,10 @@
import { Subject, Observable } from 'rxjs';
import { ElectronService } from 'ngx-electron';
import { Injectable, OnInit } from '@angular/core';
import { Clipboard } from 'electron';
import { HttpClient } from '@angular/common/http';
import { Settings } from './common/settings.model';
import { ServerService } from './server-service';
import {Observable, Subject} from 'rxjs';
import {ElectronService} from 'ngx-electron';
import {Injectable} from '@angular/core';
import {Clipboard} from 'electron';
import {HttpClient} from '@angular/common/http';
import {Settings} from './domain/settings.model';
import {ServerService} from './server-service';
@Injectable({
providedIn: 'root'
@@ -13,6 +13,7 @@ export class ClipboardService {
private links$: Subject<string> = new Subject();
private interval: NodeJS.Timer;
private lastText = '';
constructor(
private electronService: ElectronService,
private serverService: ServerService,
@@ -1,36 +0,0 @@
import { Component, Input } from '@angular/core';
import { trigger, state, style, transition, animate } from '@angular/animations';
@Component({
selector: 'app-preview',
template: `
<div class="previews" style="display: inline-block; white-space: nowrap;">
<ng-container *ngFor="let link of links">
<img style="display: inline-block; max-width:150px; min-width:150px; width: auto; height: auto;"
[@simpleFadeAnimation]="'in'"
class="mat-elevation-z8"
[src]="link"
/>
</ng-container>
</div>
`,
styles: [
`
img {
max-height: 200px;
max-width: 200px;
margin-left: 5px;
}
`
],
animations: [
trigger('simpleFadeAnimation', [
state('in', style({ opacity: 1 })),
transition(':enter', [style({ opacity: 0 }), animate(300)])
])
]
})
export class AppPreviewComponent {
@Input() links: string[] = [];
}
@@ -1,46 +0,0 @@
import { AppPreviewComponent } from './preview-tooltip.component';
import { Directive, Input, HostListener, ComponentRef, OnInit, ElementRef, NgZone } from '@angular/core';
import { OverlayRef, Overlay, OverlayPositionBuilder } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
@Directive({ selector: '[appPreview]' })
export class AppPreviewDirective implements OnInit {
private overlayRef: OverlayRef;
tooltipPortal = new ComponentPortal(AppPreviewComponent);
@Input() appPreview: string[];
constructor(private overlayPositionBuilder: OverlayPositionBuilder,
private elementRef: ElementRef,
private overlay: Overlay,
private ngZone: NgZone) {}
ngOnInit() {
const positionStrategy = this.overlayPositionBuilder
.flexibleConnectedTo(this.elementRef)
.withPush(true)
.withGrowAfterOpen(true)
.withPositions([{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
}]);
this.overlayRef = this.overlay.create({ positionStrategy });
}
@HostListener('mouseenter')
show() {
this.ngZone.run(() => {
const tooltipRef: ComponentRef<AppPreviewComponent> = this.overlayRef.attach(this.tooltipPortal);
tooltipRef.instance.links = this.appPreview;
});
}
@HostListener('mouseout')
hide() {
this.ngZone.run(() => {
this.overlayRef.detach();
});
}
}
@@ -1,10 +0,0 @@
export interface Settings {
downloadPath: string;
maxThreads: number;
autoStart: boolean;
vLogin: boolean;
vUsername: string;
vPassword: string;
vThanks: boolean;
desktopClipboard: boolean;
}
@@ -1,5 +1,5 @@
import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material';
import {Component, Inject} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
export interface DialogConfirmation {
header: string;
@@ -5,10 +5,12 @@ export enum CMD {
SPEED_SUB = 'SPEED_SUB',
USER_SUB = 'USER_SUB',
THREAD_PARSING_SUB = 'THREAD_PARSING_SUB',
GRAB_QUEUE_SUB = 'GRAB_QUEUE_SUB',
POSTS_UNSUB = 'POSTS_UNSUB',
POST_DETAILS_UNSUB = 'POST_DETAILS_UNSUB',
GLOBAL_STATE_UNSUB = 'GLOBAL_STATE_UNSUB',
SPEED_UNSUB = 'SPEED_UNSUB',
USER_UNSUB = 'USER_UNSUB',
THREAD_PARSING_UNSUB = 'THREAD_PARSING_UNSUB'
THREAD_PARSING_UNSUB = 'THREAD_PARSING_UNSUB',
GRAB_QUEUE_UNSUB = 'GRAB_QUEUE_UNSUB'
}

Some files were not shown because too many files have changed in this diff Show More