Compare commits

...
23 Commits
Author SHA1 Message Date
death-claw c657cc7688 version bump 2024-01-14 19:06:16 +01:00
death-claw 49fb659d08 fix pom.xml 2024-01-14 18:54:05 +01:00
death-claw 71f9e208e6 version bump 2024-01-14 18:37:16 +01:00
death-clawandGitHub 7293d3d031 Merge pull request #159 from death-claw/feature/issue-156/allow-download-folder-rename
Feature/issue 156/allow download folder rename
2024-01-14 17:35:15 +00:00
death-clawandGitHub bf26100549 Feature/issue 156/allow download folder rename (#158)
* fixes #156
2024-01-14 17:33:43 +00:00
death-claw b6c7bd8e26 fixes #156 2024-01-14 18:33:08 +01:00
death-clawandGitHub 134abf791e fixes #156 (#157) 2024-01-14 17:31:34 +00:00
death-claw 1dcb07e124 fixes #156 2024-01-14 18:30:47 +01:00
death-clawandGitHub b5db9f8d7b fixes #154 (#155) 2024-01-13 21:27:29 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2130b30329 Bump ch.qos.logback:logback-classic from 1.4.11 to 1.4.12 (#140)
Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.4.11 to 1.4.12.
- [Commits](https://github.com/qos-ch/logback/compare/v_1.4.11...v_1.4.12)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-classic
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-13 19:10:13 +00:00
death-clawandGitHub 5f9c231eab fixes #152 (#153) 2024-01-13 19:00:46 +00:00
death-clawandGitHub 537ad90084 fixes #147 (#150) 2024-01-13 17:57:12 +00:00
death-clawandGitHub 81cf97cc50 fixes #145 (#146) 2023-12-12 20:15:48 +00:00
UncleRoger33andGitHub d82cf55590 Update README.md (#142) 2023-12-12 19:23:28 +00:00
death-clawandGitHub 884704c70d fixes #143 (#144) 2023-12-08 13:36:54 +00:00
death-claw e798218a21 version bump 2023-11-25 18:37:35 +01:00
death-claw 04df3bcc81 version bump 2023-11-25 18:30:02 +01:00
death-clawandGitHub 7af0aeafa7 fixes #137 (#138) 2023-11-25 18:25:57 +01:00
death-claw 90625736e9 fix release.yml 2023-11-14 01:03:46 +01:00
death-claw eff254747f fix release.yml 2023-11-14 01:00:40 +01:00
death-claw b8b22f7faa fix release.yml 2023-11-14 00:50:54 +01:00
death-clawandGitHub 6376576e9e fixes #130 (#131) 2023-11-14 00:38:43 +01:00
l1xanderandGitHub f0c2de829a Fix for images on Pimpandhost throwing exception (#127)
Attempts to fix the following exception:


Failed to download http://pimpandhost.com/image/39446205-medium.html
me.mnlr.vripper.exception. DownloadException: me.mnlr.vripper.exception.HostException: Xpath //img[contains(@class, 'original')]' cannot be found in 'http://pimpandhost.com/image/39446205-medium.html' at me.mnlr.vripper.download.ImageDownloadRunnable.download(Image Download Runnable.kt:88)
at me.mnlr.vripper.download.ImageDownloadRunnable.run(ImageDownloadRunnable.kt:151)
at net.jodah.failsafe.Functions.lambda$toSupplier$10(Functions.java:262)
at net.jodah.failsafe.Functions.lambda$get$0(Functions.java:48)
at net.jodah.failsafe.Retry Policy Executor.lambda$supply$0(RetryPolicyExecutor.java:66)
at net.jodah.failsafe.Execution.executeSync(Execution.java:128)
at net.jodah.failsafe.FailsafeExecutor.call(FailsafeExecutor.java:379)
at net.jodah.failsafe.FailsafeExecutor.run(FailsafeExecutor.java:212)
at me.mnlr.vripper.download.DownloadService.schedule$lambda$28 (DownloadService.kt:264)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: me.mnlr.vripper.exception.HostException: Xpath //img[contains(@class, 'original')]' cannot be found in 'http://pimpandhost.com/image/39446205-medium.html'
at me.mnlr.vripper.host.PimpandhostHost.resolve(PimpandhostHost.kt:46)
at me.mnlr.vripper.host.Host.download Internal(Host.kt:62)
at me.mnlr.vripper.download.ImageDownloadRunnable.download (ImageDownloadRunnable.kt:72)
11 more
2023-09-22 10:04:19 +01:00
299 changed files with 16426 additions and 21840 deletions
+14 -11
View File
@@ -13,14 +13,17 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
server-id: github # Value of the distributionManagement/repository/id field of the pom.xml
settings-path: ${{ github.workspace }} # location for the settings.xml file
- name: Build parent project
run: mvn -N -q install --file pom.xml
- name: Build Core Jar
run: mvn -B -q install --file vripper-core/pom.xml
@@ -28,10 +31,10 @@ jobs:
run: mvn -B -q install --file vripper-gui/pom.xml
- name: Rename GUI Jar
run: mv vripper-gui/target/vripper-gui-${{ github.event.release.tag_name }}.jar vripper-gui/target/vripper-noarch-gui-${{ github.event.release.tag_name }}.jar
run: mv vripper-gui/target/vripper-gui-${{ github.event.release.tag_name }}-jar-with-dependencies.jar vripper-gui/target/vripper-noarch-gui-${{ github.event.release.tag_name }}.jar
- if: matrix.os == 'ubuntu-latest'
name: Upload GUI Jar
name: Release GUI Jar
uses: softprops/action-gh-release@v1
with:
files: vripper-gui/target/vripper-noarch-gui-${{ github.event.release.tag_name }}.jar
@@ -48,7 +51,7 @@ jobs:
run: mv vripper-web/target/vripper-web-${{ github.event.release.tag_name }}.jar vripper-web/target/vripper-noarch-web-${{ github.event.release.tag_name }}.jar
- if: matrix.os == 'ubuntu-latest'
name: Upload WEB Jar
name: Release WEB Jar
uses: softprops/action-gh-release@v1
with:
files: vripper-web/target/vripper-noarch-web-${{ github.event.release.tag_name }}.jar
@@ -90,7 +93,7 @@ jobs:
mv dist/VRipper-${{ github.event.release.tag_name }}.dmg dist/vripper-macos-${{ github.event.release.tag_name }}.dmg
- if: matrix.os == 'ubuntu-latest'
name: Zip portable
name: Zip Ubuntu portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
@@ -99,7 +102,7 @@ jobs:
filename: 'vripper-linux-portable-${{ github.event.release.tag_name }}.zip'
- if: matrix.os == 'windows-latest'
name: Zip portable
name: Zip Windows portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
@@ -108,7 +111,7 @@ jobs:
filename: 'vripper-windows-portable-${{ github.event.release.tag_name }}.zip'
- if: matrix.os == 'macos-latest'
name: Zip portable
name: Zip MacOS portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
@@ -117,7 +120,7 @@ jobs:
filename: 'vripper-macos-portable-${{ github.event.release.tag_name }}.zip'
- if: matrix.os == 'ubuntu-latest'
name: Upload DEB and RPM package for Linux
name: Release packages for Linux
uses: softprops/action-gh-release@v1
with:
files: |
@@ -126,7 +129,7 @@ jobs:
jpackage/dist/vripper-linux-portable-${{ github.event.release.tag_name }}.zip
- if: matrix.os == 'windows-latest'
name: Upload packages for Windows
name: Release packages for Windows
uses: softprops/action-gh-release@v1
with:
files: |
@@ -135,7 +138,7 @@ jobs:
jpackage/dist/vripper-windows-portable-${{ github.event.release.tag_name }}.zip
- if: matrix.os == 'macos-latest'
name: Upload package for macOS
name: Release packages for macOS
uses: softprops/action-gh-release@v1
with:
files: |
+52
View File
@@ -2,6 +2,58 @@
This is my spin for a cross-platform gallery ripper for [vipergirls.to](https://vipergirls.to).
![1Jw0oq8h_o](https://github.com/UncleRoger33/vripper-project/assets/66418211/80b44389-7620-4e05-8696-4b62fa626b1b)
## Requirements
Direct access to vipergirls.to (or one of its [alternative domains](https://vipergirls.to/threads/5887340)), consider using a VPN otherwise.
## Supported Image Hosts
The following hosts are supported:
* acidimg.cc
* imagetwist.com
* imagezilla.com
* imgspice.com
* imagebam.com
* imgbox.com
* imx.to
* pimpandhost.com
* pixhost.to
* pixxxels.cc
* turboimagehost.com
* postimg.cc
* imagevenue.com
* pixroute.to
* vipr.im
## Installation
Download the latest version from the Release page and execute the installer depending on your Operating System
Application data (application logs, settings and persisted data) is stored in
HOME_FOLDER/vripper (Windows)
HOME_FOLDER/.config/vripper (Linux and macOS)
## Instructions for Jar
You need Java 17+, you can download from https://adoptium.net/
Download the latest jar file from the Release page, open a command prompt and run the jar file using the following command
For the GUI app
javaw -jar vripper-gui.jar
For the WEB app
java -jar vripper-web.jar
Application data (application logs, settings and persisted data) is stored in the location where you launched the jar for both GUI and WEB
## How to build
You need JDK 17 and a recent version of maven 3.6.1+
+1 -1
View File
@@ -1,2 +1,2 @@
--type app-image
--java-options "-Dbase.dir=${user.dir}"
--java-options "-Dvripper.portable=true"
+1 -1
View File
@@ -1,5 +1,5 @@
--icon icon.png
--java-options "-Dbase.dir=${user.home}/.config"
--java-options "-Dvripper.portable=false"
--linux-package-name vripper
--linux-app-release 1
--linux-menu-group Utility
+1 -1
View File
@@ -1,4 +1,4 @@
--icon icon.icns
--java-options "-Dbase.dir=${user.home}/.config"
--java-options "-Dvripper.portable=false"
--mac-package-identifier me.mnlr.vripper.vripper-gui
--mac-package-name VRipper
+1 -1
View File
@@ -1,5 +1,5 @@
--icon icon.ico
--java-options "-Dbase.dir=${user.home}"
--java-options "-Dvripper.portable=false"
--win-dir-chooser
--win-menu
--win-per-user-install
+1 -1
View File
@@ -1,7 +1,7 @@
--input jar
--main-jar vripper-gui.jar
--add-modules java.base,java.desktop,java.sql,jdk.unsupported,jdk.crypto.ec,java.security.jgss
--jlink-options "--strip-native-commands --strip-debug --no-man-pages --no-header-files --compress=2"
--jlink-options "--strip-native-commands --strip-debug --no-man-pages --no-header-files --compress=zip-6"
--name VRipper
--description "Image ripper tool for vipergirls"
--dest dist
+167 -3
View File
@@ -3,14 +3,178 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>me.mnlr</groupId>
<groupId>me.vripper</groupId>
<artifactId>vripper-project</artifactId>
<packaging>pom</packaging>
<version>${revision}</version>
<properties>
<java.version>21</java.version>
<kotlin.version>1.9.20</kotlin.version>
<maven.deploy.skip>false</maven.deploy.skip>
<timestamp>${maven.build.timestamp}</timestamp>
<maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<revision>5.2.0</revision>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>1.1.0</version>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals>
<goal>flatten</goal>
</goals>
</execution>
<execution>
<id>flatten.clean</id>
<phase>clean</phase>
<goals>
<goal>clean</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<modules>
<module>vripper-core</module>
<module>vripper-web-ui</module>
<module>vripper-web</module>
<module>vripper-gui</module>
</modules>
<packaging>pom</packaging>
<version>4.4.0</version>
<dependencyManagement>
<dependencies>
<dependency>
<artifactId>logback-classic</artifactId>
<groupId>ch.qos.logback</groupId>
<version>1.4.12</version>
</dependency>
<dependency>
<artifactId>kotlin-stdlib</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core-jvm</artifactId>
<version>1.7.3</version>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-jdbc</artifactId>
<version>0.41.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-java-time</artifactId>
<version>0.41.1</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.0</version>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.5.10</version>
</dependency>
<dependency>
<groupId>io.insert-koin</groupId>
<artifactId>koin-core-jvm</artifactId>
<version>3.4.2</version>
</dependency>
<dependency>
<artifactId>jackson-module-kotlin</artifactId>
<groupId>com.fasterxml.jackson.module</groupId>
<version>2.15.2</version>
</dependency>
<dependency>
<artifactId>h2</artifactId>
<groupId>com.h2database</groupId>
<version>2.2.224</version>
</dependency>
<dependency>
<artifactId>httpclient</artifactId>
<groupId>org.apache.httpcomponents</groupId>
<version>4.5.14</version>
</dependency>
<dependency>
<artifactId>htmlcleaner</artifactId>
<groupId>net.sourceforge.htmlcleaner</groupId>
<version>2.26</version>
</dependency>
<dependency>
<artifactId>failsafe</artifactId>
<groupId>net.jodah</groupId>
<version>2.4.4</version>
</dependency>
<dependency>
<artifactId>caffeine</artifactId>
<groupId>com.github.ben-manes.caffeine</groupId>
<version>3.1.8</version>
</dependency>
<dependency>
<artifactId>jackson-databind</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
<version>5.13.0</version>
</dependency>
<dependency>
<artifactId>kotlin-reflect</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<artifactId>jcl-over-slf4j</artifactId>
<groupId>org.slf4j</groupId>
<version>1.7.32</version>
</dependency>
<dependency>
<artifactId>httpclient5</artifactId>
<groupId>org.apache.httpcomponents.client5</groupId>
<version>5.2.1</version>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
<version>1.16.0</version>
</dependency>
<dependency>
<artifactId>liquibase-core</artifactId>
<groupId>org.liquibase</groupId>
<version>4.24.0</version>
</dependency>
<dependency>
<artifactId>liquibase-slf4j</artifactId>
<groupId>com.mattbertolini</groupId>
<version>5.0.0</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
+140 -122
View File
@@ -1,129 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<artifactId>spring-boot-starter-parent</artifactId>
<groupId>org.springframework.boot</groupId>
<relativePath/>
<version>3.1.3</version>
</parent>
<groupId>me.mnlr.vripper</groupId>
<artifactId>vripper-core</artifactId>
<version>4.4.0</version>
<name>vripper-core</name>
<description>vripper-core</description>
<properties>
<java.version>17</java.version>
<kotlin.version>1.8.21</kotlin.version>
<maven.deploy.skip>false</maven.deploy.skip>
<timestamp>${maven.build.timestamp}</timestamp>
<maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>
</properties>
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<dependencies>
<dependency>
<artifactId>kotlin-reflect</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
</dependency>
<dependency>
<artifactId>kotlin-stdlib</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
</dependency>
<dependency>
<artifactId>jackson-module-kotlin</artifactId>
<groupId>com.fasterxml.jackson.module</groupId>
</dependency>
<dependency>
<artifactId>spring-boot-starter-jdbc</artifactId>
<groupId>org.springframework.boot</groupId>
</dependency>
<dependency>
<artifactId>liquibase-core</artifactId>
<groupId>org.liquibase</groupId>
</dependency>
<dependency>
<artifactId>hsqldb</artifactId>
<groupId>org.hsqldb</groupId>
<scope>runtime</scope>
</dependency>
<dependency>
<artifactId>httpclient</artifactId>
<groupId>org.apache.httpcomponents</groupId>
<version>4.5.14</version>
</dependency>
<dependency>
<artifactId>htmlcleaner</artifactId>
<groupId>net.sourceforge.htmlcleaner</groupId>
<version>2.26</version>
</dependency>
<dependency>
<artifactId>reactor-core</artifactId>
<groupId>io.projectreactor</groupId>
</dependency>
<dependency>
<artifactId>failsafe</artifactId>
<groupId>net.jodah</groupId>
<version>2.4.4</version>
</dependency>
<dependency>
<artifactId>caffeine</artifactId>
<groupId>com.github.ben-manes.caffeine</groupId>
</dependency>
<dependency>
<artifactId>jackson-databind</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
<version>5.13.0</version>
</dependency>
<parent>
<groupId>me.vripper</groupId>
<artifactId>vripper-project</artifactId>
<version>${revision}</version>
</parent>
<dependency>
<artifactId>spring-boot-starter-test</artifactId>
<groupId>org.springframework.boot</groupId>
<scope>test</scope>
</dependency>
<dependency>
<artifactId>spring-restdocs-mockmvc</artifactId>
<groupId>org.springframework.restdocs</groupId>
<scope>test</scope>
</dependency>
</dependencies>
<artifactId>vripper-core</artifactId>
<name>vripper-core</name>
<description>vripper-core</description>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<artifactId>kotlin-maven-allopen</artifactId>
<dependencies>
<dependency>
<artifactId>logback-classic</artifactId>
<groupId>ch.qos.logback</groupId>
</dependency>
<dependency>
<artifactId>kotlin-stdlib</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core-jvm</artifactId>
</dependency>
<dependency>
<artifactId>kotlin-reflect</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-java-time</artifactId>
</dependency>
<dependency>
<groupId>io.insert-koin</groupId>
<artifactId>koin-core-jvm</artifactId>
</dependency>
<dependency>
<artifactId>jackson-module-kotlin</artifactId>
<groupId>com.fasterxml.jackson.module</groupId>
</dependency>
<dependency>
<artifactId>h2</artifactId>
<groupId>com.h2database</groupId>
</dependency>
<dependency>
<artifactId>httpclient5</artifactId>
<groupId>org.apache.httpcomponents.client5</groupId>
</dependency>
<dependency>
<artifactId>jcl-over-slf4j</artifactId>
<groupId>org.slf4j</groupId>
</dependency>
<dependency>
<artifactId>htmlcleaner</artifactId>
<groupId>net.sourceforge.htmlcleaner</groupId>
</dependency>
<dependency>
<artifactId>failsafe</artifactId>
<groupId>net.jodah</groupId>
</dependency>
<dependency>
<artifactId>caffeine</artifactId>
<groupId>com.github.ben-manes.caffeine</groupId>
</dependency>
<dependency>
<artifactId>jackson-databind</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
</dependency>
<dependency>
<artifactId>liquibase-core</artifactId>
<groupId>org.liquibase</groupId>
</dependency>
<dependency>
<artifactId>liquibase-slf4j</artifactId>
<groupId>com.mattbertolini</groupId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</dependency>
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<source>src/main/kotlin</source>
<source>target/generated-sources/annotations</source>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifestEntries>
<Build-Time>${maven.build.timestamp}</Build-Time>
<Built-By>${user.name}</Built-By>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -1,19 +0,0 @@
package me.mnlr.vripper
import jakarta.annotation.PreDestroy
import me.mnlr.vripper.download.DownloadService
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
@Component
class Management(
private val jdbcTemplate: JdbcTemplate,
private val downloadService: DownloadService
) {
@PreDestroy
fun destroy() {
downloadService.destroy()
jdbcTemplate.execute("SHUTDOWN")
}
}
@@ -1,37 +0,0 @@
package me.mnlr.vripper
import org.springframework.beans.BeansException
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.context.ConfigurableApplicationContext
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.stereotype.Component
import me.mnlr.vripper.delegate.LoggerDelegate
@Component
@EnableScheduling
class SpringContext : ApplicationContextAware {
@Throws(BeansException::class)
override fun setApplicationContext(context: ApplicationContext) {
// store ApplicationContext reference to access required beans later on
Companion.context = context as ConfigurableApplicationContext
}
companion object {
private val log by LoggerDelegate()
private lateinit var context: ConfigurableApplicationContext
fun <T> getBean(beanClass: Class<T>): T {
return context.getBean(beanClass)
}
fun <T> getBeansOfType(beanClass: Class<T>): Map<String, T> {
return context.getBeansOfType(beanClass)
}
fun close() {
log.info("Application terminating...")
context.close()
}
}
}
@@ -1,290 +0,0 @@
package me.mnlr.vripper.download
import jakarta.annotation.PostConstruct
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.ImageDownloadState
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.entities.PostDownloadState
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.formatToString
import me.mnlr.vripper.host.Host
import me.mnlr.vripper.repositories.ImageRepository
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.repositories.PostDownloadStateRepository
import me.mnlr.vripper.services.*
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.RetryPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import java.util.stream.Collectors
import kotlin.concurrent.withLock
@Service
class DownloadService(
@param:Value("\${download.pool-size}") private val maxPoolSize: Int,
private val settingsService: SettingsService,
private val dataTransaction: DataTransaction,
private val hosts: List<Host>,
private val eventRepository: LogEventRepository,
private val retryPolicyService: RetryPolicyService,
private val postDownloadStateRepository: PostDownloadStateRepository,
private val imageRepository: ImageRepository,
private val asyncTaskRunnerService: AsyncTaskRunnerService,
) {
private val log by LoggerDelegate()
// Class fields
private val executor: ExecutorService = Executors.newFixedThreadPool(maxPoolSize)
private val running: MutableMap<Host, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val pending: MutableMap<Host, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private val pollThread: Thread
init {
pollThread = Thread(
{
val accepted: MutableList<ImageDownloadRunnable> = mutableListOf()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
while (!Thread.interrupted()) {
lock.withLock {
candidates.addAll(getCandidates(candidateCount()))
candidates.forEach {
if (canRun(it.context.image.host)) {
accepted.add(it)
running[it.context.image.host]!!.add(it)
log.debug("${it.context.image.url} accepted to run")
}
}
accepted.forEach {
pending[it.context.image.host]?.remove(it)
schedule(it)
}
accepted.clear()
candidates.clear()
try {
condition.await()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
}, "Download scheduler thread"
)
}
@PostConstruct
fun init() {
pollThread.start()
}
fun destroy() {
log.info("Shutting down ExecutionService")
pollThread.interrupt()
executor.shutdown()
stop(postDownloadStateRepository.findAll().map { it.postId })
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
log.warn("Some jobs are still running!, forcing shutdown")
executor.shutdownNow()
}
}
fun stopAll(postIds: List<String>?) {
if (postIds != null) {
stop(postIds)
} else {
stop(postDownloadStateRepository.findAll().map(PostDownloadState::postId))
}
}
fun restartAll(postIds: List<String> = listOf()) {
if (postIds.isNotEmpty()) {
restart(postIds)
} else {
restart(postDownloadStateRepository.findAll().map(PostDownloadState::postId))
}
}
private fun restart(postIds: List<String>) {
lock.withLock {
val data: MutableMap<PostDownloadState, Collection<ImageDownloadState>> = mutableMapOf()
for (postId in postIds) {
if (isPending(postId)) {
log.warn("Cannot restart, jobs are currently running for post id $postIds")
continue
}
val imageDownloadStates: List<ImageDownloadState> =
imageRepository.findByPostIdAndIsNotCompleted(postId)
if (imageDownloadStates.isEmpty()) {
continue
}
val postDownloadState: PostDownloadState =
postDownloadStateRepository.findByPostId(postId).orElseThrow()
log.debug("Restarting ${imageDownloadStates.size} jobs for post id $postIds")
postDownloadState.status = Status.PENDING
dataTransaction.update(postDownloadState)
data[postDownloadState] = imageDownloadStates
}
for ((postDownloadState, imageDownloadStates) in data) {
postDownloadState.status = Status.PENDING
dataTransaction.update(postDownloadState)
for (imageDownloadState in imageDownloadStates) {
log.debug("Enqueuing a job for ${imageDownloadState.url}")
with(imageDownloadState) {
this.status = Status.STOPPED
this.current = 0
}
dataTransaction.update(imageDownloadState)
val imageDownloadRunnable = ImageDownloadRunnable(
imageDownloadState.id!!, settingsService.settings
)
pending.computeIfAbsent(
imageDownloadState.host
) { mutableListOf() }
pending[imageDownloadState.host]!!.add(imageDownloadRunnable)
}
}
condition.signal()
}
}
private fun isPending(postId: String): Boolean {
lock.withLock {
return pending.values.flatten().any { it.context.image.postId == postId }
}
}
private fun isRunning(postId: String): Boolean {
lock.withLock {
return running.values.flatten().any { it.context.image.postId == postId }
}
}
private fun stop(postIds: List<String>) {
lock.withLock {
for (postId in postIds) {
val postDownloadState: PostDownloadState =
postDownloadStateRepository.findByPostId(postId).orElseThrow()
pending.values.forEach { pending ->
pending.removeIf { it.context.image.postId == postId }
}
running.values.flatten()
.filter { p: ImageDownloadRunnable -> p.context.image.postId == postId }
.forEach { obj: ImageDownloadRunnable -> obj.stop() }
dataTransaction.stopImagesByPostIdAndIsNotCompleted(postId)
dataTransaction.finishPost(postDownloadState)
}
}
}
private fun canRun(host: Host): Boolean {
val totalRunning = running.values.sumOf { it.size }
return (running[host]!!.size < settingsService.settings.connectionSettings.maxThreads && if (settingsService.settings.connectionSettings.maxTotalThreads == 0) totalRunning < maxPoolSize else totalRunning < settingsService.settings.connectionSettings.maxTotalThreads)
}
private fun candidateCount(): Map<Host, Int> {
val map: MutableMap<Host, Int> = mutableMapOf()
hosts.forEach { h: Host ->
val imageDownloadRunnableList: List<ImageDownloadRunnable> = running.computeIfAbsent(
h
) { mutableListOf() }
val count: Int =
settingsService.settings.connectionSettings.maxThreads - imageDownloadRunnableList.size
log.debug("Download slots for ${h.host}: $count")
map[h] = count
}
return map
}
private fun getCandidates(candidateCount: Map<Host, Int>): List<ImageDownloadRunnable> {
val hostIntegerMap: MutableMap<Host, Int> = candidateCount.toMutableMap()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
hosts@ for (host in pending.keys) {
val list: List<ImageDownloadRunnable> =
pending[host]!!.sortedWith(Comparator.comparingInt<ImageDownloadRunnable> { it.context.post.rank }
.thenComparingInt { it.context.image.index })
for (imageDownloadRunnable in list) {
val count = hostIntegerMap[host] ?: 0
if (count > 0) {
candidates.add(imageDownloadRunnable)
hostIntegerMap[host] = count - 1
} else {
continue@hosts
}
}
}
if (log.isDebugEnabled) {
val collect: Map<Host, List<ImageDownloadRunnable>> =
candidates.stream().collect(Collectors.groupingBy { it.context.image.host })
collect.forEach {
log.debug(
"Candidate download for ${it.key.host} ${it.value.size}/${candidateCount[it.key]}"
)
}
}
return candidates.sortedWith(Comparator.comparing { v: ImageDownloadRunnable -> v.context.post.rank })
}
private fun schedule(imageDownloadRunnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${imageDownloadRunnable.context.image.url}")
executor.execute {
try {
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload())
.onFailure {
try {
eventRepository.save(
LogEvent(
type = LogEvent.Type.DOWNLOAD,
status = LogEvent.Status.ERROR,
message = "Failed to download ${imageDownloadRunnable.context.image.url}\n ${it.failure.formatToString()}"
)
)
} catch (exp: Exception) {
log.error("Failed to save event", exp)
}
log.error(
"Failed to download ${imageDownloadRunnable.context.image.url} after ${it.attemptCount} tries",
it.failure
)
val image = imageDownloadRunnable.context.image
image.status = Status.ERROR
dataTransaction.update(image)
}.onComplete {
afterJobFinish(imageDownloadRunnable)
log.debug(
"Finished downloading ${imageDownloadRunnable.context.image.url}"
)
}.run(imageDownloadRunnable::run)
} catch (ignored: Exception) {
}
}
}
fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
lock.withLock {
running[imageDownloadRunnable.context.image.host]!!.remove(imageDownloadRunnable)
if (!isPending(imageDownloadRunnable.context.image.postId) && !isRunning(
imageDownloadRunnable.context.image.postId
)
) {
dataTransaction.finishPost(imageDownloadRunnable.context.post)
}
condition.signal()
}
}
fun pendingCount(): Int {
return pending.values.sumOf { it.size }
}
fun runningCount(): Int {
return running.values.sumOf { it.size }
}
}
@@ -1,26 +0,0 @@
package me.mnlr.vripper.download
import org.apache.http.client.protocol.HttpClientContext
import me.mnlr.vripper.SpringContext
import me.mnlr.vripper.entities.ImageDownloadState
import me.mnlr.vripper.entities.PostDownloadState
import me.mnlr.vripper.model.Settings
import me.mnlr.vripper.repositories.ImageRepository
import me.mnlr.vripper.repositories.PostDownloadStateRepository
class ImageDownloadContext(val imageId: Long, val settings: Settings) {
private val imageRepository: ImageRepository =
SpringContext.getBean(ImageRepository::class.java)
private val postDownloadStateRepository: PostDownloadStateRepository =
SpringContext.getBean(PostDownloadStateRepository::class.java)
val httpContext: HttpClientContext = HttpClientContext.create()
val postId = image.postIdRef
var stopped = false
val image: ImageDownloadState
get() = imageRepository.findById(imageId).orElseThrow()
val post: PostDownloadState
get() = postDownloadStateRepository.findById(postId).orElseThrow()
}
@@ -1,178 +0,0 @@
package me.mnlr.vripper.download
import me.mnlr.vripper.SpringContext
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.ImageDownloadState
import me.mnlr.vripper.entities.PostDownloadState
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.exception.DownloadException
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.getExtension
import me.mnlr.vripper.host.DownloadedImage
import me.mnlr.vripper.host.ImageMimeType
import me.mnlr.vripper.model.Settings
import me.mnlr.vripper.services.*
import net.jodah.failsafe.function.CheckedRunnable
import org.apache.http.client.protocol.HttpClientContext
import org.apache.http.impl.client.BasicCookieStore
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.*
import kotlin.io.path.pathString
class ImageDownloadRunnable(
private val imageInternalId: Long, private val settings: Settings
) : CheckedRunnable {
private val log by LoggerDelegate()
private val dataTransaction: DataTransaction = SpringContext.getBean(DataTransaction::class.java)
private val pathService: PathService = SpringContext.getBean(PathService::class.java)
val context: ImageDownloadContext = ImageDownloadContext(imageInternalId, settings)
private val image: ImageDownloadState
get() = context.image
private var stopped: Boolean
get() = context.stopped
set(value) {
context.stopped = value
}
private val httpContext: HttpClientContext
get() = context.httpContext
init {
httpContext.cookieStore = BasicCookieStore()
httpContext.setAttribute(
HTTPService.ContextAttributes.CONTEXT_ATTRIBUTES, HTTPService.ContextAttributes()
)
}
@Throws(DownloadException::class)
fun download() {
try {
val image = image
with(image) {
this.status = Status.DOWNLOADING
this.current = 0
}
dataTransaction.update(image)
synchronized(image.postId.intern()) {
val post = context.post
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataTransaction.update(post)
}
}
if (stopped) {
return
}
log.debug("Getting image url and name from ${image.url} using ${image.host}")
val downloadedImage = image.host.downloadInternal(image.url, context)
log.debug("Resolved name for ${image.url}: ${downloadedImage.name}")
log.debug(
"Downloaded image ${image.url} to ${downloadedImage.path}"
)
val sanitizedFileName = pathService.sanitize(downloadedImage.name)
log.debug(
"Sanitizing image name from ${downloadedImage.name} to $sanitizedFileName"
)
checkImageTypeAndRename(
context.post, downloadedImage, image.index
)
} catch (e: Exception) {
if (stopped) {
return
}
throw DownloadException(e)
} finally {
synchronized(image.postId.intern()) {
val image = image
if (image.current == image.total && image.total > 0) {
image.status = Status.FINISHED
val post = context.post
post.done += 1
dataTransaction.update(post)
} else if (stopped) {
image.status = Status.STOPPED
} else {
image.status = Status.ERROR
}
dataTransaction.update(image)
}
}
}
@Throws(HostException::class)
private fun checkImageTypeAndRename(
postDownloadState: PostDownloadState, downloadedImage: DownloadedImage, index: Int
) {
val existingExtension = getExtension(downloadedImage.name)
val extension = when (downloadedImage.type) {
ImageMimeType.IMAGE_BMP -> "BMP"
ImageMimeType.IMAGE_GIF -> "GIF"
ImageMimeType.IMAGE_JPEG -> "JPG"
ImageMimeType.IMAGE_PNG -> "PNG"
ImageMimeType.IMAGE_WEBP -> "WEBP"
}
val filename = if (existingExtension.isBlank()) "${downloadedImage.name}.$extension" else downloadedImage.name
try {
val downloadDestinationFolder = Path.of(postDownloadState.downloadDirectory)
synchronized(downloadDestinationFolder.pathString.intern()) {
Files.createDirectories(downloadDestinationFolder)
}
val image = downloadDestinationFolder.resolve(
"${
if (settings.downloadSettings.forceOrder) String.format(
"%03d_", index + 1
) else ""
}$filename"
)
Files.copy(downloadedImage.path, image, StandardCopyOption.REPLACE_EXISTING)
} catch (e: Exception) {
throw HostException("Failed to rename the image", e)
} finally {
try {
Files.delete(downloadedImage.path)
} catch (e: IOException) {
log.warn(
"Failed to delete temporary file ${downloadedImage.path}"
)
}
}
}
@Throws(Exception::class)
override fun run() {
if (stopped) {
return
}
download()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val that = other as ImageDownloadRunnable
return imageInternalId == that.imageInternalId
}
override fun hashCode(): Int {
return Objects.hash(imageInternalId)
}
fun stop() {
stopped = true
val contextAttributes = httpContext.getAttribute(
HTTPService.ContextAttributes.CONTEXT_ATTRIBUTES, HTTPService.ContextAttributes::class.java
)
if (contextAttributes != null) {
synchronized(contextAttributes.requests) {
for (request in contextAttributes.requests) {
request.abort()
}
}
}
}
}
@@ -1,162 +0,0 @@
package me.mnlr.vripper.download
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.function.CheckedSupplier
import org.apache.http.client.HttpClient
import org.apache.http.client.methods.CloseableHttpResponse
import org.apache.http.client.methods.HttpGet
import org.apache.http.client.protocol.HttpClientContext
import org.apache.http.client.utils.URIBuilder
import org.apache.http.util.EntityUtils
import me.mnlr.vripper.SpringContext
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.entities.LogEvent.Status.*
import me.mnlr.vripper.exception.DownloadException
import me.mnlr.vripper.exception.PostParseException
import me.mnlr.vripper.formatToString
import me.mnlr.vripper.model.PostItem
import me.mnlr.vripper.parser.ThreadLookupAPIResponseHandler
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.services.*
import java.net.URISyntaxException
import java.util.*
import javax.xml.parsers.SAXParserFactory
class PostDownloadRunnable(private val threadId: String, private val postId: String) : Runnable {
private val log by LoggerDelegate()
private val dataTransaction: DataTransaction =
SpringContext.getBean(DataTransaction::class.java)
private val settingsService: SettingsService =
SpringContext.getBean(SettingsService::class.java)
private val vgAuthService: VGAuthService = SpringContext.getBean(VGAuthService::class.java)
private val eventRepository: LogEventRepository =
SpringContext.getBean(LogEventRepository::class.java)
private val cm: HTTPService = SpringContext.getBean(HTTPService::class.java)
private val retryPolicyService: RetryPolicyService =
SpringContext.getBean(RetryPolicyService::class.java)
private val downloadService: DownloadService =
SpringContext.getBean(DownloadService::class.java)
private val link: String =
"${settingsService.settings.viperSettings.host}/threads/$threadId?p=$postId"
private val logEvent: LogEvent
init {
logEvent = eventRepository.save(
LogEvent(
type = LogEvent.Type.POST,
status = PENDING,
message = "Processing $link"
)
)
}
override fun run() {
try {
eventRepository.update(logEvent.copy(status = PROCESSING))
if (dataTransaction.exists(postId)) {
log.warn(String.format("skipping %s, already loaded", postId))
eventRepository.update(
logEvent.copy(
status = ERROR,
message = String.format("Gallery %s is already loaded", link)
)
)
return
}
val postItem: PostItem = try {
parse()
} catch (e: PostParseException) {
val error = String.format("parsing failed for gallery %s", link)
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
return
}
if (postItem.imageItemList.isEmpty()) {
val error = String.format("Gallery %s contains no images to download", link)
log.error(error)
eventRepository.update(logEvent.copy(status = ERROR, message = error))
return
}
val post = dataTransaction.newPost(postItem)
vgAuthService.leaveThanks(post)
// metadataService.startFetchingMetadata(post)
if (settingsService.settings.downloadSettings.autoStart) {
log.debug("Auto start downloads option is enabled")
downloadService.restartAll(listOf(postItem.postId))
log.debug(String.format("Done enqueuing jobs for %s", postItem.url))
}
eventRepository.update(
logEvent.copy(
status = DONE,
message = String.format(
"Gallery %s is successfully added to download queue",
link
)
)
)
} catch (e: Exception) {
val error = String.format("Error when adding gallery %s", link)
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
}
}
@Throws(PostParseException::class)
fun parse(): PostItem {
log.debug("Parsing post $postId")
val httpGet: HttpGet = try {
val uriBuilder = URIBuilder("${settingsService.settings.viperSettings.host}/vr.php")
uriBuilder.setParameter("p", postId)
cm.buildHttpGet(uriBuilder.build(), HttpClientContext.create())
} catch (e: URISyntaxException) {
throw PostParseException(e)
}
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting $httpGet")
return try {
Failsafe.with(retryPolicyService.buildGenericRetryPolicy<Any>()).onFailure {
log.error(
"parsing failed for thread $threadId, post $postId", it.failure
)
}.get(CheckedSupplier {
val connection: HttpClient = cm.client.build()
(connection.execute(
httpGet, vgAuthService.context
) as CloseableHttpResponse).use { response ->
try {
if (response.statusLine.statusCode / 100 != 2) {
throw DownloadException("Unexpected response code '${response.statusLine.statusCode}' for $httpGet")
}
factory.newSAXParser()
.parse(response.entity.content, threadLookupAPIResponseHandler)
threadLookupAPIResponseHandler.result.postItemList[0]
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
})
} catch (e: Exception) {
throw PostParseException(e)
}
}
companion object {
private val factory = SAXParserFactory.newInstance()
}
}
@@ -1,7 +0,0 @@
package me.mnlr.vripper.event
data class Event<T>(val kind: Kind, val data: T?) {
enum class Kind {
POST_UPDATE, POST_REMOVE, IMAGE_UPDATE, METADATA_UPDATE, THREAD_UPDATE, THREAD_REMOVE, LOG_EVENT_UPDATE, LOG_EVENT_REMOVE, VG_USER, DOWNLOAD_STATUS, BYTES_PER_SECOND, SETTINGS_UPDATE, THREAD_CLEAR
}
}
@@ -1,30 +0,0 @@
package me.mnlr.vripper.event
import jakarta.annotation.PreDestroy
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.SignalType
import reactor.core.publisher.Sinks
import reactor.core.publisher.Sinks.EmitFailureHandler
import reactor.core.publisher.Sinks.EmitResult
@Service
class EventBus {
private val sink = Sinks.many().multicast().onBackpressureBuffer<Event<*>>()
fun publishEvent(event: Event<*>) {
sink.emitNext(event, RETRY)
}
fun flux(): Flux<Event<*>> {
return sink.asFlux()
}
@PreDestroy
private fun destroy() {
sink.emitComplete(RETRY)
}
companion object {
val RETRY = EmitFailureHandler { _: SignalType?, _: EmitResult? -> true }
}
}
@@ -1,104 +0,0 @@
package me.mnlr.vripper.host
import org.apache.http.NameValuePair
import org.apache.http.client.HttpClient
import org.apache.http.client.entity.UrlEncodedFormEntity
import org.apache.http.client.methods.CloseableHttpResponse
import org.apache.http.message.BasicNameValuePair
import org.apache.http.util.EntityUtils
import org.springframework.stereotype.Service
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class AcidimgHost(
private val httpService: HTTPService,
private val htmlProcessorService: HtmlProcessorService,
private val xpathService: XpathService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
): Pair<String, String> {
try {
log.debug(
String.format(
"Looking for xpath expression %s in %s",
CONTINUE_BUTTON_XPATH,
url
)
)
xpathService.getAsNode(document, CONTINUE_BUTTON_XPATH)
} catch (e: XpathException) {
throw HostException(e)
}
log.debug(String.format("Click button found for %s", url))
val client: HttpClient = httpService.client.build()
val httpPost = httpService.buildHttpPost(url, context.httpContext)
httpPost.addHeader("Referer", url)
val params: MutableList<NameValuePair> = ArrayList()
params.add(BasicNameValuePair("imgContinue", "Continue to your image"))
try {
httpPost.entity = UrlEncodedFormEntity(params)
} catch (e: Exception) {
throw HostException(e)
}
log.debug(String.format("Requesting %s", httpPost))
val doc = try {
(client.execute(
httpPost, context.httpContext
) as CloseableHttpResponse).use { response ->
log.debug(String.format("Cleaning response for %s", httpPost))
try {
htmlProcessorService.clean(response.entity.content)
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
} catch (e: Exception) {
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim()
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim()
Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
}
companion object {
private const val host = "acidimg.cc"
private const val lookup = "acidimg.cc"
private const val CONTINUE_BUTTON_XPATH = "//input[@id='continuebutton']"
private const val IMG_XPATH = "//img[@class='centred']"
}
}
@@ -1,231 +0,0 @@
package me.mnlr.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.DownloadException
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.getFileNameWithoutExtension
import me.mnlr.vripper.services.*
import org.apache.http.Header
import org.apache.http.client.HttpClient
import org.apache.http.client.methods.CloseableHttpResponse
import org.apache.http.client.protocol.HttpClientContext
import org.apache.http.util.EntityUtils
import org.w3c.dom.Document
import java.nio.file.Files
import java.nio.file.Path
abstract class Host(
private val httpService: HTTPService,
private val htmlProcessorService: HtmlProcessorService,
private val dataTransaction: DataTransaction,
private val downloadSpeedService: DownloadSpeedService
) {
private val log by LoggerDelegate()
companion object {
private const val READ_BUFFER_SIZE = 8192
}
abstract val host: String
@Throws(HostException::class)
abstract fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
): Pair<String, String>
@Throws(HostException::class)
fun downloadInternal(url: String, context: ImageDownloadContext): DownloadedImage {
val headers = head(url, context.httpContext)
// is the body of type image ?
val imageMimeType = getImageMimeType(headers)
val downloadedImage = if (imageMimeType != null) {
// a direct link, awesome
val downloadedImage = fetch(url, context) {
handleImageDownload(it, context)
}
DownloadedImage(getDefaultImageName(url), downloadedImage.first, downloadedImage.second)
} else {
// linked image ?
val value = headers.find { it.name.contains("content-type", true) }?.value
if (value != null) {
if (value.contains("text/html")) {
val document = fetch(url, context) {
htmlProcessorService.clean(it.entity.content)
}
if (log.isDebugEnabled) {
log.debug("Cleaning $url response", url)
}
val resolvedImage = resolve(url, document, context)
val downloadImage: Pair<Path, ImageMimeType> =
fetch(resolvedImage.second, context) {
handleImageDownload(it, context)
}
DownloadedImage(resolvedImage.first, downloadImage.first, downloadImage.second)
} else {
throw HostException("Unable to download $url, can't process content type $value")
}
} else {
throw HostException("Unexpected server response for $url, response have no content type")
}
}
return downloadedImage
}
private fun handleImageDownload(
response: CloseableHttpResponse,
context: ImageDownloadContext
): Pair<Path, ImageMimeType> {
val mimeType = getImageMimeType(response.allHeaders)
?: throw HostException("Unsupported image type ${response.getFirstHeader("content-type")}")
val tempImage = Files.createTempFile(Path.of(context.settings.downloadSettings.tempPath), "vripper_", ".tmp")
return response.entity.content.use { inputStream ->
try {
Files.newOutputStream(tempImage).use { fos ->
val image = context.image
image.total = response.entity.contentLength
dataTransaction.update(image)
log.debug(
"Length is ${image.total}"
)
log.debug(
"Starting data transfer"
)
val buffer = ByteArray(READ_BUFFER_SIZE)
var read: Int
while (inputStream.read(buffer, 0, READ_BUFFER_SIZE)
.also { read = it } != -1 && !context.stopped
) {
fos.write(buffer, 0, read)
with(image) {
current += read
}
downloadSpeedService.increase(read.toLong())
dataTransaction.update(image)
}
Pair(tempImage, mimeType)
}
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
}
fun isSupported(url: String): Boolean {
return url.contains(host)
}
@Throws(HostException::class)
fun head(url: String, context: HttpClientContext): Array<Header> {
val client: HttpClient = httpService.client.build()
val httpGet = httpService.buildHttpHead(url, context)
log.debug(String.format("Requesting %s", url))
return try {
(client.execute(
httpGet,
context
) as CloseableHttpResponse).use { response ->
try {
if (response.statusLine.statusCode / 100 != 2) {
throw HostException(
String.format(
"Unexpected response code: %d", response.statusLine.statusCode
)
)
}
response.allHeaders
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
} catch (e: Exception) {
throw HostException(e)
}
}
@Throws(HostException::class)
fun <T> fetch(
url: String,
context: ImageDownloadContext,
transformer: (CloseableHttpResponse) -> T
): T {
val client: HttpClient = httpService.client.build()
val httpGet = httpService.buildHttpGet(url, context.httpContext)
httpGet.addHeader("Referer", context.image.url)
log.debug(String.format("Requesting %s", url))
return try {
(client.execute(
httpGet,
context.httpContext
) as CloseableHttpResponse).use { response ->
if (response.statusLine.statusCode / 100 != 2) {
EntityUtils.consumeQuietly(response.entity)
throw DownloadException(
"Server returned code ${response.statusLine.statusCode}"
)
}
try {
transformer(response)
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
} catch (e: Exception) {
throw HostException(e)
}
}
private fun getImageMimeType(headers: Array<Header>): ImageMimeType? {
// first check if content type header exists
val value = headers.find { it.name.contains("content-type", true) }?.value
// header found, check the type
return if (value != null) {
ImageMimeType.values().find {
value.contains(it.strValue, true)
}
} else {
null
}
}
fun getDefaultImageName(imgUrl: String): String {
val imageTitle = imgUrl.substring(imgUrl.lastIndexOf('/') + 1)
log.debug(String.format("Extracting name from url %s: %s", imgUrl, imageTitle))
return getFileNameWithoutExtension(imageTitle)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Host
if (host != other.host) return false
return true
}
override fun hashCode(): Int {
return host.hashCode()
}
override fun toString(): String {
return host
}
}
data class DownloadedImage(val name: String, val path: Path, val type: ImageMimeType)
enum class ImageMimeType(val strValue: String) {
IMAGE_BMP("image/bmp"),
IMAGE_GIF("image/gif"),
IMAGE_JPEG("image/jpeg"),
IMAGE_PNG("image/png"),
IMAGE_WEBP("image/webp"),
}
@@ -1,107 +0,0 @@
package me.mnlr.vripper.host
import org.apache.http.NameValuePair
import org.apache.http.client.HttpClient
import org.apache.http.client.entity.UrlEncodedFormEntity
import org.apache.http.client.methods.CloseableHttpResponse
import org.apache.http.client.methods.HttpPost
import org.apache.http.message.BasicNameValuePair
import org.apache.http.util.EntityUtils
import org.springframework.stereotype.Service
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.HtmlProcessorException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import java.io.IOException
@Service
class ImxHost(
private val httpService: HTTPService,
private val htmlProcessorService: HtmlProcessorService,
private val xpathService: XpathService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
): Pair<String, String> {
var value: String? = null
try {
log.debug("Looking for xpath expression $CONTINUE_BUTTON_XPATH in $url")
val contDiv = xpathService.getAsNode(document, CONTINUE_BUTTON_XPATH)
?: throw HostException("$CONTINUE_BUTTON_XPATH cannot be found")
val node = contDiv.attributes.getNamedItem("value")
if (node != null) {
value = node.textContent
}
} catch (e: XpathException) {
throw HostException(e)
}
if (value == null) {
throw HostException("Failed to obtain value attribute from continue input")
}
log.debug("Click button found for $url")
val client: HttpClient = httpService.client.build()
val httpPost: HttpPost = httpService.buildHttpPost(url, context.httpContext)
val params: MutableList<NameValuePair> = ArrayList()
params.add(BasicNameValuePair("imgContinue", value))
try {
httpPost.entity = UrlEncodedFormEntity(params)
} catch (e: Exception) {
throw HostException(e)
}
log.debug("Requesting $httpPost")
val doc = try {
(client.execute(
httpPost, context.httpContext
) as CloseableHttpResponse).use { response ->
log.debug("Cleaning response for $httpPost")
try {
htmlProcessorService.clean(response.entity.content)
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
} catch (e: IOException) {
throw HostException(e)
} catch (e: HtmlProcessorException) {
throw HostException(e)
}
val imgNode: Node = try {
log.debug("Looking for xpath expression $IMG_XPATH in $url")
xpathService.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
"Xpath $IMG_XPATH cannot be found in $url"
)
return try {
log.debug("Resolving name and image url for $url")
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
}
companion object {
private const val host = "imx.to"
private const val lookup = "imx.to"
private const val CONTINUE_BUTTON_XPATH = "//*[@name='imgContinue']"
private const val IMG_XPATH = "//img[@class='centred']"
}
}
@@ -1,44 +0,0 @@
package me.mnlr.vripper.listeners
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent
import org.springframework.context.ApplicationListener
import java.io.RandomAccessFile
import java.nio.file.Files
import kotlin.io.path.Path
import kotlin.io.path.pathString
import kotlin.system.exitProcess
class AppLock : ApplicationListener<ApplicationEnvironmentPreparedEvent> {
override fun onApplicationEvent(event: ApplicationEnvironmentPreparedEvent) {
val baseDir = event.environment.getProperty("base.dir")
val appDirName = event.environment.getProperty("base.dir.name")
if (baseDir == null || appDirName == null) {
if (baseDir == null) {
System.err.println("Property base.dir is undefined")
}
if (appDirName == null) {
System.err.println("Property base.dir.name is undefined")
}
System.err.println("Misconfiguration detected, quitting...")
exitProcess(-1)
}
val lock = Path(baseDir).resolve(appDirName).resolve("lock")
try {
val randomFile = RandomAccessFile(lock.pathString, "rw")
val channel = randomFile.channel
val fileLock = channel.tryLock()
if (fileLock == null) {
System.err.println("Another instance is already running in ${lock.parent.pathString}")
exitProcess(-1)
} else {
Runtime.getRuntime().addShutdownHook(Thread {
fileLock.release()
channel.close()
Files.deleteIfExists(lock)
})
}
} catch (e: Exception) {
println(e.toString())
}
}
}
@@ -1,19 +0,0 @@
package me.mnlr.vripper.listeners
import org.springframework.context.event.ContextRefreshedEvent
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component
import me.mnlr.vripper.repositories.PostDownloadStateRepository
import me.mnlr.vripper.services.DataTransaction
@Component
class EventListenerBean(
private val postDownloadStateRepository: PostDownloadStateRepository,
private val dataTransaction: DataTransaction
) {
@EventListener
fun onApplicationEvent(event: ContextRefreshedEvent?) {
postDownloadStateRepository.setDownloadingToStopped()
dataTransaction.sortPostsByRank()
}
}
@@ -1,19 +0,0 @@
package me.mnlr.vripper.model
data class DownloadSpeed(val speed: String) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadSpeed
if (speed != other.speed) return false
return true
}
override fun hashCode(): Int {
return speed.hashCode()
}
}
@@ -1,33 +0,0 @@
package me.mnlr.vripper.model
data class GlobalState(
val running: Int,
val remaining: Int,
val error: Int,
val loggedUser: String,
val downloadSpeed: String
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as GlobalState
if (running != other.running) return false
if (remaining != other.remaining) return false
if (error != other.error) return false
if (loggedUser != other.loggedUser) return false
if (downloadSpeed != other.downloadSpeed) return false
return true
}
override fun hashCode(): Int {
var result = running
result = 31 * result + remaining
result = 31 * result + error
result = 31 * result + loggedUser.hashCode()
result = 31 * result + downloadSpeed.hashCode()
return result
}
}
@@ -1,79 +0,0 @@
package me.mnlr.vripper.parser
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.function.CheckedSupplier
import org.apache.http.client.HttpClient
import org.apache.http.client.methods.CloseableHttpResponse
import org.apache.http.client.methods.HttpGet
import org.apache.http.client.protocol.HttpClientContext
import org.apache.http.client.utils.URIBuilder
import org.apache.http.util.EntityUtils
import me.mnlr.vripper.SpringContext
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.exception.DownloadException
import me.mnlr.vripper.exception.PostParseException
import me.mnlr.vripper.model.ThreadItem
import me.mnlr.vripper.services.HTTPService
import me.mnlr.vripper.services.RetryPolicyService
import me.mnlr.vripper.services.SettingsService
import me.mnlr.vripper.services.VGAuthService
import java.io.BufferedInputStream
import java.net.URISyntaxException
import java.util.*
import javax.xml.parsers.SAXParserFactory
class ThreadLookupAPIParser(private val threadId: String) {
private val log by LoggerDelegate()
private val cm: HTTPService = SpringContext.getBean(HTTPService::class.java)
private val retryPolicyService: RetryPolicyService =
SpringContext.getBean(RetryPolicyService::class.java)
private val vgAuthService: VGAuthService = SpringContext.getBean(VGAuthService::class.java)
private val settingsService: SettingsService =
SpringContext.getBean(SettingsService::class.java)
@Throws(PostParseException::class)
fun parse(): ThreadItem {
log.debug("Parsing thread $threadId")
val httpGet: HttpGet = try {
val uriBuilder = URIBuilder(settingsService.settings.viperSettings.host + "/vr.php")
uriBuilder.setParameter("t", threadId)
cm.buildHttpGet(uriBuilder.build(), HttpClientContext.create())
} catch (e: URISyntaxException) {
throw PostParseException(e)
}
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting $httpGet")
return try {
Failsafe.with(retryPolicyService.buildGenericRetryPolicy<Any>()).onFailure {
log.error(
"parsing failed for thread $threadId",
it.failure
)
}.get(CheckedSupplier {
val connection: HttpClient = cm.client.build()
(connection.execute(
httpGet, vgAuthService.context
) as CloseableHttpResponse).use { response ->
try {
if (response.statusLine.statusCode / 100 != 2) {
throw DownloadException("Unexpected response code '${response.statusLine.statusCode}' for $httpGet")
}
factory.newSAXParser().parse(
BufferedInputStream(response.entity.content),
threadLookupAPIResponseHandler
)
threadLookupAPIResponseHandler.result
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
})
} catch (e: Exception) {
throw PostParseException(e)
}
}
companion object {
private val factory = SAXParserFactory.newInstance()
}
}
@@ -1,16 +0,0 @@
package me.mnlr.vripper.repositories
import me.mnlr.vripper.entities.ImageDownloadState
import java.util.*
interface ImageRepository {
fun save(imageDownloadState: ImageDownloadState): ImageDownloadState
fun deleteAllByPostId(postId: String)
fun findByPostId(postId: String): List<ImageDownloadState>
fun countError(): Int
fun findByPostIdAndIsNotCompleted(postId: String): List<ImageDownloadState>
fun stopByPostIdAndIsNotCompleted(postId: String): Int
fun findByPostIdAndIsError(postId: String): List<ImageDownloadState>
fun findById(id: Long): Optional<ImageDownloadState>
fun update(imageDownloadState: ImageDownloadState)
}
@@ -1,13 +0,0 @@
package me.mnlr.vripper.repositories
import me.mnlr.vripper.entities.LogEvent
import java.util.*
interface LogEventRepository {
fun save(logEvent: LogEvent): LogEvent
fun update(logEvent: LogEvent): LogEvent
fun findAll(): List<LogEvent>
fun findById(id: Long): Optional<LogEvent>
fun delete(id: Long)
fun deleteAll()
}
@@ -1,10 +0,0 @@
package me.mnlr.vripper.repositories
import me.mnlr.vripper.entities.Metadata
import java.util.*
interface MetadataRepository {
fun save(metadata: Metadata): Metadata
fun findByPostId(postId: String): Optional<Metadata>
fun deleteByPostId(postId: String): Int
}
@@ -1,16 +0,0 @@
package me.mnlr.vripper.repositories
import me.mnlr.vripper.entities.PostDownloadState
import java.util.*
interface PostDownloadStateRepository {
fun save(postDownloadState: PostDownloadState): PostDownloadState
fun findByPostId(postId: String): Optional<PostDownloadState>
fun findById(id: Long): Optional<PostDownloadState>
fun findCompleted(): List<String>
fun findAll(): List<PostDownloadState>
fun existByPostId(postId: String): Boolean
fun setDownloadingToStopped(): Int
fun deleteByPostId(postId: String): Int
fun update(postDownloadState: PostDownloadState)
}
@@ -1,125 +0,0 @@
package me.mnlr.vripper.repositories.impl
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Service
import me.mnlr.vripper.SpringContext
import me.mnlr.vripper.entities.ImageDownloadState
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.host.Host
import me.mnlr.vripper.repositories.ImageRepository
import java.sql.ResultSet
import java.sql.SQLException
import java.util.*
@Service
class ImageRepositoryImpl(
private val jdbcTemplate: JdbcTemplate, private val eventBus: EventBus
) : ImageRepository {
@Synchronized
private fun nextId(): Long {
return jdbcTemplate.queryForObject("CALL NEXT VALUE FOR SEQ_IMAGE", Long::class.java)!!
}
override fun save(imageDownloadState: ImageDownloadState): ImageDownloadState {
val id = nextId()
jdbcTemplate.update(
"INSERT INTO IMAGE (ID, CURRENT, HOST, INDEX, POST_ID, STATUS, TOTAL, URL, POST_ID_REF) VALUES (?,?,?,?,?,?,?,?,?)",
id,
imageDownloadState.current,
imageDownloadState.host.host,
imageDownloadState.index,
imageDownloadState.postId,
imageDownloadState.status.name,
imageDownloadState.total,
imageDownloadState.url,
imageDownloadState.postIdRef
)
imageDownloadState.id = id
eventBus.publishEvent(Event(Event.Kind.IMAGE_UPDATE, id))
return imageDownloadState
}
override fun deleteAllByPostId(postId: String) {
jdbcTemplate.update("DELETE FROM IMAGE WHERE POST_ID = ?", postId)
}
override fun findByPostId(postId: String): List<ImageDownloadState> {
return jdbcTemplate.query(
"SELECT * FROM IMAGE WHERE POST_ID = ?", ImageRowMapper(), postId
)
}
override fun countError(): Int {
return jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM IMAGE AS image WHERE image.STATUS = ?", Int::class.java, Status.ERROR.name
)
}
override fun findByPostIdAndIsNotCompleted(postId: String): List<ImageDownloadState> {
return jdbcTemplate.query(
"SELECT * FROM IMAGE AS image WHERE image.POST_ID = ? AND image.STATUS <> ?",
ImageRowMapper(),
postId,
Status.FINISHED.name
)
}
override fun stopByPostIdAndIsNotCompleted(postId: String): Int {
return jdbcTemplate.update(
"UPDATE IMAGE AS image SET image.STATUS = ? WHERE image.POST_ID = ? AND image.STATUS <> ?",
Status.STOPPED.name,
postId,
Status.FINISHED.name
)
}
override fun findByPostIdAndIsError(postId: String): List<ImageDownloadState> {
return jdbcTemplate.query(
"SELECT * FROM IMAGE AS image WHERE image.POST_ID = ? AND image.STATUS = ?",
ImageRowMapper(),
postId,
Status.ERROR.name
)
}
override fun findById(id: Long): Optional<ImageDownloadState> {
val images = jdbcTemplate.query(
"SELECT * FROM IMAGE AS image WHERE image.ID = ?", ImageRowMapper(), id
)
return if (images.isEmpty()) {
Optional.empty()
} else {
Optional.of(images[0])
}
}
override fun update(imageDownloadState: ImageDownloadState) {
jdbcTemplate.update(
"UPDATE IMAGE AS image SET image.STATUS = ?, image.CURRENT = ?, image.TOTAL = ? WHERE image.ID = ?",
imageDownloadState.status.name,
imageDownloadState.current,
imageDownloadState.total,
imageDownloadState.id
)
eventBus.publishEvent(Event(Event.Kind.IMAGE_UPDATE, imageDownloadState.id))
}
}
internal class ImageRowMapper : RowMapper<ImageDownloadState> {
@Throws(SQLException::class)
override fun mapRow(rs: ResultSet, rowNum: Int): ImageDownloadState {
val id = rs.getLong("ID")
val postId = rs.getString("POST_ID")
val url = rs.getString("URL")
val host = SpringContext.getBeansOfType(Host::class.java).values.filter { it.host == rs.getString("HOST") }[0]
val index = rs.getInt("INDEX")
val current = rs.getLong("CURRENT")
val total = rs.getLong("TOTAL")
val status = Status.valueOf(rs.getString("STATUS"))
val postIdRef = rs.getLong("POST_ID_REF")
return ImageDownloadState(id, postId, url, host, index, postIdRef, total, current, status)
}
}
@@ -1,107 +0,0 @@
package me.mnlr.vripper.repositories.impl
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Service
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.services.SettingsService
import java.sql.ResultSet
import java.sql.SQLException
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
@Service
class LogEventRepositoryImpl(
private val jdbcTemplate: JdbcTemplate,
private val settingsService: SettingsService,
private val eventBus: EventBus
) : LogEventRepository {
private val log by LoggerDelegate()
@Synchronized
private fun nextId(): Long {
return jdbcTemplate.queryForObject("CALL NEXT VALUE FOR SEQ_EVENT", Long::class.java)!!
}
@Synchronized
override fun save(logEvent: LogEvent): LogEvent {
val maxRecords = settingsService.settings.maxEventLog - 1
val count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM EVENT", Long::class.java) ?: 0
if (count > maxRecords) {
val idList = jdbcTemplate.queryForList(
"SELECT ID FROM EVENT ORDER BY TIME ASC LIMIT ?",
Long::class.java,
count - maxRecords
)
idList.forEach { delete(it) }
}
val id = nextId()
jdbcTemplate.update(
"INSERT INTO EVENT (ID, TYPE, STATUS, TIME, MESSAGE) VALUES (?,?,?,?,?)",
id,
logEvent.type.name,
logEvent.status.name,
logEvent.time.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
logEvent.message
)
eventBus.publishEvent(Event(Event.Kind.LOG_EVENT_UPDATE, id))
return logEvent.copy(id = id)
}
override fun update(logEvent: LogEvent): LogEvent {
if (logEvent.id == null) {
log.warn("Cannot update entity with null id")
return logEvent
}
jdbcTemplate.update(
"UPDATE EVENT SET STATUS = ?, MESSAGE = ? WHERE ID = ?",
logEvent.status.name,
logEvent.message,
logEvent.id
)
eventBus.publishEvent(Event(Event.Kind.LOG_EVENT_UPDATE, logEvent.id))
return logEvent
}
override fun findById(id: Long): Optional<LogEvent> {
val logEvents =
jdbcTemplate.query("SELECT * FROM EVENT WHERE ID = ?", LogEventRowMapper(), id)
return if (logEvents.isEmpty()) {
Optional.empty()
} else {
Optional.of(logEvents[0])
}
}
override fun findAll(): List<LogEvent> {
return jdbcTemplate.query("SELECT * FROM EVENT", LogEventRowMapper())
}
override fun delete(id: Long) {
jdbcTemplate.update("DELETE FROM EVENT WHERE ID = ?", id)
eventBus.publishEvent(Event(Event.Kind.LOG_EVENT_REMOVE, id))
}
override fun deleteAll() {
jdbcTemplate.update("DELETE FROM EVENT")
}
}
internal class LogEventRowMapper : RowMapper<LogEvent> {
@Throws(SQLException::class)
override fun mapRow(rs: ResultSet, rowNum: Int): LogEvent {
val id = rs.getLong("ID")
val type = LogEvent.Type.valueOf(rs.getString("TYPE"))
val status = LogEvent.Status.valueOf(rs.getString("STATUS"))
val time =
LocalDateTime.parse(rs.getString("TIME"), DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val message = rs.getString("MESSAGE")
return LogEvent(id, type, status, time, message)
}
}
@@ -1,65 +0,0 @@
package me.mnlr.vripper.repositories.impl
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Service
import me.mnlr.vripper.entities.Metadata
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.repositories.*
import java.sql.ResultSet
import java.sql.SQLException
import java.util.*
@Service
class MetadataRepositoryImpl(
private val jdbcTemplate: JdbcTemplate,
private val eventBus: EventBus
) : MetadataRepository {
override fun save(metadata: Metadata): Metadata {
jdbcTemplate.update(
"INSERT INTO METADATA (POST_ID_REF, POST_ID, POSTED_BY, RESOLVED_NAMES) VALUES (?,?,?,?)",
metadata.postIdRef,
metadata.postId,
metadata.postedBy,
java.lang.String.join("%sep%", metadata.resolvedNames)
)
eventBus.publishEvent(Event(Event.Kind.METADATA_UPDATE, metadata.postIdRef))
return metadata
}
override fun findByPostId(postId: String): Optional<Metadata> {
val metadata = jdbcTemplate.query(
"SELECT metadata.* FROM METADATA AS metadata WHERE metadata.POST_ID = ?",
MetadataRowMapper(),
postId
)
return if (metadata.isEmpty()) {
Optional.empty()
} else {
Optional.of(metadata[0])
}
}
override fun deleteByPostId(postId: String): Int {
return jdbcTemplate.update(
"DELETE FROM METADATA AS metadata WHERE metadata.POST_ID = ?", postId
)
}
}
internal class MetadataRowMapper : RowMapper<Metadata> {
@Throws(SQLException::class)
override fun mapRow(rs: ResultSet, rowNum: Int): Metadata {
val metadata = Metadata()
metadata.postIdRef = rs.getLong("POST_ID_REF")
metadata.postId = rs.getString("POST_ID")
metadata.postedBy = rs.getString("POSTED_BY")
val resolvedNames = rs.getString("RESOLVED_NAMES")
if (resolvedNames != null && resolvedNames.isNotBlank()) {
metadata.resolvedNames =
resolvedNames.split("%sep%").dropLastWhile { it.isEmpty() }
}
return metadata
}
}
@@ -1,163 +0,0 @@
package me.mnlr.vripper.repositories.impl
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Service
import me.mnlr.vripper.entities.PostDownloadState
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.repositories.PostDownloadStateRepository
import java.sql.ResultSet
import java.sql.SQLException
import java.sql.Timestamp
import java.util.*
@Service
class PostDownloadStateRepositoryImpl(
private val jdbcTemplate: JdbcTemplate, private val eventBus: EventBus
) : PostDownloadStateRepository {
@Synchronized
private fun nextId(): Long {
return jdbcTemplate.queryForObject("CALL NEXT VALUE FOR SEQ_POST", Long::class.java)!!
}
override fun save(postDownloadState: PostDownloadState): PostDownloadState {
val id = nextId()
jdbcTemplate.update(
"INSERT INTO POST (ID, DONE, HOSTS, POST_FOLDER_NAME, POST_ID, STATUS, THREAD_ID, POST_TITLE, THREAD_TITLE, FORUM, TOTAL, URL, TOKEN, ADDED_ON, RANK) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
id,
postDownloadState.done,
java.lang.String.join(";", postDownloadState.hosts),
postDownloadState.downloadDirectory,
postDownloadState.postId,
postDownloadState.status.name,
postDownloadState.threadId,
postDownloadState.postTitle,
postDownloadState.threadTitle,
postDownloadState.forum,
postDownloadState.total,
postDownloadState.url,
postDownloadState.token,
Timestamp.valueOf(postDownloadState.addedOn),
postDownloadState.rank
)
eventBus.publishEvent(Event(Event.Kind.POST_UPDATE, id))
return postDownloadState.copy(id = id)
}
override fun findByPostId(postId: String): Optional<PostDownloadState> {
val post = jdbcTemplate.query(
"SELECT metadata.*,post.* FROM METADATA metadata FULL JOIN POST post ON metadata.POST_ID_REF = post.ID WHERE post.POST_ID = ?",
PostRowMapper(),
postId
)
return if (post.isEmpty()) {
Optional.empty()
} else {
Optional.of(post[0])
}
}
override fun findCompleted(): List<String> {
return jdbcTemplate.query(
"SELECT POST_ID FROM POST AS post WHERE status = ? AND done >= total",
{ rs: ResultSet, _: Int -> rs.getString("POST_ID") },
Status.FINISHED.name
)
}
override fun findById(id: Long): Optional<PostDownloadState> {
val post = jdbcTemplate.query(
"SELECT metadata.*,post.* FROM METADATA metadata FULL JOIN POST post ON metadata.POST_ID_REF = post.ID WHERE post.ID = ?",
PostRowMapper(),
id
)
return if (post.isEmpty()) {
Optional.empty()
} else {
Optional.of(post[0])
}
}
override fun findAll(): List<PostDownloadState> {
return jdbcTemplate.query(
"SELECT metadata.*,post.* FROM METADATA metadata FULL JOIN POST post ON metadata.POST_ID_REF = post.ID",
PostRowMapper()
)
}
override fun existByPostId(postId: String): Boolean {
val count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM POST AS post WHERE post.POST_ID = ?", Int::class.java, postId
)
return count > 0
}
override fun setDownloadingToStopped(): Int {
return jdbcTemplate.update(
"UPDATE POST AS post SET post.STATUS = ? WHERE post.STATUS = ? OR post.STATUS = ?",
Status.STOPPED.name,
Status.DOWNLOADING.name,
Status.PENDING.name
)
}
override fun deleteByPostId(postId: String): Int {
val mutationCount = jdbcTemplate.update("DELETE FROM POST AS post WHERE post.POST_ID = ?", postId)
eventBus.publishEvent(Event(Event.Kind.POST_REMOVE, postId))
return mutationCount
}
override fun update(postDownloadState: PostDownloadState) {
jdbcTemplate.update(
"UPDATE POST AS post SET post.STATUS = ?, post.DONE = ?, post.RANK = ? WHERE post.ID = ?",
postDownloadState.status.name,
postDownloadState.done,
postDownloadState.rank,
postDownloadState.id
)
eventBus.publishEvent(Event(Event.Kind.POST_UPDATE, postDownloadState.id))
}
}
internal class PostRowMapper : RowMapper<PostDownloadState> {
@Throws(SQLException::class)
override fun mapRow(rs: ResultSet, rowNum: Int): PostDownloadState {
val id = rs.getLong("post.ID")
val status = Status.valueOf(rs.getString("post.STATUS"))
val postId = rs.getString("post.POST_ID")
val threadId = rs.getString("post.THREAD_ID")
val postTitle = rs.getString("post.POST_TITLE")
val threadTitle = rs.getString("post.THREAD_TITLE")
val forum = rs.getString("post.FORUM")
val url = rs.getString("post.URL")
val token = rs.getString("post.TOKEN")
val done = rs.getInt("post.DONE")
val total = rs.getInt("post.TOTAL")
val hosts = rs.getString("post.HOSTS").split(DELIMITER).dropLastWhile { it.isEmpty() }.toSet()
val downloadDirectory = rs.getString("post.POST_FOLDER_NAME")
val addedOn = rs.getTimestamp("post.ADDED_ON").toLocalDateTime()
val rank = rs.getInt("post.RANK")
// val metadataId = rs.getLong("metadata.POST_ID_REF")
// if (!rs.wasNull()) {
// val metadata = Metadata()
// metadata.postIdRef = metadataId
// metadata.postId = rs.getString("metadata.POST_ID")
// val resolvedNames = rs.getString("metadata.RESOLVED_NAMES")
// if (resolvedNames != null && resolvedNames.isNotBlank()) {
// metadata.resolvedNames =
// resolvedNames.split("%sep%").dropLastWhile { it.isEmpty() }
// }
// metadata.postedBy = rs.getString("metadata.POSTED_BY")
// postDownloadState.metadata = metadata
// }
return PostDownloadState(
id, postTitle, threadTitle, forum, url, token, postId, threadId, total, hosts, downloadDirectory, addedOn, status, done, rank
)
}
companion object {
private const val DELIMITER = ";"
}
}
@@ -1,88 +0,0 @@
package me.mnlr.vripper.repositories.impl
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Service
import me.mnlr.vripper.entities.Thread
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.repositories.ThreadRepository
import java.sql.ResultSet
import java.sql.SQLException
import java.util.*
@Service
class ThreadRepositoryImpl @Autowired constructor(
private val jdbcTemplate: JdbcTemplate,
private val eventBus: EventBus
) : ThreadRepository {
@Synchronized
private fun nextId(): Long {
return jdbcTemplate.queryForObject("CALL NEXT VALUE FOR SEQ_THREAD", Long::class.java)!!
}
override fun save(thread: Thread): Thread {
val id = nextId()
jdbcTemplate.update(
"INSERT INTO THREAD (ID, TOTAL, LINK, THREAD_ID) values (?,?,?,?)",
id,
thread.total,
thread.link,
thread.threadId
)
eventBus.publishEvent(Event(Event.Kind.THREAD_UPDATE, id))
return thread.copy(id = id)
}
override fun findByThreadId(threadId: String): Optional<Thread> {
val threadList = jdbcTemplate.query(
"SELECT * FROM THREAD AS thread WHERE thread.THREAD_ID = ?",
ThreadRowMapper(),
threadId
)
return if (threadList.isEmpty()) {
Optional.empty()
} else {
Optional.of(threadList[0])
}
}
override fun findAll(): List<Thread> {
return jdbcTemplate.query("SELECT * FROM THREAD", ThreadRowMapper())
}
override fun findById(id: Long): Optional<Thread> {
val threadList = jdbcTemplate.query(
"SELECT * FROM THREAD AS thread WHERE thread.ID = ?", ThreadRowMapper(), id
)
return if (threadList.isEmpty()) {
Optional.empty()
} else {
Optional.of(threadList[0])
}
}
override fun deleteByThreadId(threadId: String): Int {
val mutationCount =
jdbcTemplate.update("DELETE FROM THREAD AS thread WHERE THREAD_ID = ?", threadId)
eventBus.publishEvent(Event(Event.Kind.THREAD_REMOVE, threadId))
return mutationCount
}
override fun deleteAll() {
jdbcTemplate.update("DELETE FROM THREAD")
eventBus.publishEvent(Event(Event.Kind.THREAD_CLEAR, null))
}
}
internal class ThreadRowMapper : RowMapper<Thread> {
@Throws(SQLException::class)
override fun mapRow(rs: ResultSet, rowNum: Int): Thread {
val id = rs.getLong("ID")
val link = rs.getString("LINK")
val threadId = rs.getString("THREAD_ID")
val total = rs.getInt("TOTAL")
return Thread(id, link, threadId, total)
}
}
@@ -1,7 +0,0 @@
package me.mnlr.vripper.services
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
@Service
class AppVersion(@param:Value("\${build.version}") val version: String, @param:Value("\${build.timestamp}") val timestamp: String)
@@ -1,18 +0,0 @@
package me.mnlr.vripper.services
import jakarta.annotation.PostConstruct
import org.springframework.stereotype.Service
import reactor.core.publisher.Sinks
import reactor.core.scheduler.Schedulers
@Service
class AsyncTaskRunnerService {
val sink = Sinks.many().unicast().onBackpressureBuffer<Runnable>()
@PostConstruct
fun init() {
sink.asFlux().subscribeOn(Schedulers.parallel()).subscribe {
it.run()
}
}
}
@@ -1,157 +0,0 @@
package me.mnlr.vripper.services
import me.mnlr.vripper.entities.ImageDownloadState
import me.mnlr.vripper.entities.Metadata
import me.mnlr.vripper.entities.PostDownloadState
import me.mnlr.vripper.entities.Thread
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.model.PostItem
import me.mnlr.vripper.repositories.ImageRepository
import me.mnlr.vripper.repositories.MetadataRepository
import me.mnlr.vripper.repositories.PostDownloadStateRepository
import me.mnlr.vripper.repositories.ThreadRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlin.io.path.pathString
@Service
@Transactional
class DataTransaction(
private val postDownloadStateRepository: PostDownloadStateRepository,
private val imageRepository: ImageRepository,
private val threadRepository: ThreadRepository,
private val metadataRepository: MetadataRepository,
private val settingsService: SettingsService,
private val pathService: PathService
) {
private fun save(postDownloadState: PostDownloadState): PostDownloadState {
return postDownloadStateRepository.save(postDownloadState)
}
fun update(postDownloadState: PostDownloadState) {
postDownloadStateRepository.update(postDownloadState)
}
fun save(thread: Thread): Thread {
return threadRepository.save(thread)
}
private fun save(imageDownloadState: ImageDownloadState): ImageDownloadState {
return imageRepository.save(imageDownloadState)
}
fun update(imageDownloadState: ImageDownloadState) {
imageRepository.update(imageDownloadState)
}
fun exists(postId: String): Boolean {
return postDownloadStateRepository.existByPostId(postId)
}
fun newPost(postItem: PostItem): PostDownloadState {
val postDownloadState = save(
PostDownloadState(
postTitle = postItem.title,
url = postItem.url,
token = postItem.securityToken,
postId = postItem.postId,
threadId = postItem.threadId,
total = postItem.imageCount,
hosts = postItem.hosts.map { "${it.first} (${it.second})" }.toSet(),
threadTitle = postItem.threadTitle,
forum = postItem.forum,
downloadDirectory = pathService.calculateDownloadPath(
postItem.forum,
postItem.threadTitle,
postItem.title,
postItem.postId,
settingsService.settings
).pathString
)
)
val images: MutableList<ImageDownloadState> = mutableListOf()
postItem.imageItemList.forEachIndexed { index, imageItem ->
val imageDownloadState = ImageDownloadState(
postId = postItem.postId,
url = imageItem.mainLink,
host = imageItem.host,
index = index,
postIdRef = postDownloadState.id!!
)
images.add(save(imageDownloadState))
}
sortPostsByRank()
return postDownloadState
}
fun finishPost(postDownloadState: PostDownloadState) {
if (imageRepository.findByPostIdAndIsError(postDownloadState.postId).isNotEmpty()) {
postDownloadState.status = Status.ERROR
update(postDownloadState)
} else {
if (postDownloadState.done < postDownloadState.total) {
postDownloadState.status = Status.STOPPED
update(postDownloadState)
} else {
postDownloadState.status = Status.FINISHED
update(postDownloadState)
if (settingsService.settings.downloadSettings.clearCompleted) {
remove(listOf(postDownloadState.postId))
}
}
}
}
private fun remove(postIds: List<String>) {
for (postId in postIds) {
imageRepository.deleteAllByPostId(postId)
metadataRepository.deleteByPostId(postId)
postDownloadStateRepository.deleteByPostId(postId)
}
sortPostsByRank()
}
fun removeThread(threadId: String) {
threadRepository.deleteByThreadId(threadId)
}
fun clearCompleted(): List<String> {
val completed = postDownloadStateRepository.findCompleted()
remove(completed)
return completed
}
fun removeAll(postIds: List<String>?) {
if (postIds != null) {
remove(postIds)
} else {
remove(postDownloadStateRepository.findAll().map(PostDownloadState::postId))
}
}
fun stopImagesByPostIdAndIsNotCompleted(postId: String) {
imageRepository.stopByPostIdAndIsNotCompleted(postId)
}
@Synchronized
fun setMetadata(postDownloadState: PostDownloadState, metadata: Metadata) {
if (metadataRepository.findByPostId(postDownloadState.postId).isEmpty) {
metadata.postIdRef = postDownloadState.id
metadataRepository.save(metadata)
}
}
fun clearQueueLinks() {
threadRepository.deleteAll()
}
@Synchronized
fun sortPostsByRank() {
val postDownloadState =
postDownloadStateRepository.findAll().sortedWith(Comparator.comparing(PostDownloadState::addedOn))
for (i in postDownloadState.indices) {
postDownloadState[i].rank = i
update(postDownloadState[i])
}
}
}
@@ -1,30 +0,0 @@
package me.mnlr.vripper.services
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import java.util.concurrent.atomic.AtomicLong
@Service
class DownloadSpeedService(private val eventBus: EventBus) {
private val read = AtomicLong(0)
var currentValue = 0L
private var allowWrite = false
fun increase(read: Long) {
if (allowWrite) {
this.read.addAndGet(read)
}
}
@Scheduled(fixedDelay = 1000)
private fun calc() {
allowWrite = false
val newValue = read.getAndSet(0)
if (newValue != currentValue) {
currentValue = newValue
eventBus.publishEvent(Event(Event.Kind.BYTES_PER_SECOND, currentValue))
}
allowWrite = true
}
}
@@ -1,44 +0,0 @@
package me.mnlr.vripper.services
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import me.mnlr.vripper.download.DownloadService
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.formatSI
import me.mnlr.vripper.model.GlobalState
import me.mnlr.vripper.repositories.ImageRepository
@Service
class GlobalStateService(
private val downloadService: DownloadService,
private val imageRepository: ImageRepository,
private val eventBus: EventBus,
private val vgAuthService: VGAuthService,
private val downloadSpeedService: DownloadSpeedService
) {
private var currentState: GlobalState = newValue()
@Scheduled(fixedDelay = 200)
private fun interval() {
val newGlobalState = newValue()
if (newGlobalState != currentState) {
currentState = newGlobalState
eventBus.publishEvent(Event(Event.Kind.DOWNLOAD_STATUS, currentState))
}
}
private fun newValue(): GlobalState {
return GlobalState(
downloadService.runningCount(),
downloadService.pendingCount(),
imageRepository.countError(),
vgAuthService.loggedUser,
downloadSpeedService.currentValue.formatSI()
)
}
fun get(): GlobalState {
return currentState.copy()
}
}
@@ -1,131 +0,0 @@
package me.mnlr.vripper.services
import jakarta.annotation.PreDestroy
import org.apache.http.client.config.CookieSpecs
import org.apache.http.client.config.RequestConfig
import org.apache.http.client.methods.AbstractExecutionAwareRequest
import org.apache.http.client.methods.HttpGet
import org.apache.http.client.methods.HttpHead
import org.apache.http.client.methods.HttpPost
import org.apache.http.client.protocol.HttpClientContext
import org.apache.http.impl.client.HttpClientBuilder
import org.apache.http.impl.client.HttpClients
import org.apache.http.impl.client.LaxRedirectStrategy
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import reactor.core.Disposable
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.model.Settings
import java.net.URI
import java.util.concurrent.TimeUnit
@Service
class HTTPService(eventBus: EventBus, settingsService: SettingsService) {
private val disposable: Disposable
private lateinit var pcm: PoolingHttpClientConnectionManager
private lateinit var rc: RequestConfig
private var connectionTimeout: Int = settingsService.settings.connectionSettings.timeout
init {
disposable = eventBus
.flux()
.filter { it.kind == Event.Kind.SETTINGS_UPDATE }
.map { it.data as Settings }
.subscribe {
if (connectionTimeout != it.connectionSettings.timeout) {
connectionTimeout = it.connectionSettings.timeout
buildRequestConfig()
}
}
buildRequestConfig()
buildConnectionPool()
}
@PreDestroy
private fun destroy() {
disposable.dispose()
}
private fun buildConnectionPool() {
pcm = PoolingHttpClientConnectionManager()
pcm.maxTotal = Int.MAX_VALUE
pcm.defaultMaxPerRoute = Int.MAX_VALUE
}
private fun buildRequestConfig() {
rc = RequestConfig.custom()
.setConnectionRequestTimeout(connectionTimeout * 1000)
.setConnectTimeout(connectionTimeout * 1000)
.setSocketTimeout(connectionTimeout * 1000)
.setCookieSpec(CookieSpecs.STANDARD)
.build()
}
@Scheduled(fixedDelay = 15000)
private fun idleConnectionMonitoring() {
pcm.closeIdleConnections(60, TimeUnit.SECONDS)
}
val client: HttpClientBuilder
get() = HttpClients.custom()
.setConnectionManager(pcm)
.setRedirectStrategy(LaxRedirectStrategy())
.disableAutomaticRetries()
.setDefaultRequestConfig(rc)
fun buildHttpGet(url: String, context: HttpClientContext): HttpGet {
val httpGet = HttpGet(url.replace(" ", "+"))
httpGet.addHeader("User-Agent", USER_AGENT)
addToContext(context, httpGet)
return httpGet
}
fun buildHttpHead(url: String, context: HttpClientContext): HttpHead {
val httpHead = HttpHead(url.replace(" ", "+"))
httpHead.addHeader("User-Agent", USER_AGENT)
addToContext(context, httpHead)
return httpHead
}
fun buildHttpPost(url: String, context: HttpClientContext): HttpPost {
val httpPost = HttpPost(url.replace(" ", "+"))
httpPost.addHeader("User-Agent", USER_AGENT)
addToContext(context, httpPost)
return httpPost
}
fun buildHttpGet(uri: URI, context: HttpClientContext): HttpGet {
val httpGet = HttpGet(uri)
httpGet.addHeader("User-Agent", USER_AGENT)
addToContext(context, httpGet)
return httpGet
}
fun addToContext(context: HttpClientContext, request: AbstractExecutionAwareRequest) {
val contextAttributes =
context.getAttribute(
ContextAttributes.CONTEXT_ATTRIBUTES,
ContextAttributes::class.java
)
if (contextAttributes != null) {
synchronized(contextAttributes.requests) {
contextAttributes.requests.add(request)
}
}
}
class ContextAttributes {
val requests: MutableList<AbstractExecutionAwareRequest> = mutableListOf()
companion object {
const val CONTEXT_ATTRIBUTES = "CONTEXT_ATTRIBUTES"
}
}
companion object {
const val 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"
}
}
@@ -1,49 +0,0 @@
package me.mnlr.vripper.services
import org.springframework.stereotype.Service
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.model.Settings
import java.io.File
import java.nio.file.Path
@Service
class PathService {
private val log by LoggerDelegate()
fun calculateDownloadPath(forum: String, threadTitle: String, postTitle: String, postId: String, settings: Settings): Path {
var downloadDirectory =
if (settings.downloadSettings.forumSubfolder) Path.of(settings.downloadSettings.downloadPath, sanitize(forum)) else Path.of(
settings.downloadSettings.downloadPath
)
downloadDirectory = if (settings.downloadSettings.threadSubLocation) downloadDirectory.resolve(threadTitle) else downloadDirectory
downloadDirectory = downloadDirectory.resolve(if (settings.downloadSettings.appendPostId) "${sanitize(postTitle)}_${postId}" else sanitize(
postTitle
))
return downloadDirectory
}
private fun makeDir(sourceFolder: File): File {
var counter = 1
var folder = sourceFolder
while (folder.exists()) {
folder = File(sourceFolder.toString() + '.' + counter++)
}
if (!folder.mkdirs()) {
throw RuntimeException(String.format("Failed to create the folder %s", sourceFolder))
}
return folder
}
/**
* Will sanitize the image name and remove extension
*
* @param path
* @return Sanitized local path string
*/
fun sanitize(path: String): String {
val sanitizedPath =
path.replace("\\.|\\\\|/|\\||:|\\?|\\*|\"|<|>|\\p{Cntrl}".toRegex(), "_")
log.debug(String.format("%s sanitized to %s", path, sanitizedPath))
return sanitizedPath
}
}
@@ -1,54 +0,0 @@
package me.mnlr.vripper.services
import jakarta.annotation.PreDestroy
import net.jodah.failsafe.RetryPolicy
import net.jodah.failsafe.event.ExecutionAttemptedEvent
import org.springframework.stereotype.Service
import reactor.core.Disposable
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.model.Settings
import java.time.temporal.ChronoUnit
@Service
class RetryPolicyService(eventBus: EventBus, settingsService: SettingsService) {
private val log by LoggerDelegate()
private val disposable: Disposable
private var maxAttempts: Int = settingsService.settings.connectionSettings.maxAttempts
init {
disposable = eventBus
.flux()
.filter { it.kind == Event.Kind.SETTINGS_UPDATE }
.map { it.data as Settings }
.subscribe {
if (maxAttempts != it.connectionSettings.maxAttempts) {
maxAttempts = it.connectionSettings.maxAttempts
}
}
}
fun <T> buildRetryPolicyForDownload(): RetryPolicy<T> {
return RetryPolicy<T>()
.withDelay(2, 5, ChronoUnit.SECONDS)
.withMaxAttempts(maxAttempts)
.onFailedAttempt {
log.warn("#${it.attemptCount} tries failed", it.lastFailure)
}
}
fun <T> buildGenericRetryPolicy(): RetryPolicy<T> {
return RetryPolicy<T>()
.withDelay(2, 5, ChronoUnit.SECONDS)
.withMaxAttempts(maxAttempts)
.onFailedAttempt { e: ExecutionAttemptedEvent<T> ->
log.warn("#${e.attemptCount} tries failed", e.lastFailure)
}
}
@PreDestroy
private fun destroy() {
disposable.dispose()
}
}
@@ -1,39 +0,0 @@
package me.mnlr.vripper.services
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import jakarta.annotation.PreDestroy
import org.springframework.stereotype.Service
import reactor.core.Disposable
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.model.ThreadItem
import me.mnlr.vripper.parser.ThreadLookupAPIParser
import java.util.*
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
@Service
class ThreadCacheService(eventBus: EventBus) {
private val cache: LoadingCache<String, ThreadItem> =
Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES)
.build { threadId: String ->
ThreadLookupAPIParser(threadId).parse()
}
private val disposable: Disposable
init {
disposable = eventBus.flux().filter { e: Event<*> -> e.kind == Event.Kind.SETTINGS_UPDATE }
.subscribe { cache.invalidateAll() }
}
@PreDestroy
private fun destroy() {
disposable.dispose()
}
@Throws(ExecutionException::class)
operator fun get(threadId: String): ThreadItem {
return cache[threadId] ?: throw NoSuchElementException("$threadId does not exist")
}
}
@@ -1,151 +0,0 @@
package me.mnlr.vripper.services
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.PostDownloadState
import me.mnlr.vripper.event.Event
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.exception.VripperException
import me.mnlr.vripper.tasks.LeaveThanksRunnable
import org.apache.http.NameValuePair
import org.apache.http.client.entity.UrlEncodedFormEntity
import org.apache.http.client.protocol.HttpClientContext
import org.apache.http.cookie.Cookie
import org.apache.http.impl.client.BasicCookieStore
import org.apache.http.message.BasicNameValuePair
import org.apache.http.util.EntityUtils
import org.springframework.stereotype.Service
import reactor.core.Disposable
@Service
class VGAuthService(
private val cm: HTTPService,
private val settingsService: SettingsService,
private val asyncTaskRunnerService: AsyncTaskRunnerService,
private val eventBus: EventBus
) {
private val log by LoggerDelegate()
private val disposable: Disposable
final val context: HttpClientContext = HttpClientContext.create()
var loggedUser = ""
var authenticated = false
init {
context.cookieStore = BasicCookieStore()
disposable = eventBus
.flux()
.filter { it.kind == Event.Kind.SETTINGS_UPDATE }
.subscribe { authenticate() }
}
@PostConstruct
private fun init() {
authenticate()
}
@PreDestroy
private fun destroy() {
disposable.dispose()
}
fun authenticate() {
authenticated = false
if (!settingsService.settings.viperSettings.login) {
log.debug("Authentication option is disabled")
context.cookieStore.clear()
loggedUser = ""
eventBus.publishEvent(Event(Event.Kind.VG_USER, loggedUser))
return
}
val username = settingsService.settings.viperSettings.username
val password = settingsService.settings.viperSettings.password
if (username.isEmpty() || password.isEmpty()) {
log.error("Cannot authenticate with ViperGirls credentials, username or password is empty")
context.cookieStore.clear()
loggedUser = ""
eventBus.publishEvent(Event(Event.Kind.VG_USER, loggedUser))
return
}
val postAuth =
cm.buildHttpPost(
settingsService.settings.viperSettings.host + "/login.php?do=login",
context
)
val params: MutableList<NameValuePair> = ArrayList()
params.add(BasicNameValuePair("vb_login_username", username))
params.add(BasicNameValuePair("cookieuser", "1"))
params.add(BasicNameValuePair("do", "login"))
params.add(BasicNameValuePair("vb_login_md5password", password))
try {
postAuth.entity = UrlEncodedFormEntity(params)
} catch (e: Exception) {
context.cookieStore.clear()
loggedUser = ""
eventBus.publishEvent(Event(Event.Kind.VG_USER, loggedUser))
log.error(
"Failed to authenticate user with " + settingsService.settings.viperSettings.host,
e
)
return
}
postAuth.addHeader("Referer", settingsService.settings.viperSettings.host)
postAuth.addHeader(
"Host",
settingsService.settings.viperSettings.host.replace("https://", "")
.replace("http://", "")
)
val client = cm.client.build()
try {
client.execute(postAuth, context).use { response ->
if (response.statusLine.statusCode / 100 != 2) {
throw VripperException(
String.format(
"Unexpected response code returned %s", response.statusLine.statusCode
)
)
}
val responseBody = EntityUtils.toString(response.entity)
log.debug(
String.format(
"Authentication with ViperGirls response body:%n%s",
responseBody
)
)
EntityUtils.consumeQuietly(response.entity)
if (context.cookieStore.cookies.stream()
.map { obj: Cookie -> obj.name }
.noneMatch { e: String -> e == "vg_userid" }
) {
log.error(
String.format(
"Failed to authenticate user with %s, missing vg_userid cookie",
settingsService.settings.viperSettings.host
)
)
return
}
}
} catch (e: Exception) {
context.cookieStore.clear()
loggedUser = ""
eventBus.publishEvent(Event(Event.Kind.VG_USER, loggedUser))
log.error(
"Failed to authenticate user with " + settingsService.settings.viperSettings.host,
e
)
return
}
authenticated = true
loggedUser = username
log.info(String.format("Authenticated: %s", username))
eventBus.publishEvent(Event(Event.Kind.VG_USER, loggedUser))
}
fun leaveThanks(post: PostDownloadState) {
asyncTaskRunnerService.sink
.emitNext(LeaveThanksRunnable(post, authenticated, context)) { _, _ -> true }
}
}
@@ -1,118 +0,0 @@
package me.mnlr.vripper.tasks
import org.apache.http.NameValuePair
import org.apache.http.client.entity.UrlEncodedFormEntity
import org.apache.http.client.methods.HttpPost
import org.apache.http.client.protocol.HttpClientContext
import org.apache.http.impl.client.CloseableHttpClient
import org.apache.http.message.BasicNameValuePair
import org.apache.http.util.EntityUtils
import me.mnlr.vripper.SpringContext
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.entities.LogEvent.Status.*
import me.mnlr.vripper.entities.PostDownloadState
import me.mnlr.vripper.formatToString
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.services.HTTPService
import me.mnlr.vripper.services.SettingsService
import java.io.UnsupportedEncodingException
class LeaveThanksRunnable(
private val postDownloadState: PostDownloadState,
private val authenticated: Boolean,
private val context: HttpClientContext
) : Runnable {
private val log by LoggerDelegate()
private val cm: HTTPService = SpringContext.getBean(HTTPService::class.java)
private val settingsService: SettingsService = SpringContext.getBean(SettingsService::class.java)
private val eventRepository: LogEventRepository = SpringContext.getBean(LogEventRepository::class.java)
private val logEvent: LogEvent
init {
logEvent = eventRepository.save(
LogEvent(
type = LogEvent.Type.THANKS, status = PENDING, message = "Leaving thanks for $postDownloadState.url"
)
)
}
override fun run() {
try {
eventRepository.update(logEvent.copy(status = PROCESSING))
if (!settingsService.settings.viperSettings.login) {
eventRepository.update(
logEvent.copy(
status = DONE,
message = "Will not send a like for ${postDownloadState.url}\nAuthentication with ViperGirls option is disabled"
)
)
return
}
if (!settingsService.settings.viperSettings.thanks) {
eventRepository.update(
logEvent.copy(
status = DONE,
message = "Will not send a like for ${postDownloadState.url}\nLeave thanks option is disabled"
)
)
return
}
if (!authenticated) {
eventRepository.update(
logEvent.copy(
status = ERROR,
message = "Will not send a like for ${postDownloadState.url}\nYou are not authenticated"
)
)
return
}
val postThanks: HttpPost = cm.buildHttpPost(
"${settingsService.settings.viperSettings.host}/post_thanks.php", HttpClientContext.create()
)
val params: MutableList<NameValuePair> = ArrayList()
params.add(BasicNameValuePair("do", "post_thanks_add"))
params.add(BasicNameValuePair("using_ajax", "1"))
params.add(BasicNameValuePair("p", postDownloadState.postId))
params.add(BasicNameValuePair("securitytoken", postDownloadState.token))
try {
postThanks.entity = UrlEncodedFormEntity(params)
} catch (e: UnsupportedEncodingException) {
val error = "Request error for ${postDownloadState.url}"
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
return
}
postThanks.addHeader("Referer", settingsService.settings.viperSettings.host)
postThanks.addHeader(
"Host", settingsService.settings.viperSettings.host.replace("https://", "").replace("http://", "")
)
val client: CloseableHttpClient = cm.client.build()
client.execute(postThanks, context).use { response ->
try {
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
eventRepository.update(logEvent.copy(status = DONE))
} catch (e: Exception) {
val error = "Failed to leave a thanks for $postDownloadState"
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
}
}
}
@@ -1,111 +0,0 @@
package me.mnlr.vripper.tasks
import me.mnlr.vripper.AppEndpointService
import me.mnlr.vripper.SpringContext
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.PostDownloadRunnable
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.entities.LogEvent.Status.*
import me.mnlr.vripper.entities.Thread
import me.mnlr.vripper.formatToString
import me.mnlr.vripper.model.Settings
import me.mnlr.vripper.model.ThreadItem
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.repositories.ThreadRepository
import me.mnlr.vripper.services.AsyncTaskRunnerService
import me.mnlr.vripper.services.DataTransaction
import me.mnlr.vripper.services.SettingsService
import me.mnlr.vripper.services.ThreadCacheService
class ThreadLookupRunnable(private val threadId: String, private val settings: Settings) :
Runnable {
private val log by LoggerDelegate()
private val dataTransaction = SpringContext.getBean(DataTransaction::class.java)
private val eventRepository = SpringContext.getBean(LogEventRepository::class.java)
private val settingsService: SettingsService =
SpringContext.getBean(SettingsService::class.java)
private val threadCacheService = SpringContext.getBean(
ThreadCacheService::class.java
)
private val threadRepository = SpringContext.getBean(
ThreadRepository::class.java
)
private val asyncTaskRunnerService = SpringContext.getBean(
AsyncTaskRunnerService::class.java
)
private val appEndpointService = SpringContext.getBean(
AppEndpointService::class.java
)
private val link: String =
"${settingsService.settings.viperSettings.host}/threads/$threadId"
private val logEvent: LogEvent
init {
logEvent = eventRepository.save(
LogEvent(
type = LogEvent.Type.THREAD,
status = LogEvent.Status.PENDING,
message = "Processing multi-post link $link"
)
)
}
override fun run() {
try {
eventRepository.update(logEvent.copy(status = PROCESSING))
val threadLookupResult = threadCacheService[threadId]
if (threadLookupResult.postItemList.isEmpty()) {
val message = "Nothing found for $link"
eventRepository.update(logEvent.copy(status = ERROR, message = message))
return
}
if (threadRepository.findByThreadId(threadId).isEmpty) {
dataTransaction.save(
Thread(
link = link,
threadId = threadId,
total = threadLookupResult.postItemList.size
)
)
eventRepository.update(
logEvent.copy(
status = DONE, message = "New thread $link is added"
)
)
autostart(threadLookupResult)
} else {
log.info("Link $link is already loaded")
eventRepository.update(
logEvent.copy(
status = ERROR,
message = "$link has already been added to the queue"
)
)
}
} catch (e: Exception) {
val error = "Error when adding multi-post link $link"
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
}
}
private fun autostart(lookupResult: ThreadItem) {
if (lookupResult.postItemList.size <= settings.downloadSettings.autoQueueThreshold) {
appEndpointService.threadRemove(listOf(lookupResult.threadId))
lookupResult.postItemList.forEach {
asyncTaskRunnerService.sink.emitNext(
PostDownloadRunnable(
it.threadId, it.postId
)
) { _, _ -> true }
}
}
}
}
@@ -0,0 +1,106 @@
package me.vripper
import me.vripper.download.DownloadService
import me.vripper.event.EventBus
import me.vripper.host.*
import me.vripper.repositories.*
import me.vripper.repositories.impl.*
import me.vripper.services.*
import org.koin.dsl.bind
import org.koin.dsl.module
val coreModule = module {
single<EventBus> {
EventBus
}
single<SettingsService> {
SettingsService(get())
}
single<ImageRepository> {
ImageRepositoryImpl()
}
single<PostDownloadStateRepository> {
PostDownloadStateRepositoryImpl()
}
single<MetadataRepository> {
MetadataRepositoryImpl()
}
single<ThreadRepository> {
ThreadRepositoryImpl()
}
single<LogRepository> {
LogRepositoryImpl(get())
}
single<DataTransaction> {
DataTransaction(get(), get(), get(), get(), get(), get(), get())
}
single<RetryPolicyService> {
RetryPolicyService(get(), get())
}
single<HTTPService> {
HTTPService(get(), get())
}
single<VGAuthService> {
VGAuthService(get(), get(), get())
}
single<ThreadCacheService> {
ThreadCacheService(get())
}
single<DownloadService> {
DownloadService(get(), get(), get(), get(), get())
}
single<DownloadSpeedService> {
DownloadSpeedService(get())
}
single<AppEndpointService> {
AppEndpointService(get(), get(), get(), get())
}
single {
AcidimgHost(get(), get(), get())
} bind Host::class
single {
DPicMeHost(get(), get(), get())
} bind Host::class
single {
ImageBamHost(get(), get(), get())
} bind Host::class
single {
ImageTwistHost(get(), get(), get())
} bind Host::class
single {
ImageVenueHost(get(), get(), get())
} bind Host::class
single {
ImageZillaHost(get(), get(), get())
} bind Host::class
single {
ImgboxHost(get(), get(), get())
} bind Host::class
single {
ImgSpiceHost(get(), get(), get())
} bind Host::class
single {
ImxHost(get(), get(), get())
} bind Host::class
single {
PimpandhostHost(get(), get(), get())
} bind Host::class
single {
PixhostHost(get(), get(), get())
} bind Host::class
single {
PixRouteHost(get(), get(), get())
} bind Host::class
single {
PixxxelsHost(get(), get(), get())
} bind Host::class
single {
PostImgHost(get(), get(), get())
} bind Host::class
single {
TurboImageHost(get(), get(), get())
} bind Host::class
single {
ViprImHost(get(), get(), get())
} bind Host::class
}
@@ -1,4 +1,4 @@
package me.mnlr.vripper.delegate
package me.vripper.delegate
import org.slf4j.Logger
import org.slf4j.LoggerFactory
@@ -0,0 +1,282 @@
package me.vripper.download
import kotlinx.coroutines.Runnable
import me.vripper.entities.Image
import me.vripper.entities.LogEntry
import me.vripper.entities.Post
import me.vripper.entities.domain.Status
import me.vripper.event.ErrorCountEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.host.Host
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.services.DataTransaction
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
import me.vripper.utilities.GLOBAL_EXECUTOR
import me.vripper.utilities.formatToString
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.RetryPolicy
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.concurrent.CompletableFuture
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class DownloadService(
private val settingsService: SettingsService,
private val dataTransaction: DataTransaction,
private val retryPolicyService: RetryPolicyService,
private val vgAuthService: VGAuthService,
private val eventBus: EventBus
) {
private val maxPoolSize: Int = 24
private val log by me.vripper.delegate.LoggerDelegate()
private val running: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val pending: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val lock = ReentrantLock()
private val condition = lock.newCondition()
fun init() {
Thread.ofVirtual().name("Download Loop").unstarted(Runnable {
val accepted: MutableList<ImageDownloadRunnable> = mutableListOf()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
while (!Thread.currentThread().isInterrupted) {
lock.withLock {
candidates.addAll(getCandidates(candidateCount()))
candidates.forEach {
if (canRun(it.context.image.host)) {
accepted.add(it)
running[it.context.image.host]!!.add(it)
log.debug("${it.context.image.url} accepted to run")
}
}
accepted.forEach {
pending[it.context.image.host]?.remove(it)
scheduleForDownload(it)
}
accepted.clear()
candidates.clear()
try {
condition.await()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
}).start()
}
fun stop(postIds: List<Long> = emptyList()) {
if (postIds.isNotEmpty()) {
stopInternal(postIds)
} else {
stopAll()
}
}
fun restartAll(postIds: List<Post> = emptyList()) {
if (postIds.isNotEmpty()) {
restart(postIds.associateWith { dataTransaction.findByPostIdAndIsNotCompleted(it.postId) })
} else {
restart(dataTransaction.findAllPosts()
.associateWith { dataTransaction.findByPostIdAndIsNotCompleted(it.postId) })
}
}
private fun restart(posts: Map<Post, List<Image>>) {
lock.withLock {
val toProcess = mutableMapOf<Post, List<Image>>()
for ((post, images) in posts) {
if (images.isEmpty()) {
continue
}
if (isPending(post.postId)) {
log.warn("Cannot restart, jobs are currently running for post id $posts")
continue
}
toProcess[post] = images
}
toProcess.forEach { (post, images) ->
post.status = Status.PENDING
images.forEach { image ->
with(image) {
this.status = Status.PENDING
this.downloaded = 0
}
}
}
transaction {
dataTransaction.updatePosts(
toProcess.keys.toList()
)
dataTransaction.updateImages(
toProcess.values.flatten()
)
}
toProcess.keys.forEach {
vgAuthService.leaveThanks(it)
}
toProcess.entries.forEach { (post, images) ->
images.forEach { image ->
log.debug("Enqueuing a job for ${image.url}")
val imageDownloadRunnable = ImageDownloadRunnable(
image, post.rank, settingsService.settings
)
pending.computeIfAbsent(
image.host
) { mutableListOf() }
pending[image.host]!!.add(imageDownloadRunnable)
}
}
condition.signal()
}
}
private fun isPending(postId: Long): Boolean {
return pending.values.flatten().any { it.context.image.postId == postId }
}
private fun isRunning(postId: Long): Boolean {
return running.values.flatten().any { it.context.image.postId == postId }
}
private fun stopAll() {
lock.withLock {
pending.values.clear()
running.values.flatten().forEach { obj: ImageDownloadRunnable -> obj.stop() }
dataTransaction.findAllNonCompletedPostIds().forEach {
dataTransaction.stopImagesByPostIdAndIsNotCompleted(it)
dataTransaction.finishPost(it)
}
}
}
private fun stopInternal(postIds: List<Long>) {
lock.withLock {
for (postId in postIds) {
pending.values.forEach { pending ->
pending.removeIf { it.context.image.postId == postId }
}
running.values.flatten().filter { p: ImageDownloadRunnable -> p.context.image.postId == postId }
.forEach { obj: ImageDownloadRunnable -> obj.stop() }
}
postIds.forEach {
dataTransaction.stopImagesByPostIdAndIsNotCompleted(it)
dataTransaction.finishPost(it)
}
}
}
private fun canRun(host: Byte): Boolean {
val totalRunning = running.values.sumOf { it.size }
return (running[host]!!.size < settingsService.settings.connectionSettings.maxConcurrentPerHost && if (settingsService.settings.connectionSettings.maxGlobalConcurrent == 0) totalRunning < maxPoolSize else totalRunning < settingsService.settings.connectionSettings.maxGlobalConcurrent)
}
private fun candidateCount(): Map<Byte, Int> {
val map: MutableMap<Byte, Int> = mutableMapOf()
Host.getHosts().values.forEach { host: Byte ->
val imageDownloadRunnableList: List<ImageDownloadRunnable> = running.computeIfAbsent(
host
) { mutableListOf() }
val count: Int =
settingsService.settings.connectionSettings.maxConcurrentPerHost - imageDownloadRunnableList.size
log.debug("Download slots for $host: $count")
map[host] = count
}
return map
}
private fun getCandidates(candidateCount: Map<Byte, Int>): List<ImageDownloadRunnable> {
val hostIntegerMap: MutableMap<Byte, Int> = candidateCount.toMutableMap()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
hosts@ for (host in pending.keys) {
val list: List<ImageDownloadRunnable> =
pending[host]!!.sortedWith(Comparator.comparingInt<ImageDownloadRunnable> { it.postRank }
.thenComparingInt { it.context.image.index })
for (imageDownloadRunnable in list) {
val count = hostIntegerMap[host] ?: 0
if (count > 0) {
candidates.add(imageDownloadRunnable)
hostIntegerMap[host] = count - 1
} else {
continue@hosts
}
}
}
return candidates
}
private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${imageDownloadRunnable.context.image.url}")
CompletableFuture.runAsync({
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload())
.onFailure {
try {
dataTransaction.saveLog(
LogEntry(
type = LogEntry.Type.DOWNLOAD,
status = LogEntry.Status.ERROR,
message = "Failed to download ${imageDownloadRunnable.context.image.url}\n ${it.failure.formatToString()}"
)
)
} catch (exp: Exception) {
log.error("Failed to save event", exp)
}
log.error(
"Failed to download ${imageDownloadRunnable.context.image.url} after ${it.attemptCount} tries",
it.failure
)
val image = imageDownloadRunnable.context.image
image.status = Status.ERROR
dataTransaction.updateImage(image)
}.onComplete {
afterJobFinish(imageDownloadRunnable)
eventBus.publishEvent(
QueueStateEvent(
QueueState(
runningCount(), pendingCount()
)
)
)
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
log.debug(
"Finished downloading ${imageDownloadRunnable.context.image.url}"
)
}.run(imageDownloadRunnable::run)
}, GLOBAL_EXECUTOR)
}
private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
lock.withLock {
val image = imageDownloadRunnable.context.image
running[image.host]!!.remove(imageDownloadRunnable)
if (!isPending(image.postId) && !isRunning(
image.postId
) && !imageDownloadRunnable.context.stopped
) {
dataTransaction.finishPost(image.postId, true)
}
condition.signal()
}
}
private fun pendingCount(): Int {
return pending.values.sumOf { it.size }
}
private fun runningCount(): Int {
return running.values.sumOf { it.size }
}
}
@@ -0,0 +1,17 @@
package me.vripper.download
import me.vripper.entities.Image
import me.vripper.model.Settings
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
import org.apache.hc.client5.http.cookie.BasicCookieStore
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.koin.core.component.KoinComponent
class ImageDownloadContext(val image: Image, val settings: Settings) : KoinComponent {
val httpContext: HttpClientContext =
HttpClientContext.create().apply { cookieStore = BasicCookieStore() }
val requests = mutableListOf<HttpUriRequestBase>()
val postId = image.postIdRef
var stopped = false
}
@@ -0,0 +1,150 @@
package me.vripper.download
import kotlinx.coroutines.*
import me.vripper.entities.Image
import me.vripper.entities.domain.Status
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.host.DownloadedImage
import me.vripper.host.Host
import me.vripper.host.ImageMimeType
import me.vripper.model.Settings
import me.vripper.services.*
import me.vripper.utilities.PathUtils
import me.vripper.utilities.PathUtils.getExtension
import net.jodah.failsafe.function.CheckedRunnable
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.*
import kotlin.io.path.Path
import kotlin.io.path.pathString
class ImageDownloadRunnable(
private val image: Image, val postRank: Int, private val settings: Settings
) : KoinComponent, CheckedRunnable {
private val log by me.vripper.delegate.LoggerDelegate()
private val dataTransaction: DataTransaction by inject()
private val hosts: List<Host> = getKoin().getAll()
val context: ImageDownloadContext = ImageDownloadContext(image, settings)
private var stopped: Boolean
get() = context.stopped
set(value) {
context.stopped = value
}
@Throws(DownloadException::class)
fun download() {
try {
image.status = Status.DOWNLOADING
image.downloaded = 0
dataTransaction.updateImage(image)
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataTransaction.updatePost(post)
}
}
log.debug("Getting image url and name from ${image.url} using ${image.host}")
val host = hosts.first { it.isSupported(image.url) }
val downloadedImage = host.downloadInternal(image.url, context)
log.debug("Resolved name for ${image.url}: ${downloadedImage.name}")
log.debug("Downloaded image {} to {}", image.url, downloadedImage.path)
val sanitizedFileName = PathUtils.sanitize(downloadedImage.name)
log.debug(
"Sanitizing image name from ${downloadedImage.name} to $sanitizedFileName"
)
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
checkImageTypeAndRename(
downloadDirectory, downloadedImage, image.index
)
if (image.downloaded == image.size && image.size > 0) {
image.status = Status.FINISHED
post.done += 1
post.downloaded += image.size
dataTransaction.updatePost(post)
} else {
image.status = Status.ERROR
}
dataTransaction.updateImage(image)
}
} catch (e: Exception) {
if (stopped) {
return
}
image.status = Status.ERROR
dataTransaction.updateImage(image)
throw DownloadException(e)
}
}
@Throws(HostException::class)
private fun checkImageTypeAndRename(
downloadDirectory: String, downloadedImage: DownloadedImage, index: Int
) {
val existingExtension = getExtension(downloadedImage.name).lowercase()
val extension = when (downloadedImage.type) {
ImageMimeType.IMAGE_BMP -> "BMP"
ImageMimeType.IMAGE_GIF -> "GIF"
ImageMimeType.IMAGE_JPEG -> "JPG"
ImageMimeType.IMAGE_PNG -> "PNG"
ImageMimeType.IMAGE_WEBP -> "WEBP"
}
val filename =
if (existingExtension.isBlank()) "${downloadedImage.name}.$extension" else downloadedImage.name
try {
val downloadDestinationFolder = Path.of(downloadDirectory)
Files.createDirectories(downloadDestinationFolder)
val image = downloadDestinationFolder.resolve(
"${
if (settings.downloadSettings.forceOrder) String.format(
"%03d_", index + 1
) else ""
}$filename"
)
Files.copy(downloadedImage.path, image, StandardCopyOption.REPLACE_EXISTING)
} catch (e: Exception) {
throw HostException("Failed to rename the image", e)
} finally {
try {
Files.delete(downloadedImage.path)
} catch (e: IOException) {
log.warn(
"Failed to delete temporary file ${downloadedImage.path}"
)
}
}
}
@Throws(Exception::class)
override fun run() {
if (stopped) {
return
}
download()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val that = other as ImageDownloadRunnable
return image.id == that.image.id
}
override fun hashCode(): Int {
return Objects.hash(image.id)
}
fun stop() {
context.requests.forEach { it.abort() }
stopped = true
}
}
@@ -1,24 +1,24 @@
package me.mnlr.vripper.entities
package me.vripper.entities
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.host.Host
import me.vripper.entities.domain.Status
data class ImageDownloadState(
var id: Long? = null,
val postId: String,
data class Image(
val id: Long = -1,
val postId: Long,
val url: String,
val host: Host,
val thumbUrl: String,
val host: Byte,
val index: Int,
val postIdRef: Long,
var total: Long = -1,
var current: Long = 0,
val postIdRef: Long = -1,
var size: Long = -1,
var downloaded: Long = 0,
var status: Status = Status.STOPPED,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ImageDownloadState
other as Image
if (url != other.url) return false
@@ -1,13 +1,13 @@
package me.mnlr.vripper.entities
package me.vripper.entities
import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDateTime
data class LogEvent(
val id: Long? = null,
data class LogEntry(
val id: Long = -1,
val type: Type,
val status: Status,
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") val time: LocalDateTime = LocalDateTime.now(),
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") val time: LocalDateTime = LocalDateTime.now(),
val message: String,
) {
@@ -20,4 +20,4 @@ data class LogEvent(
enum class Status(val stringValue: String) {
PENDING("Pending"), PROCESSING("Processing"), DONE("Done"), ERROR("Error")
}
}
}
@@ -1,4 +1,4 @@
package me.mnlr.vripper.entities
package me.vripper.entities
class Metadata {
var postIdRef: Long? = null
@@ -1,35 +1,43 @@
package me.mnlr.vripper.entities
package me.vripper.entities
import com.fasterxml.jackson.annotation.JsonFormat
import me.mnlr.vripper.entities.domain.Status
import me.vripper.entities.domain.Status
import java.time.LocalDateTime
import kotlin.io.path.Path
import kotlin.io.path.pathString
data class PostDownloadState(
var id: Long? = null,
data class Post(
val id: Long = -1,
val postTitle: String,
val threadTitle: String,
val forum: String,
val url: String,
val token: String,
val postId: String,
val threadId: String,
val postId: Long,
val threadId: Long,
val total: Int,
val hosts: Set<String>,
val downloadDirectory: String,
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") val addedOn: LocalDateTime = LocalDateTime.now(),
var folderName: String,
var status: Status = Status.STOPPED,
var done: Int = 0,
var rank: Int = Int.MAX_VALUE
var rank: Int = Int.MAX_VALUE,
var size: Long = -1,
var downloaded: Long = 0,
) {
fun getDownloadFolder(): String {
return Path(downloadDirectory, folderName).pathString
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostDownloadState
other as Post
if (postId != other.postId) return false
return true
return postId == other.postId
}
override fun hashCode(): Int {
@@ -1,9 +1,10 @@
package me.mnlr.vripper.entities
package me.vripper.entities
data class Thread(
val id: Long? = null,
val id: Long = -1,
val title: String,
val link: String,
val threadId: String,
val threadId: Long,
var total: Int = 0,
) {
override fun equals(other: Any?): Boolean {
@@ -1,4 +1,4 @@
package me.mnlr.vripper.entities.domain
package me.vripper.entities.domain
enum class Status(val stringValue: String) {
PENDING("Pending"), DOWNLOADING("Downloading"), FINISHED("Finished"), ERROR("Error"), STOPPED("Stopped")
@@ -0,0 +1,29 @@
package me.vripper.event
import me.vripper.entities.Image
import me.vripper.entities.LogEntry
import me.vripper.entities.Post
import me.vripper.entities.Thread
import me.vripper.model.DownloadSpeed
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.model.Settings
data class PostCreateEvent(val posts: List<Post>)
data class PostUpdateEvent(val posts: List<Post>)
data class PostDeleteEvent(val postIds: List<Long>)
data class ImageEvent(val images: List<Image>)
data class ThreadCreateEvent(val thread: Thread)
data class ThreadDeleteEvent(val threadId: Long)
class ThreadClearEvent
data class VGUserLoginEvent(val username: String)
data class DownloadSpeedEvent(val downloadSpeed: DownloadSpeed)
data class QueueStateEvent(val queueState: QueueState)
data class ErrorCountEvent(val errorCount: ErrorCount)
data class SettingsUpdateEvent(val settings: Settings)
data class LogCreateEvent(val logEntry: LogEntry)
data class LogUpdateEvent(val logEntry: LogEntry)
data class LogDeleteEvent(val deleted: List<Long>)
data class LoadingTasks(val loading: Boolean)
@@ -0,0 +1,17 @@
package me.vripper.event
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import reactor.core.scheduler.Scheduler
import reactor.core.scheduler.Schedulers
import java.util.concurrent.Executors
object EventBus {
private val scheduler: Scheduler = Schedulers.fromExecutor(Executors.newSingleThreadExecutor())
private val _events = Sinks.many().multicast().onBackpressureBuffer<Any>()
val events: Flux<Any> = _events.asFlux().publishOn(scheduler)
fun publishEvent(event: Any) {
_events.emitNext(event) { _, _ -> true }
}
}
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class DownloadException : Exception {
constructor(message: String?) : super(message)
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class HostException : Exception {
constructor(e: Throwable?) : super(e)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class HtmlProcessorException(e: Throwable?) : Exception(e)
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class PostParseException : Exception {
constructor(message: String?) : super(message)
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class QueueException : Exception {
constructor(message: String?) : super(message)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class RenameException(message: String?, e: Exception?) : Exception(message, e)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class ValidationException(message: String) : Exception(message)
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class VripperException : Exception {
constructor(message: String?) : super(message)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class XpathException(e: Throwable?) : Exception(e)
@@ -0,0 +1,86 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.core5.http.message.BasicNameValuePair
import org.w3c.dom.Document
import org.w3c.dom.Node
class AcidimgHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("acidimg.cc", 0, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String, document: Document, context: ImageDownloadContext
): Pair<String, String> {
try {
log.debug(
String.format(
"Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, url
)
)
XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH)
} catch (e: XpathException) {
throw HostException(e)
}
log.debug(String.format("Click button found for %s", url))
val httpPost = HttpPost(url).also {
it.addHeader("Referer", url)
it.entity = UrlEncodedFormEntity(
listOf(
BasicNameValuePair(
"imgContinue",
"Continue to your image"
)
)
)
}.also { context.requests.add(it) }
log.debug(String.format("Requesting %s", httpPost))
val doc = try {
httpService.client.execute(
httpPost, context.httpContext
)
{ response ->
log.debug(String.format("Cleaning response for %s", httpPost))
HtmlUtils.clean(response.entity.content)
}
} catch (e: Exception) {
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'", IMG_XPATH, url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim()
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim()
Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
}
companion object {
private const val CONTINUE_BUTTON_XPATH = "//input[@id='continuebutton']"
private const val IMG_XPATH = "//img[@class='centred']"
}
}
@@ -1,26 +1,20 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import java.util.*
@Service
class DPicMeHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("dpic.me", 1, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -30,7 +24,7 @@ class DPicMeHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -62,8 +56,6 @@ class DPicMeHost(
}
companion object {
private const val host = "dpic.me"
private const val lookup = "dpic.me"
private const val IMG_XPATH = "//img[@id='pic']"
}
}
@@ -0,0 +1,215 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.client5.http.classic.methods.HttpHead
import org.apache.hc.core5.http.ClassicHttpResponse
import org.apache.hc.core5.http.Header
import org.jetbrains.exposed.sql.transactions.transaction
import org.w3c.dom.Document
import java.nio.file.Files
import java.nio.file.Path
abstract class Host(
val hostName: String,
val hostId: Byte,
private val httpService: HTTPService,
private val dataTransaction: DataTransaction,
private val downloadSpeedService: DownloadSpeedService
) {
private val log by me.vripper.delegate.LoggerDelegate()
companion object {
private const val READ_BUFFER_SIZE = 8192
private val hosts: MutableMap<String, Byte> = mutableMapOf()
fun getHosts(): Map<String, Byte> {
return hosts.toMap()
}
}
init {
hosts[hostName] = hostId
}
@Throws(HostException::class)
abstract fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
): Pair<String, String>
@Throws(HostException::class)
fun downloadInternal(url: String, context: ImageDownloadContext): DownloadedImage {
val headers = head(url, context)
// is the body of type image ?
val imageMimeType = getImageMimeType(headers)
val downloadedImage = if (imageMimeType != null) {
// a direct link, awesome
val downloadedImage = fetch(url, context) {
handleImageDownload(it, context)
}
DownloadedImage(getDefaultImageName(url), downloadedImage.first, downloadedImage.second)
} else {
// linked image ?
val value = headers.find { it.name.contains("content-type", true) }?.value
if (value != null) {
if (value.contains("text/html")) {
val document = fetch(url, context) {
HtmlUtils.clean(it.entity.content)
}
if (log.isDebugEnabled) {
log.debug("Cleaning $url response", url)
}
val resolvedImage = resolve(url, document, context)
val downloadImage: Pair<Path, ImageMimeType> =
fetch(resolvedImage.second, context) {
handleImageDownload(it, context)
}
DownloadedImage(resolvedImage.first, downloadImage.first, downloadImage.second)
} else {
throw HostException("Unable to download $url, can't process content type $value")
}
} else {
throw HostException("Unexpected server response for $url, response have no content type")
}
}
return downloadedImage
}
private fun handleImageDownload(
response: ClassicHttpResponse,
context: ImageDownloadContext
): Pair<Path, ImageMimeType> {
val mimeType = getImageMimeType(response.headers)
?: throw HostException("Unsupported image type ${response.getFirstHeader("content-type")}")
val tempImage = Files.createTempFile(
Path.of(context.settings.systemSettings.tempPath),
"vripper_",
".tmp"
)
return Files.newOutputStream(tempImage).use { fos ->
val image = context.image
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
val size = if (image.size < 0) {
response.entity.contentLength
} else {
0
}
image.size = response.entity.contentLength
post.size += size
transaction {
dataTransaction.updateImage(image)
dataTransaction.updatePost(post)
}
}
log.debug(
"Length is ${image.size}"
)
log.debug(
"Starting data transfer"
)
val buffer = ByteArray(READ_BUFFER_SIZE)
var read: Int
while (response.entity.content.read(buffer, 0, READ_BUFFER_SIZE)
.also { read = it } != -1 && !context.stopped
) {
fos.write(buffer, 0, read)
image.downloaded += read
dataTransaction.updateImage(image)
downloadSpeedService.reportDownloadedBytes(read.toLong())
}
Pair(tempImage, mimeType)
}
}
fun isSupported(url: String): Boolean {
return url.contains(hostName)
}
@Throws(HostException::class)
fun head(url: String, context: ImageDownloadContext): Array<Header> {
val httpHead = HttpHead(url).also { context.requests.add(it) }
log.debug(String.format("Requesting %s", url))
return httpService.client.execute(
httpHead,
context.httpContext
) {
if (it.code / 100 != 2) {
throw HostException("Unexpected response code: ${it.code}")
}
it.headers
}
}
@Throws(HostException::class)
fun <T> fetch(
url: String,
context: ImageDownloadContext,
transformer: (ClassicHttpResponse) -> T
): T {
val httpGet = HttpGet(url).also { it.addHeader("Referer", context.image.url) }.also { context.requests.add(it) }
log.debug(String.format("Requesting %s", url))
return httpService.client.execute(httpGet, context.httpContext) {
if (it.code / 100 != 2) {
throw DownloadException("Server returned code ${it.code}")
}
transformer(it)
}
}
private fun getImageMimeType(headers: Array<Header>): ImageMimeType? {
// first check if content type header exists
val value = headers.find { it.name.contains("content-type", true) }?.value
// header found, check the type
return if (value != null) {
ImageMimeType.entries.find {
value.contains(it.strValue, true)
}
} else {
null
}
}
fun getDefaultImageName(imgUrl: String): String {
val imageTitle = imgUrl.substring(imgUrl.lastIndexOf('/') + 1)
log.debug(String.format("Extracting name from url %s: %s", imgUrl, imageTitle))
return getFileNameWithoutExtension(imageTitle)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Host
return hostName == other.hostName
}
override fun hashCode(): Int {
return hostName.hashCode()
}
override fun toString(): String {
return hostName
}
}
data class DownloadedImage(val name: String, val path: Path, val type: ImageMimeType)
enum class ImageMimeType(val strValue: String) {
IMAGE_BMP("image/bmp"),
IMAGE_GIF("image/gif"),
IMAGE_JPEG("image/jpeg"),
IMAGE_PNG("image/png"),
IMAGE_WEBP("image/webp"),
}
@@ -1,30 +1,25 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.apache.http.impl.cookie.BasicClientCookie
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.impl.cookie.BasicClientCookie
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import java.sql.Date
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*
@Service
class ImageBamHost(
private val htmlProcessorService: HtmlProcessorService,
private val xpathService: XpathService,
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("imagebam.com", 2, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -34,7 +29,7 @@ class ImageBamHost(
): Pair<String, String> {
val doc = try {
log.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_XPATH, url))
if (xpathService.getAsNode(document, CONTINUE_XPATH) != null) {
if (XpathUtils.getAsNode(document, CONTINUE_XPATH) != null) {
val clientCookie = BasicClientCookie("nsfw_inter", "1")
clientCookie.domain = "www.imagebam.com"
clientCookie.path = "/"
@@ -44,7 +39,7 @@ class ImageBamHost(
)
context.httpContext.cookieStore.addCookie(clientCookie)
fetch(url, context) {
htmlProcessorService.clean(it.entity.content)
HtmlUtils.clean(it.entity.content)
}
} else {
document
@@ -54,7 +49,7 @@ class ImageBamHost(
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(doc, IMG_XPATH)
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -84,8 +79,6 @@ class ImageBamHost(
}
companion object {
private const val host = "imagebam.com"
private const val lookup = "imagebam.com"
private const val IMG_XPATH = "//img[contains(@class,'main-image')]"
private const val CONTINUE_XPATH = "//*[contains(text(), 'Continue')]"
}
@@ -1,26 +1,20 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import java.util.*
@Service
class ImageTwistHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("imagetwist.com", 3, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -30,7 +24,7 @@ class ImageTwistHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -54,8 +48,6 @@ class ImageTwistHost(
}
companion object {
private const val host = "imagetwist.com"
private const val lookup = "imagetwist.com"
private const val IMG_XPATH = "//img[contains(@class, 'img')]"
}
}
@@ -1,25 +1,20 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class ImageVenueHost(
private val htmlProcessorService: HtmlProcessorService,
private val xpathService: XpathService,
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("imagevenue.com", 4, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -35,10 +30,10 @@ class ImageVenueHost(
url
)
)
if (xpathService.getAsNode(document, CONTINUE_BUTTON_XPATH) != null) {
if (XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH) != null) {
// Button detected. No need to actually click it, just make the call again.
fetch(url, context) {
htmlProcessorService.clean(it.entity.content)
HtmlUtils.clean(it.entity.content)
}
} else {
document
@@ -48,7 +43,7 @@ class ImageVenueHost(
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(doc, IMG_XPATH)
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -72,8 +67,6 @@ class ImageVenueHost(
}
companion object {
private const val host = "imagevenue.com"
private const val lookup = "imagevenue.com"
private const val CONTINUE_BUTTON_XPATH = "//a[@title='Continue to ImageVenue']"
private const val IMG_XPATH = "//a[@data-toggle='full']/img"
}
@@ -1,24 +1,18 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class ImageZillaHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("imagezilla.net", 5, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -28,7 +22,7 @@ class ImageZillaHost(
): Pair<String, String> {
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -52,8 +46,6 @@ class ImageZillaHost(
}
companion object {
private const val host = "imagezilla.net"
private const val lookup = "imagezilla.net/show"
private const val IMG_XPATH = "//img[@id='photo']"
}
}
@@ -1,25 +1,19 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class ImgSpiceHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("imgspice.com", 7, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -29,7 +23,7 @@ class ImgSpiceHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -52,8 +46,6 @@ class ImgSpiceHost(
}
companion object {
private const val host = "imgspice.com"
private const val lookup = "imgspice.com"
private const val IMG_XPATH = "//img[@id='imgpreview']"
}
}
@@ -1,25 +1,19 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class ImgboxHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("imgbox.com", 6, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -29,7 +23,7 @@ class ImgboxHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -50,8 +44,6 @@ class ImgboxHost(
}
companion object {
private const val host = "imgbox.com"
private const val lookup = "imgbox.com"
private const val IMG_XPATH = "//img[@id='img']"
}
}
@@ -0,0 +1,87 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.HtmlProcessorException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.core5.http.message.BasicNameValuePair
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.io.IOException
class ImxHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imx.to", 8, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
): Pair<String, String> {
val httpsUrl = url.replace("http:", "https:")
var value: String? = null
try {
log.debug("Looking for xpath expression $CONTINUE_BUTTON_XPATH in $httpsUrl")
val contDiv = XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH)
?: throw HostException("$CONTINUE_BUTTON_XPATH cannot be found")
val node = contDiv.attributes.getNamedItem("value")
if (node != null) {
value = node.textContent
}
} catch (e: XpathException) {
throw HostException(e)
}
if (value == null) {
throw HostException("Failed to obtain value attribute from continue input")
}
log.debug("Click button found for $httpsUrl")
val httpPost: HttpPost = HttpPost(httpsUrl).also {
it.entity = UrlEncodedFormEntity(listOf(BasicNameValuePair("imgContinue", value)))
}.also { context.requests.add(it) }
log.debug("Requesting {}", httpPost)
val doc = try {
httpService.client.execute(
httpPost, context.httpContext
) { response ->
log.debug("Cleaning response for {}", httpPost)
HtmlUtils.clean(response.entity.content)
}
} catch (e: IOException) {
throw HostException(e)
} catch (e: HtmlProcessorException) {
throw HostException(e)
}
val imgNode: Node = try {
log.debug("Looking for xpath expression $IMG_XPATH in $httpsUrl")
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
"Xpath $IMG_XPATH cannot be found in $httpsUrl"
)
return try {
log.debug("Resolving name and image url for $httpsUrl")
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
}
companion object {
private const val CONTINUE_BUTTON_XPATH = "//*[@name='imgContinue']"
private const val IMG_XPATH = "//img[@class='centred']"
}
}
@@ -1,27 +1,22 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import java.net.URI
import java.net.URISyntaxException
@Service
class PimpandhostHost(
private val xpathService: XpathService,
private val htmlProcessorService: HtmlProcessorService,
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("pimpandhost.com", 9, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -31,16 +26,16 @@ class PimpandhostHost(
): Pair<String, String> {
val newUrl: String
try {
newUrl = appendUri(url.replace("http://", "https://"), "size=original")
newUrl = appendUri(url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""), "size=original")
} catch (e: Exception) {
throw HostException(e)
}
val doc = fetch(newUrl, context) {
htmlProcessorService.clean(it.entity.content)
HtmlUtils.clean(it.entity.content)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, newUrl))
xpathService.getAsNode(doc, IMG_XPATH)
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -78,8 +73,6 @@ class PimpandhostHost(
}
companion object {
private const val host = "pimpandhost.com"
private const val lookup = "pimpandhost.com"
private const val IMG_XPATH = "//img[contains(@class, 'original')]"
}
}
}
@@ -1,25 +1,19 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class PixRouteHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("pixroute.com", 11, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -29,7 +23,7 @@ class PixRouteHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -49,8 +43,6 @@ class PixRouteHost(
}
companion object {
private const val host = "pixroute.com"
private const val lookup = "pixroute.com"
private const val IMG_XPATH = "//img[@id='imgpreview']"
}
}
@@ -1,25 +1,19 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class PixhostHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("pixhost.to", 10, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -29,7 +23,7 @@ class PixhostHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -50,8 +44,6 @@ class PixhostHost(
}
companion object {
private const val host = "pixhost.to"
private const val lookup = "pixhost.to"
private const val IMG_XPATH = "//img[@id='image']"
}
}
@@ -1,24 +1,18 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class PixxxelsHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("pixxxels.cc", 12, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -28,7 +22,7 @@ class PixxxelsHost(
): Pair<String, String> {
val imgNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -40,7 +34,7 @@ class PixxxelsHost(
)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
xpathService.getAsNode(document, TITLE_XPATH)
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -63,8 +57,6 @@ class PixxxelsHost(
}
companion object {
private const val host = "pixxxels.cc"
private const val lookup = "pixxxels.cc"
private const val IMG_XPATH = "//*[@id='download']"
private const val TITLE_XPATH = "//*[contains(@class,'imagename')]"
}
@@ -1,26 +1,20 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import java.util.*
@Service
class PostImgHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("postimg.cc", 13, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -30,7 +24,7 @@ class PostImgHost(
): Pair<String, String> {
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
xpathService.getAsNode(document, TITLE_XPATH)
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -42,7 +36,7 @@ class PostImgHost(
)
val urlNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -64,8 +58,6 @@ class PostImgHost(
}
companion object {
private const val host = "postimg.cc"
private const val lookup = "postimg.cc"
private const val TITLE_XPATH = "//span[contains(@class,'imagename')]"
private const val IMG_XPATH = "//a[@id='download']"
}
@@ -1,25 +1,19 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
@Service
class TurboImageHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String
get() = Companion.host
) : Host("turboimagehost.com", 14, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -30,7 +24,7 @@ class TurboImageHost(
var title: String?
title = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
val titleNode: Node? = xpathService.getAsNode(document, TITLE_XPATH)
val titleNode: Node? = XpathUtils.getAsNode(document, TITLE_XPATH)
log.debug(String.format("Resolving name for %s", url))
titleNode?.textContent?.trim { it <= ' ' }
} catch (e: XpathException) {
@@ -39,7 +33,7 @@ class TurboImageHost(
if (title.isNullOrEmpty()) {
title = getDefaultImageName(url)
}
val urlNode: Node = xpathService.getAsNode(document, IMG_XPATH)
val urlNode: Node = XpathUtils.getAsNode(document, IMG_XPATH)
?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
@@ -51,8 +45,6 @@ class TurboImageHost(
}
companion object {
private const val host = "turboimagehost.com"
private const val lookup = "turboimagehost.com"
private const val TITLE_XPATH = "//div[contains(@class,'titleFullS')]/h1"
private const val IMG_XPATH = "//img[@id='imageid']"
}
@@ -1,25 +1,20 @@
package me.mnlr.vripper.host
package me.vripper.host
import org.springframework.stereotype.Service
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import java.util.*
@Service
class ViprImHost(
private val xpathService: XpathService,
httpService: HTTPService,
htmlProcessorService: HtmlProcessorService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host(httpService, htmlProcessorService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
override val host: String = "vipr.im"
) : Host("vipr.im", 15, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -29,7 +24,7 @@ class ViprImHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
xpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,20 +1,14 @@
package me.mnlr.vripper.listeners
package me.vripper.listeners
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent
import org.springframework.context.ApplicationListener
import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR
import java.io.RandomAccessFile
import java.nio.file.Files
import kotlin.io.path.Path
import kotlin.io.path.pathString
import kotlin.system.exitProcess
class AppListener : ApplicationListener<ApplicationEnvironmentPreparedEvent> {
override fun onApplicationEvent(event: ApplicationEnvironmentPreparedEvent) {
val lock = event.environment.getProperty("base.dir")?.let { Path(it).resolve("lock") }
if(lock == null) {
System.err.println("Misconfiguration detected, quitting...")
exitProcess(-1)
}
object AppLock {
fun exclusiveLock() {
val lock = VRIPPER_DIR.resolve("lock")
try {
val randomFile = RandomAccessFile(lock.pathString, "rw")
val channel = randomFile.channel
@@ -0,0 +1,24 @@
package me.vripper.listeners
import me.vripper.download.DownloadService
import me.vripper.services.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
open class OnStartupListener : KoinComponent {
private val dataTransaction: DataTransaction by inject()
private val downloadService: DownloadService by inject()
private val httpService: HTTPService by inject()
private val retryPolicyService: RetryPolicyService by inject()
private val vgAuthService: VGAuthService by inject()
private val downloadSpeedService: DownloadSpeedService by inject()
open fun run() {
dataTransaction.setDownloadingToStopped()
httpService.init()
retryPolicyService.init()
vgAuthService.init()
downloadService.init()
downloadSpeedService.init()
}
}
@@ -0,0 +1,3 @@
package me.vripper.model
data class DownloadSpeed(val speed: Long)
@@ -0,0 +1,3 @@
package me.vripper.model
data class ErrorCount(val count: Int)
@@ -1,5 +1,5 @@
package me.mnlr.vripper.model
package me.vripper.model
import me.mnlr.vripper.host.Host
import me.vripper.host.Host
data class ImageItem(val mainLink: String, val thumbLink: String, val host: Host)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.model
package me.vripper.model
data class LoggedUser(val user: String)
@@ -1,9 +1,9 @@
package me.mnlr.vripper.model
package me.vripper.model
data class PostItem(
val threadId: String,
val threadId: Long,
val threadTitle: String,
val postId: String,
val postId: Long,
val number: Int,
val title: String,
val imageCount: Int,
@@ -0,0 +1,6 @@
package me.vripper.model
data class QueueState(
val running: Int,
val remaining: Int
)
@@ -1,11 +1,10 @@
package me.mnlr.vripper.model
package me.vripper.model
data class Settings(
var maxEventLog: Int = 1_000,
var connectionSettings: ConnectionSettings = ConnectionSettings(),
var downloadSettings: DownloadSettings = DownloadSettings(),
var viperSettings: ViperSettings = ViperSettings(),
var clipboardSettings: ClipboardSettings = ClipboardSettings()
var systemSettings: SystemSettings = SystemSettings()
)
data class ViperSettings(
@@ -18,21 +17,26 @@ data class ViperSettings(
data class DownloadSettings(
var downloadPath: String = System.getProperty("user.home"),
var tempPath: String = System.getProperty("java.io.tmpdir"),
var autoStart: Boolean = true,
var autoQueueThreshold: Int = 1,
var forceOrder: Boolean = false,
var forumSubfolder: Boolean = false,
var forumSubDirectory: Boolean = false,
var threadSubLocation: Boolean = false,
var clearCompleted: Boolean = false,
var appendPostId: Boolean = false
)
data class ConnectionSettings(
var maxThreads: Int = 2,
var maxTotalThreads: Int = 0,
var timeout: Int = 30,
var maxConcurrentPerHost: Int = 2,
var maxGlobalConcurrent: Int = 0,
var timeout: Long = 30,
var maxAttempts: Int = 3,
)
data class ClipboardSettings(var enable: Boolean = false, var pollingRate: Int = 500)
data class SystemSettings(
var tempPath: String = System.getProperty("java.io.tmpdir"),
var cachePath: String = System.getProperty("java.io.tmpdir"),
var enableClipboardMonitoring: Boolean = false,
var clipboardPollingRate: Int = 500,
var maxEventLog: Int = 1_000,
)
@@ -1,7 +1,7 @@
package me.mnlr.vripper.model
package me.vripper.model
data class ThreadItem(
val threadId: String,
val threadId: Long,
val title: String,
val securityToken: String,
val forum: String,
@@ -0,0 +1,3 @@
package me.vripper.model
data class ThreadPostId(val threadId: Long, val postId: Long)
@@ -0,0 +1,67 @@
package me.vripper.parser
import me.vripper.exception.DownloadException
import me.vripper.exception.PostParseException
import me.vripper.model.ThreadItem
import me.vripper.services.HTTPService
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.function.CheckedSupplier
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.core5.net.URIBuilder
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.BufferedInputStream
import java.util.*
import javax.xml.parsers.SAXParserFactory
class ThreadLookupAPIParser(private val threadId: Long) : KoinComponent {
private val log by me.vripper.delegate.LoggerDelegate()
private val cm: HTTPService by inject()
private val retryPolicyService: RetryPolicyService by inject()
private val vgAuthService: VGAuthService by inject()
private val settingsService: SettingsService by inject()
@Throws(PostParseException::class)
fun parse(): ThreadItem {
log.debug("Parsing thread $threadId")
val httpGet =
HttpGet(URIBuilder(settingsService.settings.viperSettings.host + "/vr.php").also {
it.setParameter(
"t",
threadId.toString()
)
}.build())
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting {}", httpGet)
return try {
Failsafe.with(retryPolicyService.buildGenericRetryPolicy<Any>()).onFailure {
log.error(
"parsing failed for thread $threadId",
it.failure
)
}.get(CheckedSupplier {
cm.client.execute(
httpGet, vgAuthService.context
) { response ->
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
}
factory.newSAXParser().parse(
BufferedInputStream(response.entity.content),
threadLookupAPIResponseHandler
)
threadLookupAPIResponseHandler.result
}
})
} catch (e: Exception) {
throw PostParseException(e)
}
}
companion object {
private val factory = SAXParserFactory.newInstance()
}
}
@@ -1,31 +1,29 @@
package me.mnlr.vripper.parser
package me.vripper.parser
import me.vripper.host.Host
import me.vripper.model.ImageItem
import me.vripper.model.PostItem
import me.vripper.model.ThreadItem
import me.vripper.services.SettingsService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.xml.sax.Attributes
import org.xml.sax.helpers.DefaultHandler
import me.mnlr.vripper.SpringContext
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.host.Host
import me.mnlr.vripper.model.ImageItem
import me.mnlr.vripper.model.PostItem
import me.mnlr.vripper.model.ThreadItem
import me.mnlr.vripper.services.SettingsService
class ThreadLookupAPIResponseHandler : DefaultHandler() {
private val log by LoggerDelegate()
private val supportedHosts: Collection<Host> =
SpringContext.getBeansOfType(Host::class.java).values
private val settingsService: SettingsService =
SpringContext.getBean(SettingsService::class.java)
class ThreadLookupAPIResponseHandler : KoinComponent, DefaultHandler() {
private val log by me.vripper.delegate.LoggerDelegate()
private val supportedHosts: List<Host> = getKoin().getAll()
private val settingsService: SettingsService by inject()
private var error: String = ""
private val hostMap: MutableMap<Host, Int> = mutableMapOf()
private val postItemList: MutableList<PostItem> = mutableListOf()
private val imageItemList: MutableList<ImageItem> = mutableListOf()
private lateinit var threadItem: ThreadItem
private lateinit var threadId: String
private var threadId: Long = -1
private lateinit var threadTitle: String
private lateinit var forum: String
private lateinit var securityToken: String
private lateinit var postId: String
private var postId: Long = -1
private lateinit var postTitle: String
private var postCounter: Int = 0
@@ -39,14 +37,14 @@ class ThreadLookupAPIResponseHandler : DefaultHandler() {
when (qName.lowercase()) {
"error" -> error = attributes.getValue("details")
"thread" -> {
threadId = attributes.getValue("id")?.trim() ?: ""
threadId = attributes.getValue("id")?.trim()?.toLong() ?: -1
threadTitle = attributes.getValue("title")?.trim() ?: ""
}
"forum" -> forum = attributes.getValue("title")?.trim() ?: ""
"user" -> securityToken = attributes.getValue("hash")?.trim() ?: ""
"post" -> {
postId = attributes.getValue("id")?.trim() ?: ""
postId = attributes.getValue("id")?.trim()?.toLong() ?: -1
postCounter = attributes.getValue("number")?.trim()?.toInt() ?: 0
val title = attributes.getValue("title")?.trim() ?: ""
postTitle = title.ifBlank { threadTitle }
@@ -56,18 +54,13 @@ class ThreadLookupAPIResponseHandler : DefaultHandler() {
val mainLink = attributes.getValue("main_url")?.trim() ?: ""
val thumbLink = attributes.getValue("thumb_url")?.trim() ?: ""
val type = attributes.getValue("type")?.trim() ?: ""
if(type == "linked") {
try {
val host = supportedHosts.first {
it.isSupported(mainLink)
}.let { host ->
hostMap.computeIfAbsent(host) { 0 }
hostMap[host] = hostMap[host]!! + 1
host
}
if (type == "linked") {
supportedHosts.firstOrNull {
it.isSupported(mainLink)
}?.also { host ->
hostMap.computeIfAbsent(host) { 0 }
hostMap[host] = hostMap[host]!! + 1
imageItemList.add(ImageItem(mainLink, thumbLink, host))
} catch (e: Exception) {
log.warn("Unsupported link: $mainLink")
}
}
}
@@ -86,7 +79,7 @@ class ThreadLookupAPIResponseHandler : DefaultHandler() {
postTitle,
imageItemList.size,
"${settingsService.settings.viperSettings.host}/threads/?p=$postId&viewfull=1#post$postId",
hostMap.toMap().map { Pair(it.key.host, it.value) },
hostMap.toMap().map { Pair(it.key.hostName, it.value) },
securityToken,
forum,
imageItemList.toList()
@@ -0,0 +1,20 @@
package me.vripper.repositories
import me.vripper.entities.Image
import java.util.*
interface ImageRepository {
fun save(image: Image): Image
fun save(imageList: List<Image>)
fun deleteAllByPostId(postId: Long)
fun findByPostId(postId: Long): List<Image>
fun countError(): Int
fun findByPostIdAndIsNotCompleted(postId: Long): List<Image>
fun stopByPostIdAndIsNotCompleted(postId: Long): Int
fun stopByPostIdAndIsNotCompleted(): Int
fun findByPostIdAndIsError(postId: Long): List<Image>
fun findById(id: Long): Optional<Image>
fun update(image: Image)
fun update(images: List<Image>)
fun deleteAllByPostId(postIds: List<Long>)
}
@@ -0,0 +1,14 @@
package me.vripper.repositories
import me.vripper.entities.LogEntry
import java.util.*
interface LogRepository {
fun save(logEntry: LogEntry): LogEntry
fun update(logEntry: LogEntry)
fun findAll(): List<LogEntry>
fun findById(id: Long): Optional<LogEntry>
fun deleteOldest(): List<Long>
fun deleteAll()
}

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