Compare commits

...
Author SHA1 Message Date
dev-claw 91bb9b8728 fixes #239 2025-09-19 19:12:56 +01:00
dev-claw c9760511b5 version bump 2025-09-19 19:03:27 +01:00
dev-clawandGitHub 59fe4d227f fixes #239 (#265) 2025-09-19 19:02:36 +01:00
dev-clawandGitHub 6df84c0756 fixes #261 (#264) 2025-09-19 18:41:04 +01:00
dev-clawandGitHub aa0119f881 fixes #239 (#263) 2025-09-19 18:29:03 +01:00
dev-clawandGitHub 9283ab7837 fixes #260 (#262) 2025-09-06 14:19:34 +01:00
dev-claw ca77074ef6 version bump 2025-08-19 15:42:07 +01:00
dev-clawandGitHub 3cb251cb36 fixes #250 (#252) 2025-08-19 15:38:08 +01:00
dev-clawandGitHub 73f478c1bc fixes #248 (#249) 2025-08-18 19:46:05 +01:00
dev-claw bb1dfbfee1 version bump 2025-08-18 19:36:51 +01:00
dev-clawandGitHub a89fe8571c fixes #243 (#246) 2025-08-18 19:34:21 +01:00
dev-clawandGitHub 44c8e443f4 fixes #240 (#241) 2025-08-08 15:41:43 +01:00
dev-claw 7ddc5d00b9 fix counter 2025-08-07 15:19:41 +01:00
dev-claw 00039e230b version bump 2025-08-07 14:56:30 +01:00
dev-clawandGitHub f4821aafac fixes #237 (#238) 2025-08-07 14:55:03 +01:00
dev-clawandGitHub d7c8377a71 fixes #231 (#232) 2025-04-15 00:05:27 +01:00
dev-claw 1194faec28 version bump 2025-04-13 19:44:40 +01:00
dev-clawandGitHub 4b789bac8f fixes #228 (#229) 2025-04-13 19:43:28 +01:00
dev-claw 899e6d2893 version bump 2025-03-22 22:52:14 +01:00
dev-clawandGitHub 4c96e79802 fixes #225 (#226) 2025-03-22 22:18:59 +01:00
dev-claw 4a001efa9f version bump 2025-03-21 16:41:49 +01:00
dev-clawandGitHub 7035b1cce0 fixes #223 (#224) 2025-03-21 16:40:38 +01:00
dev-clawandGitHub b9862a5042 fixes #221 (#222) 2025-03-11 16:08:48 +01:00
dev-claw 8bcdb80149 version bump 2025-03-11 16:05:08 +01:00
dev-clawandGitHub e44220321e fixes #219 (#220) 2025-03-11 15:42:41 +01:00
dev-claw f2c9806665 version bump 2025-03-04 18:03:18 +01:00
dev-clawandGitHub fd586a522c fixes #216 (#218) 2025-03-04 18:00:16 +01:00
dev-clawandGitHub 6be744a7a7 Update release.yml 2025-03-02 14:11:22 +01:00
dev-clawandGitHub 6228551836 Update README.md 2024-12-29 12:48:07 +01:00
114 changed files with 2116 additions and 1881 deletions
+81 -40
View File
@@ -8,12 +8,15 @@ jobs:
build:
strategy:
matrix:
os: [ ubuntu-latest, windows-latest, macos-latest ]
os: [ ubuntu-latest, windows-latest, macos-13, macos-latest ]
runs-on: ${{ matrix.os }}
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v3
- name: Set up JDK 21
- name: Set up jdk21
uses: actions/setup-java@v3
with:
java-version: '21'
@@ -21,76 +24,96 @@ jobs:
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
- name: Build GUI Jar
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-with-dependencies.jar vripper-gui/target/vripper-noarch-gui-${{ github.event.release.tag_name }}.jar
- name: Build gui jar
run: |
mvn -N -q install --file pom.xml
mvn -B -q install --file vripper-core/pom.xml
mvn -B -q install --file vripper-gui/pom.xml
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: Release 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
# Start building WEB jar in ubuntu only
- if: matrix.os == 'ubuntu-latest'
name: Build WEB Jar
name: Build web Jar
run: |
mvn -B -q install --file vripper-web-ui/pom.xml
mvn -B -q install --file vripper-web/pom.xml
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: Rename WEB Jar
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: Release 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
# End building WEB jar in ubuntu only
# Start building docker image in ubuntu only
- if: matrix.os == 'ubuntu-latest'
name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- if: matrix.os == 'ubuntu-latest'
name: Build Docker image
run: |
mkdir docker_build && cp Dockerfile docker_build/Dockerfile && cp vripper-web/target/vripper-noarch-web-${{ github.event.release.tag_name }}.jar docker_build/vripper-web.jar
docker build -t ghcr.io/dev-claw/vripper-web:${{ github.event.release.tag_name }} docker_build
docker push ghcr.io/dev-claw/vripper-web:${{ github.event.release.tag_name }}
# End building docker image in ubuntu only
- name: Prepare Packaging
run: |
cp vripper-gui/target/vripper-noarch-gui-${{ github.event.release.tag_name }}.jar jpackage/jar/vripper-gui.jar
- if: matrix.os == 'ubuntu-latest'
name: Package Linux
name: Package for Linux
run: |
cd jpackage
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon icon.png
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-linux.cfg" --type deb
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-linux.cfg" --type rpm
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.png
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-linux.cfg" --resource-dir resources --type deb
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-linux.cfg" --resource-dir resources --type rpm
mv dist/vripper-${{ github.event.release.tag_name }}-1.x86_64.rpm dist/vripper-linux-${{ github.event.release.tag_name }}.x86_64.rpm
mv dist/vripper_${{ github.event.release.tag_name }}-1_amd64.deb dist/vripper-linux-${{ github.event.release.tag_name }}_amd64.deb
- if: matrix.os == 'windows-latest'
name: Package Windows
name: Package for Windows
run: |
cd jpackage
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon icon.ico
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-windows.cfg" --type msi
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-windows.cfg" --type exe
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.ico
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-windows.cfg" --resource-dir resources --type msi
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-windows.cfg" --resource-dir resources --type exe
cd dist
ren VRipper-${{ github.event.release.tag_name }}.msi vripper-windows-installer-${{ github.event.release.tag_name }}.msi
ren VRipper-${{ github.event.release.tag_name }}.exe vripper-windows-installer-${{ github.event.release.tag_name }}.exe
- if: matrix.os == 'macos-latest'
name: Package macOS
name: Package for macOS(arm64)
run: |
cd jpackage
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon icon.icns
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --type pkg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --type dmg
mv dist/VRipper-${{ github.event.release.tag_name }}.pkg dist/vripper-macos-${{ github.event.release.tag_name }}.pkg
mv dist/VRipper-${{ github.event.release.tag_name }}.dmg dist/vripper-macos-${{ github.event.release.tag_name }}.dmg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.icns
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type pkg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type dmg
mv dist/VRipper-${{ github.event.release.tag_name }}.pkg dist/vripper-macos-${{ github.event.release.tag_name }}.arm64.pkg
mv dist/VRipper-${{ github.event.release.tag_name }}.dmg dist/vripper-macos-${{ github.event.release.tag_name }}.arm64.dmg
- if: matrix.os == 'macos-13'
name: Package for macOS(x86_64)
run: |
cd jpackage
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.icns
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type pkg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type dmg
mv dist/VRipper-${{ github.event.release.tag_name }}.pkg dist/vripper-macos-${{ github.event.release.tag_name }}.x86_64.pkg
mv dist/VRipper-${{ github.event.release.tag_name }}.dmg dist/vripper-macos-${{ github.event.release.tag_name }}.x86_64.dmg
- if: matrix.os == 'ubuntu-latest'
name: Zip Ubuntu portable
@@ -111,13 +134,22 @@ jobs:
filename: 'vripper-windows-portable-${{ github.event.release.tag_name }}.zip'
- if: matrix.os == 'macos-latest'
name: Zip MacOS portable
name: Zip macOS(arm64) portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
directory: 'jpackage/dist'
path: 'VRipper.app'
filename: 'vripper-macos-portable-${{ github.event.release.tag_name }}.zip'
filename: 'vripper-macos-portable-${{ github.event.release.tag_name }}.arm64.zip'
- if: matrix.os == 'macos-13'
name: Zip macOS(x86_64) portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
directory: 'jpackage/dist'
path: 'VRipper.app'
filename: 'vripper-macos-portable-${{ github.event.release.tag_name }}.x86_64.zip'
- if: matrix.os == 'ubuntu-latest'
name: Release packages for Linux
@@ -138,10 +170,19 @@ jobs:
jpackage/dist/vripper-windows-portable-${{ github.event.release.tag_name }}.zip
- if: matrix.os == 'macos-latest'
name: Release packages for macOS
name: Release packages for macOS(arm64)
uses: softprops/action-gh-release@v1
with:
files: |
jpackage/dist/vripper-macos-${{ github.event.release.tag_name }}.pkg
jpackage/dist/vripper-macos-${{ github.event.release.tag_name }}.dmg
jpackage/dist/vripper-macos-portable-${{ github.event.release.tag_name }}.zip
jpackage/dist/vripper-macos-${{ github.event.release.tag_name }}.arm64.pkg
jpackage/dist/vripper-macos-${{ github.event.release.tag_name }}.arm64.dmg
jpackage/dist/vripper-macos-portable-${{ github.event.release.tag_name }}.arm64.zip
- if: matrix.os == 'macos-13'
name: Release packages for macOS(x86_64)
uses: softprops/action-gh-release@v1
with:
files: |
jpackage/dist/vripper-macos-${{ github.event.release.tag_name }}.x86_64.pkg
jpackage/dist/vripper-macos-${{ github.event.release.tag_name }}.x86_64.dmg
jpackage/dist/vripper-macos-portable-${{ github.event.release.tag_name }}.x86_64.zip
+4
View File
@@ -0,0 +1,4 @@
FROM eclipse-temurin:21-jre-ubi9-minimal
WORKDIR /opt/vripper-web
ENTRYPOINT ["java", "-jar", "vripper-web.jar"]
ADD vripper-web.jar /opt/vripper-web/vripper-web.jar
+11 -4
View File
@@ -9,16 +9,23 @@ To support this project, you can make a donation to its current maintainer
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/devclaw)
Or with Cryptocurrency
ETH: 0xDdac82B16dC5E3D742fc915ffF583D8548A301cA
BTC: bc1qcqudnkrndwyadsjwrxww42svkf8trnzx3c8vlr
## Requirements
Direct access to `vipergirls.to` domain.
## Installing VRipper
<img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="https://images.vexels.com/media/users/3/166401/isolated/preview/b82aa7ac3f736dd78570dd3fa3fa9e24-java-programming-language-icon-by-vexels.png" width="100%" height="75"> Java
:---:|:---:|:---:|:---:
[Installer (EXE)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-windows-installer-6.3.0.exe) <br /> [Installer (MSI)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-windows-installer-6.3.0.msi) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-windows-portable-6.3.0.zip) | [Installer (DMG)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-macos-6.3.0.dmg) <br /> [Installer (PKG)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-macos-6.3.0.pkg) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-macos-portable-6.3.0.zip) | [Linux (amd64) (DEB)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-linux-6.3.0_amd64.deb) <br /> [Linux (x86_64) (RPM)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-linux-6.3.0.x86_64.rpm) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-linux-portable-6.3.0.zip) | [Java GUI (noarch)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-noarch-gui-6.3.0.jar) <br /> [Java Web (noarch)](https://github.com/death-claw/vripper-project/releases/download/6.3.0/vripper-noarch-web-6.3.0.jar)
<img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS (Intel) | <img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS (Apple silicon) | <img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="https://images.vexels.com/media/users/3/166401/isolated/preview/b82aa7ac3f736dd78570dd3fa3fa9e24-java-programming-language-icon-by-vexels.png" width="100%" height="75"> Java
:---:|:---:|:---:|:---:|:---:
[Installer (EXE)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-windows-installer-6.5.7.exe) <br /> [Installer (MSI)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-windows-installer-6.5.7.msi) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-windows-portable-6.5.7.zip) | [Installer (DMG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-macos-6.5.7.x86_64.dmg) <br /> [Installer (PKG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-macos-6.5.7.x86_64.pkg) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-macos-portable-6.5.7.x86_64.zip) | [Installer (DMG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-macos-6.5.7.arm64.dmg) <br /> [Installer (PKG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-macos-6.5.7.arm64.pkg) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-macos-portable-6.5.7.arm64.zip) | [Linux (amd64) (DEB)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-linux-6.5.3_amd64.deb) <br /> [Linux (x86_64) (RPM)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-linux-6.5.7.x86_64.rpm) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-linux-portable-6.5.7.zip) | [Java GUI (noarch)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-noarch-gui-6.5.7.jar) <br /> [Java Web (noarch)](https://github.com/dev-claw/vripper-project/releases/download/6.5.7/vripper-noarch-web-6.5.7.jar)
Source code and previous versions are available on the [Releases page](https://github.com/death-claw/vripper-project/releases).
Source code and previous versions are available on
the [Releases page](https://github.com/dev-claw/vripper-project/releases).
Application data (application logs, settings and persisted data) is stored in:
* Windows --> `C:\USERS\<your Windows username>\vripper`
-1
View File
@@ -1,4 +1,3 @@
--icon icon.png
--java-options "-Dvripper.portable=false"
--linux-package-name vripper
--linux-app-release 1
-1
View File
@@ -1,4 +1,3 @@
--icon icon.icns
--java-options "-Dvripper.portable=false"
--mac-package-identifier me.mnlr.vripper.vripper-gui
--mac-package-name VRipper
+1 -2
View File
@@ -1,8 +1,7 @@
--icon icon.ico
--java-options "-Dvripper.portable=false"
--win-dir-chooser
--win-menu
--win-per-user-install
--win-shortcut
--win-shortcut-prompt
--win-update-url https://github.com/death-claw/vripper-project/releases
--win-update-url https://github.com/dev-claw/vripper-project/releases
+1 -1
View File
@@ -5,4 +5,4 @@
--name VRipper
--description "Image ripper tool for vipergirls"
--dest dist
--vendor "death-claw"
--vendor "dev-claw"
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=VRipper
Comment=Image ripper tool for vipergirls
Exec=/opt/vripper/bin/VRipper
Icon=/opt/vripper/lib/VRipper.png
Terminal=false
Type=Application
Categories=Utility
MimeType=
StartupWMClass=me.vripper.gui.VripperGuiApplication

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+7 -7
View File
@@ -15,7 +15,7 @@
<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>6.3.0</revision>
<revision>6.5.7</revision>
<kotlin.version>2.0.21</kotlin.version>
<slf4j.version>2.0.16</slf4j.version>
<logback.version>1.5.12</logback.version>
@@ -31,13 +31,13 @@
<kotlinx-serialization-json.version>1.7.3</kotlinx-serialization-json.version>
<httpclient5.version>5.4</httpclient5.version>
<httpcore5.version>5.3</httpcore5.version>
<commons-codec.version>1.17.1</commons-codec.version>
<liquibase-core.version>4.29.2</liquibase-core.version>
<liquibase-slf4j.version>5.0.0</liquibase-slf4j.version>
<grpc.kotlin.version>1.4.1</grpc.kotlin.version>
<java.grpc.version>1.68.1</java.grpc.version>
<protobuf.version>3.25.5</protobuf.version>
<sqlite-jdbc.version>3.47.0.0</sqlite-jdbc.version>
<bucket4j_jdk17-core.version>8.14.0</bucket4j_jdk17-core.version>
</properties>
<build>
<plugins>
@@ -186,11 +186,6 @@
<groupId>org.apache.httpcomponents.core5</groupId>
<version>${httpcore5.version}</version>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
<version>${commons-codec.version}</version>
</dependency>
<dependency>
<artifactId>liquibase-core</artifactId>
<groupId>org.liquibase</groupId>
@@ -221,6 +216,11 @@
<artifactId>protobuf-kotlin</artifactId>
<version>${protobuf.version}</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>${bucket4j_jdk17-core.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
+6 -6
View File
@@ -75,10 +75,6 @@
<artifactId>caffeine</artifactId>
<groupId>com.github.ben-manes.caffeine</groupId>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
@@ -108,6 +104,10 @@
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-kotlin</artifactId>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
</dependency>
</dependencies>
<build>
@@ -136,14 +136,14 @@
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
<configuration>
<jvmTarget>1.8</jvmTarget>
<jvmTarget>21</jvmTarget>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
@@ -8,7 +8,6 @@ import me.vripper.data.repositories.impl.ImageRepositoryImpl
import me.vripper.data.repositories.impl.MetadataRepositoryImpl
import me.vripper.data.repositories.impl.PostRepositoryImpl
import me.vripper.data.repositories.impl.ThreadRepositoryImpl
import me.vripper.download.DownloadService
import me.vripper.event.EventBus
import me.vripper.host.*
import me.vripper.services.*
@@ -42,19 +41,19 @@ val coreModule = module {
DataTransaction(get(), get(), get(), get(), get(), get())
}
single<RetryPolicyService> {
RetryPolicyService(get(), get())
RetryPolicyService()
}
single<HTTPService> {
HTTPService(get(), get())
HTTPService()
}
single<VGAuthService> {
VGAuthService(get(), get(), get())
VGAuthService(get(), get())
}
single<ThreadCacheService> {
ThreadCacheService(get())
}
single<DownloadService> {
DownloadService(get(), get(), get(), get(), get())
DownloadService(get(), get(), get(), get())
}
single<DownloadSpeedService> {
DownloadSpeedService(get())
@@ -69,54 +68,54 @@ val coreModule = module {
} bind IAppEndpointService::class
single<MetadataService> {
MetadataService(get(), get(), get(), get())
MetadataService(get(), get())
}
single {
AcidimgHost(get(), get(), get())
AcidimgHost(get())
} bind Host::class
single {
DPicMeHost(get(), get(), get())
DPicMeHost()
} bind Host::class
single {
ImageBamHost(get(), get(), get())
ImageBamHost()
} bind Host::class
single {
ImageTwistHost(get(), get(), get())
ImageTwistHost()
} bind Host::class
single {
ImageVenueHost(get(), get(), get())
ImageVenueHost()
} bind Host::class
single {
ImageZillaHost(get(), get(), get())
ImageZillaHost()
} bind Host::class
single {
ImgboxHost(get(), get(), get())
ImgboxHost()
} bind Host::class
single {
ImgSpiceHost(get(), get(), get())
ImgSpiceHost()
} bind Host::class
single {
ImxHost(get(), get(), get())
ImxHost()
} bind Host::class
single {
PimpandhostHost(get(), get(), get())
PimpandhostHost()
} bind Host::class
single {
PixhostHost(get(), get(), get())
PixhostHost()
} bind Host::class
single {
PixRouteHost(get(), get(), get())
PixRouteHost()
} bind Host::class
single {
PixxxelsHost(get(), get(), get())
PixxxelsHost()
} bind Host::class
single {
PostImgHost(get(), get(), get())
PostImgHost()
} bind Host::class
single {
TurboImageHost(get(), get(), get())
TurboImageHost()
} bind Host::class
single {
ViprImHost(get(), get(), get())
ViprImHost()
} bind Host::class
}
@@ -1,29 +0,0 @@
package me.vripper.download
import kotlinx.coroutines.*
import me.vripper.entities.ImageEntity
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
internal class ImageDownloadContext(val imageEntity: ImageEntity, val settings: Settings) : KoinComponent {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val jobs = mutableListOf<Job>()
val httpContext: HttpClientContext =
HttpClientContext.create().apply { cookieStore = BasicCookieStore() }
val requests = mutableListOf<HttpUriRequestBase>()
val postId = imageEntity.postIdRef
fun cancelCoroutines() {
runBlocking {
coroutineScope.cancel()
jobs.forEach { job -> job.cancelAndJoin() }
}
}
fun launchCoroutine(block: suspend CoroutineScope.() -> Unit): Job {
return coroutineScope.launch(block = block).also { job -> jobs.add(job) }
}
}
@@ -1,152 +0,0 @@
package me.vripper.download
import dev.failsafe.function.CheckedRunnable
import me.vripper.entities.ImageEntity
import me.vripper.entities.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.DataTransaction
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils.getExtension
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
import me.vripper.utilities.PathUtils.sanitize
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.Throws
import kotlin.io.path.Path
import kotlin.io.path.pathString
internal class ImageDownloadRunnable(
val imageEntity: ImageEntity, val postRank: Int, private val settings: Settings
) : KoinComponent, CheckedRunnable {
private val log by LoggerDelegate()
private val dataTransaction: DataTransaction by inject()
private val hosts: List<Host> = getKoin().getAll()
var completed = false
var stopped = false
private lateinit var context: ImageDownloadContext
fun download() {
try {
imageEntity.status = Status.DOWNLOADING
imageEntity.downloaded = 0
dataTransaction.updateImage(imageEntity)
synchronized(imageEntity.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId)
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataTransaction.updatePost(post)
}
}
log.debug("Getting image url and name from ${imageEntity.url} using ${imageEntity.host}")
val host = hosts.first { it.isSupported(imageEntity.url) }
val downloadedImage = host.downloadInternal(imageEntity.url, context)
log.debug("Resolved name for ${imageEntity.url}: ${downloadedImage.name}")
log.debug("Downloaded image {} to {}", imageEntity.url, downloadedImage.path)
synchronized(imageEntity.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId)
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
checkImageTypeAndRename(
downloadDirectory, downloadedImage, imageEntity.index
)
if (imageEntity.downloaded == imageEntity.size && imageEntity.size > 0) {
imageEntity.status = Status.FINISHED
post.done += 1
post.downloaded += imageEntity.size
dataTransaction.updatePost(post)
} else {
imageEntity.status = Status.ERROR
}
dataTransaction.updateImage(imageEntity)
}
} catch (e: Exception) {
if (stopped) {
return
}
imageEntity.status = Status.ERROR
dataTransaction.updateImage(imageEntity)
throw DownloadException(e)
}
}
@Throws(HostException::class)
private fun checkImageTypeAndRename(
downloadDirectory: String, downloadedImage: DownloadedImage, index: Int
) {
val existingExtension = getExtension(downloadedImage.name).lowercase()
val fileNameWithoutExtension = getFileNameWithoutExtension(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()) "${sanitize(downloadedImage.name)}.$extension" else "${
sanitize(
fileNameWithoutExtension
)
}.$extension"
try {
val downloadDestinationFolder = Path.of(downloadDirectory)
Files.createDirectories(downloadDestinationFolder)
val finalFilename = "${
if (settings.downloadSettings.forceOrder) String.format(
"%03d_", index + 1
) else ""
}$filename"
imageEntity.filename = finalFilename
val imageDownloadPath = downloadDestinationFolder.resolve(finalFilename)
Files.copy(downloadedImage.path, imageDownloadPath, StandardCopyOption.REPLACE_EXISTING)
} catch (e: Exception) {
throw HostException("Failed to rename the image", e)
} finally {
try {
Files.delete(downloadedImage.path)
} catch (_: IOException) {
}
}
}
override fun run() {
context = ImageDownloadContext(imageEntity, settings)
try {
if (stopped) {
return
}
download()
} finally {
completed = true
context.cancelCoroutines()
}
}
fun stop() {
stopped = true
context.requests.forEach { it.abort() }
context.cancelCoroutines()
dataTransaction.updateImage(context.imageEntity)
}
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 imageEntity.id == that.imageEntity.id
}
override fun hashCode(): Int {
return Objects.hash(imageEntity.id)
}
}
@@ -1,42 +1,41 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.HTTPService
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
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
internal class AcidimgHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("acidimg.cc", 0, httpService, dataTransaction, downloadSpeedService) {
) : Host("acidimg.cc", 0) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String, document: Document, context: ImageDownloadContext
image: ImageEntity, context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
try {
log.debug(
String.format(
"Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, url
"Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, image.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)
log.debug(String.format("Click button found for %s", image.url))
val httpPost = HttpPost(image.url).also {
it.addHeader("Referer", image.url)
it.entity = UrlEncodedFormEntity(
listOf(
BasicNameValuePair(
@@ -45,8 +44,9 @@ internal class AcidimgHost(
)
)
)
it.setAbsoluteRequestUri(true)
}.also { context.requests.add(it) }
log.debug(String.format("Requesting %s", httpPost))
log.info("{}", httpPost)
val doc = try {
httpService.client.execute(
httpPost, context.httpContext
@@ -59,17 +59,17 @@ internal class AcidimgHost(
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.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
"Xpath '%s' cannot be found in '%s'", IMG_XPATH, image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim()
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim()
Pair(
@@ -1,30 +1,25 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
internal class DPicMeHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("dpic.me", 1, httpService, dataTransaction, downloadSpeedService) {
internal class DPicMeHost : Host("dpic.me", 1) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +27,11 @@ internal class DPicMeHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { e: Node -> e.textContent.trim() }
@@ -4,10 +4,11 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.runBlocking
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.services.DataTransaction
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.DownloadSpeedService
import me.vripper.services.HTTPService
import me.vripper.utilities.HtmlUtils
@@ -18,21 +19,23 @@ 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.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.w3c.dom.Document
import java.io.BufferedOutputStream
import java.nio.file.Files
import java.nio.file.Path
import kotlin.Throws
internal abstract class Host(
val hostName: String,
val hostId: Byte,
private val httpService: HTTPService,
private val dataTransaction: DataTransaction,
private val downloadSpeedService: DownloadSpeedService
) {
) : KoinComponent {
private val log by LoggerDelegate()
private val httpService: HTTPService by inject()
private val dataTransaction: DataTransaction by inject()
private val downloadSpeedService: DownloadSpeedService by inject()
companion object {
private const val READ_BUFFER_SIZE = 8192
private val hosts: MutableMap<String, Byte> = mutableMapOf()
@@ -48,49 +51,49 @@ internal abstract class Host(
@Throws(HostException::class)
abstract fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String>
@Throws(HostException::class)
fun downloadInternal(url: String, context: ImageDownloadContext): DownloadedImage {
val headers = head(url, context)
fun downloadInternal(image: ImageEntity, context: ImageDownloadContext): DownloadedImage {
if (hostId == 8.toByte()) {
return downloadByHost(image, context)
}
val headers = head(image.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) {
val downloadedImage = fetch(image.url, context) {
handleImageDownload(it, context)
}
DownloadedImage(getDefaultImageName(url), downloadedImage.first, downloadedImage.second)
DownloadedImage(getDefaultImageName(image.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)
downloadByHost(image, context)
} else {
throw HostException("Unable to download $url, can't process content type $value")
throw HostException("Unable to download ${image.url}, can't process content type $value")
}
} else {
throw HostException("Unexpected server response for $url, response have no content type")
throw HostException("Unexpected server response for ${image.url}, response have no content type")
}
}
return downloadedImage
}
private fun downloadByHost(image: ImageEntity, context: ImageDownloadContext): DownloadedImage {
val resolvedImage = resolve(image, context)
val downloadImage: Pair<Path, ImageMimeType> =
fetch(resolvedImage.second, context) {
handleImageDownload(it, context)
}
return DownloadedImage(resolvedImage.first, downloadImage.first, downloadImage.second)
}
private fun handleImageDownload(
response: ClassicHttpResponse,
context: ImageDownloadContext
@@ -154,8 +157,11 @@ internal abstract class Host(
@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))
val httpHead = HttpHead(url).also {
it.setAbsoluteRequestUri(true)
context.requests.add(it)
}
log.info("{}", httpHead)
return httpService.client.execute(
httpHead,
context.httpContext
@@ -174,8 +180,11 @@ internal abstract class Host(
transformer: (ClassicHttpResponse) -> T
): T {
val httpGet =
HttpGet(url).also { it.addHeader("Referer", context.imageEntity.url) }.also { context.requests.add(it) }
log.debug(String.format("Requesting %s", url))
HttpGet(url).also {
it.addHeader("Referer", context.imageEntity.url)
it.setAbsoluteRequestUri(true)
}.also { context.requests.add(it) }
log.info("{}", httpGet)
return httpService.client.execute(httpGet, context.httpContext) {
if (it.code / 100 != 2) {
throw DownloadException("Server returned code ${it.code}")
@@ -184,6 +193,19 @@ internal abstract class Host(
}
}
fun fetchDocument(
url: String,
context: ImageDownloadContext
): Document {
return fetch(url, context) {
HtmlUtils.clean(it.entity.content)
}.also {
if (log.isDebugEnabled) {
log.debug("Cleaning $url response", url)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun getImageMimeType(headers: Array<Header>): ImageMimeType? {
@@ -1,35 +1,30 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
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 java.sql.Date
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*
internal class ImageBamHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagebam.com", 2, httpService, dataTransaction, downloadSpeedService) {
internal class ImageBamHost : Host("imagebam.com", 2) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val doc = try {
log.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_XPATH, image.url))
if (XpathUtils.getAsNode(document, CONTINUE_XPATH) != null) {
val clientCookie = BasicClientCookie("nsfw_inter", "1")
clientCookie.domain = "www.imagebam.com"
@@ -39,7 +34,7 @@ internal class ImageBamHost(
LocalDateTime.now().plusDays(3).atZone(ZoneId.systemDefault()).toInstant()
)
context.httpContext.cookieStore.addCookie(clientCookie)
fetch(url, context) {
fetch(image.url, context) {
HtmlUtils.clean(it.entity.content)
}
} else {
@@ -49,7 +44,7 @@ internal class ImageBamHost(
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -57,11 +52,11 @@ internal class ImageBamHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { e: Node -> e.textContent.trim { it <= ' ' } }
.orElse("")
@@ -1,30 +1,25 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
internal class ImageTwistHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagetwist.com", 3, httpService, dataTransaction, downloadSpeedService) {
internal class ImageTwistHost : Host("imagetwist.com", 3) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +27,11 @@ internal class ImageTwistHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { obj: Node -> obj.textContent }
@@ -1,39 +1,34 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class ImageVenueHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagevenue.com", 4, httpService, dataTransaction, downloadSpeedService) {
internal class ImageVenueHost : Host("imagevenue.com", 4) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val doc = try {
val document = fetchDocument(image.url, context)
log.debug(
String.format(
"Looking for xpath expression %s in %s",
CONTINUE_BUTTON_XPATH,
url
image.url
)
)
if (XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH) != null) {
// Button detected. No need to actually click it, just make the call again.
fetch(url, context) {
fetch(image.url, context) {
HtmlUtils.clean(it.entity.content)
}
} else {
@@ -43,7 +38,7 @@ internal class ImageVenueHost(
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -51,11 +46,11 @@ internal class ImageVenueHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
@@ -1,28 +1,23 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
internal class ImageZillaHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagezilla.net", 5, httpService, dataTransaction, downloadSpeedService) {
internal class ImageZillaHost : Host("imagezilla.net", 5) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -30,17 +25,17 @@ internal class ImageZillaHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
log.debug(String.format("Resolving name for %s", url))
log.debug(String.format("Resolving name for %s", image.url))
var title = titleNode.attributes.getNamedItem("title").textContent.trim()
titleNode.textContent.trim()
if (title.isEmpty()) {
title = getDefaultImageName(url)
title = getDefaultImageName(image.url)
}
return try {
Pair(title, url.replace("show", "images"))
Pair(title, image.url.replace("show", "images"))
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
@@ -1,29 +1,24 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class ImgSpiceHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imgspice.com", 7, httpService, dataTransaction, downloadSpeedService) {
internal class ImgSpiceHost : Host("imgspice.com", 7) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +26,11 @@ internal class ImgSpiceHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
@@ -1,29 +1,24 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class ImgboxHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imgbox.com", 6, httpService, dataTransaction, downloadSpeedService) {
internal class ImgboxHost : Host("imgbox.com", 6) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +26,11 @@ internal class ImgboxHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("title").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(imgTitle, imgUrl)
@@ -1,88 +1,85 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import me.vripper.entities.ImageEntity
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.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.ApplicationProperties.IMX_SUBDOMAINS
import me.vripper.utilities.LoggerDelegate
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
import me.vripper.utilities.RequestLimit
import org.apache.hc.client5.http.classic.methods.HttpHead
import org.apache.hc.client5.http.config.ConnectionConfig
import org.apache.hc.client5.http.impl.classic.HttpClients
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager
import java.util.concurrent.TimeUnit
internal class ImxHost : Host("imx.to", 8) {
companion object {
private val resolvedHosts: Cache<Long, String> =
Caffeine.newBuilder().expireAfterAccess(5, TimeUnit.MINUTES).build()
}
internal class ImxHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imx.to", 8, httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
private val failFastHttpClient = HttpClients.custom().apply {
setConnectionManager(BasicHttpClientConnectionManager().apply {
connectionConfig = ConnectionConfig.custom()
.setConnectTimeout(5000, TimeUnit.MILLISECONDS)
.setSocketTimeout(5000, TimeUnit.MILLISECONDS)
.build()
})
}.build()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
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
log.debug("Resolving name and image url for ${image.url}")
val imgTitle = String.format("IMG_%04d", image.index + 1)
synchronized(image.postId.toString().intern()) {
val subDomain = resolvedHosts.getIfPresent(image.postId)
if (subDomain != null) {
val imgUrl = findPattern(image, subDomain)
return Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
} else {
IMX_SUBDOMAINS.forEach { subDomain ->
val imgUrl = findPattern(image, subDomain)
val result = runCatching {
RequestLimit.getPermit(1)
val httpHead = HttpHead(imgUrl)
.also {
it.setAbsoluteRequestUri(true)
context.requests.add(it)
}
log.info("{}", httpHead)
failFastHttpClient.execute(httpHead) { response ->
if (response.code / 100 != 2) {
throw HostException("Invalid response code ${response.code}")
}
}
}
if (result.isSuccess) {
resolvedHosts.put(image.postId, subDomain)
return Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
}
}
}
} 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)
}
throw HostException("Unable to find full size image for ${image.url}")
}
companion object {
private const val CONTINUE_BUTTON_XPATH = "//*[@name='imgContinue']"
private const val IMG_XPATH = "//img[@class='centred']"
}
private fun findPattern(image: ImageEntity, subDomain: String): String = image.thumbUrl
.replace("http:", "https:")
.replace("imx.to", subDomain)
.replace("upload/small/", "i/")
.replace("u/i/", "i/")
.replace("u/t/", "i/")
.replace("t/", "i/")
}
@@ -1,33 +1,30 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.net.URI
import java.net.URISyntaxException
internal class PimpandhostHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pimpandhost.com", 9, httpService, dataTransaction, downloadSpeedService) {
internal class PimpandhostHost : Host("pimpandhost.com", 9) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val newUrl: String
try {
newUrl = appendUri(url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""), "size=original")
newUrl = appendUri(
image.url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""),
"size=original"
)
} catch (e: Exception) {
throw HostException(e)
}
@@ -43,7 +40,7 @@ internal class PimpandhostHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
@@ -1,29 +1,24 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class PixRouteHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixroute.com", 11, httpService, dataTransaction, downloadSpeedService) {
internal class PixRouteHost : Host("pixroute.com", 11) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +26,11 @@ internal class PixRouteHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
Pair(imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' },
imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' })
} catch (e: Exception) {
@@ -1,29 +1,24 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class PixhostHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixhost.to", 10, httpService, dataTransaction, downloadSpeedService) {
internal class PixhostHost : Host("pixhost.to", 10) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +26,11 @@ internal class PixhostHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(imgTitle.substring(imgTitle.indexOf('_') + 1), imgUrl)
@@ -1,28 +1,23 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
internal class PixxxelsHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixxxels.cc", 12, httpService, dataTransaction, downloadSpeedService) {
internal class PixxxelsHost : Host("pixxxels.cc", 12) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -30,11 +25,11 @@ internal class PixxxelsHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -42,11 +37,11 @@ internal class PixxxelsHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
TITLE_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = titleNode.textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("href").textContent.trim { it <= ' ' }
Pair(
@@ -1,30 +1,25 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
internal class PostImgHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("postimg.cc", 13, httpService, dataTransaction, downloadSpeedService) {
internal class PostImgHost : Host("postimg.cc", 13) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +27,11 @@ internal class PostImgHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
TITLE_XPATH,
url
image.url
)
)
val urlNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -44,14 +39,14 @@ internal class PostImgHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = Optional.ofNullable(titleNode)
.map { node: Node -> node.textContent.trim { it <= ' ' } }
.orElseGet { getDefaultImageName(url) }
.orElseGet { getDefaultImageName(image.url) }
Pair(imgTitle, urlNode.attributes.getNamedItem("href").textContent.trim { it <= ' ' })
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
@@ -1,45 +1,40 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class TurboImageHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("turboimagehost.com", 14, httpService, dataTransaction, downloadSpeedService) {
internal class TurboImageHost : Host("turboimagehost.com", 14) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
var title: String?
title = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
val titleNode: Node? = XpathUtils.getAsNode(document, TITLE_XPATH)
log.debug(String.format("Resolving name for %s", url))
log.debug(String.format("Resolving name for %s", image.url))
titleNode?.textContent?.trim { it <= ' ' }
} catch (e: XpathException) {
throw HostException(e)
}
if (title.isNullOrEmpty()) {
title = getDefaultImageName(url)
title = getDefaultImageName(image.url)
}
val urlNode: Node = XpathUtils.getAsNode(document, IMG_XPATH)
?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return Pair(title, urlNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' })
@@ -1,30 +1,25 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
internal class ViprImHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("vipr.im", 15, httpService, dataTransaction, downloadSpeedService) {
internal class ViprImHost : Host("vipr.im", 15) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +27,11 @@ internal class ViprImHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { obj: Node -> obj.textContent }
@@ -0,0 +1,54 @@
package me.vripper.listeners
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.services.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
object AppManager : KoinComponent {
private val eventBus: EventBus by inject()
private val dataTransaction: DataTransaction by inject()
private val metadataService: MetadataService by inject()
private val settingsService: SettingsService by inject()
private val vgAuthService: VGAuthService by inject()
private val downloadSpeedService: DownloadSpeedService by inject()
private val httpService: HTTPService by inject()
private val retryPolicyService: RetryPolicyService by inject()
private val threadCacheService: ThreadCacheService by inject()
private val downloadService: DownloadService by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var job: Job? = null
fun start() {
job?.cancel()
job = coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
httpService.client.close()
httpService.buildRequestConfig(it.settings.connectionSettings.timeout.toLong())
httpService.buildConnectionConfig(it.settings.connectionSettings.timeout.toLong())
httpService.buildConnectionPool()
httpService.buildClientBuilder()
vgAuthService.authenticate(it.settings)
retryPolicyService.maxAttempts = it.settings.connectionSettings.maxAttempts
threadCacheService.invalidate()
}
}
dataTransaction.setDownloadingToStopped()
dataTransaction.stopImagesByPostIdAndIsNotCompleted()
settingsService.init()
metadataService.fetchExisting()
downloadSpeedService.init()
downloadService.init()
}
fun stop() {
job?.cancel()
downloadService.halt()
downloadService.stop()
downloadSpeedService.halt()
}
}
@@ -1,17 +0,0 @@
package me.vripper.listeners
import me.vripper.services.DataTransaction
import me.vripper.services.MetadataService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
open class OnStartupListener : KoinComponent {
private val dataTransaction: DataTransaction by inject()
private val metadataService: MetadataService by inject()
open fun run() {
dataTransaction.setDownloadingToStopped()
dataTransaction.stopImagesByPostIdAndIsNotCompleted()
metadataService.init()
}
}
@@ -20,6 +20,8 @@ data class ViperSettings(
val password: String = "",
val thanks: Boolean = false,
val host: String = "https://vipergirls.to",
val requestLimit: Long = 2,
val fetchMetadata: Boolean = false,
)
@Serializable
@@ -3,7 +3,6 @@ package me.vripper.services
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.time.sample
import kotlinx.serialization.json.Json
import me.vripper.download.DownloadService
import me.vripper.entities.*
import me.vripper.event.*
import me.vripper.exception.PostParseException
@@ -14,7 +13,7 @@ import me.vripper.utilities.ApplicationProperties
import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils
import me.vripper.utilities.executorService
import me.vripper.utilities.taskRunner
import org.h2.jdbc.JdbcSQLNonTransientConnectionException
import java.sql.DriverManager
import java.time.Duration
@@ -42,10 +41,10 @@ internal class AppEndpointService(
if (postLinks.isBlank()) {
return
}
val urlList = postLinks.split(Pattern.compile("\\r?\\n")).dropLastWhile { it.isEmpty() }.map { it.trim() }
val urlList = postLinks.split(Pattern.compile("\\r?\\n")).dropLastWhile { it.isBlank() }.map { it.trim() }
.filter { it.isNotEmpty() }
for (link in urlList) {
log.debug("Starting to process thread: $link")
log.debug("Scanning: $link")
if (!link.startsWith(settingsService.settings.viperSettings.host)) {
continue
}
@@ -58,13 +57,13 @@ internal class AppEndpointService(
threadId = m.group(1).toLong()
postId = m.group(4)?.toLong()
if (postId == null) {
executorService.submit(
taskRunner.submit(
ThreadLookupTask(
threadId, settingsService.settings
)
)
} else {
executorService.submit(
taskRunner.submit(
AddPostTask(
listOf(ThreadPostId(threadId, postId))
)
@@ -86,7 +85,7 @@ internal class AppEndpointService(
}
override suspend fun download(posts: List<ThreadPostId>) {
executorService.submit(AddPostTask(posts))
taskRunner.submit(AddPostTask(posts))
}
override suspend fun stopAll(postIdList: List<Long>) {
@@ -119,6 +118,9 @@ internal class AppEndpointService(
)
}
val threadLookupResult = threadCacheService[thread.threadId]
if (threadLookupResult.error.isNotBlank()) {
throw PostParseException("Error occurred for threadId = $threadId, ${threadLookupResult.error}")
}
threadLookupResult.postItemList.map { postItem ->
PostSelection(
postItem.threadId,
@@ -132,23 +134,13 @@ internal class AppEndpointService(
postItem.forum,
postItem.imageItemList.map { it.thumbLink })
}.ifEmpty {
log.error(
String.format(
"Failed to get links for threadId = %s", threadId
)
)
throw PostParseException(
String.format(
"Failed to get links for threadId = %s", threadId
)
"Nothing found for threadId = $threadId"
)
}
} catch (e: Exception) {
throw PostParseException(
String.format(
"Failed to get links for threadId = %s, %s", threadId, e.message
)
)
log.error(e.message)
throw e
}
}
}
@@ -177,12 +169,8 @@ internal class AppEndpointService(
}
}
override fun ready(): Boolean {
return true
}
override suspend fun rename(postId: Long, newName: String) {
executorService.submit {
taskRunner.submit {
synchronized(postId.toString().intern()) {
if (dataTransaction.exists(postId)) {
dataTransaction.findPostByPostId(postId).let { post ->
@@ -463,4 +451,8 @@ internal class AppEndpointService(
"Successfully imported $postsCount posts and $threadCount threads"
}
}
override fun connectionState(): String {
return ""
}
}
@@ -1,8 +1,9 @@
package me.vripper.download
package me.vripper.services
import dev.failsafe.Failsafe
import dev.failsafe.RetryPolicy
import kotlinx.coroutines.Runnable
import dev.failsafe.function.CheckedRunnable
import kotlinx.coroutines.*
import me.vripper.entities.ImageEntity
import me.vripper.entities.PostEntity
import me.vripper.entities.Status
@@ -10,26 +11,47 @@ import me.vripper.event.ErrorCountEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.event.StoppedEvent
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.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.model.Settings
import me.vripper.utilities.ApplicationProperties
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.executorService
import me.vripper.utilities.PathUtils.getExtension
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
import me.vripper.utilities.PathUtils.sanitize
import me.vripper.utilities.downloadRunner
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
import org.apache.hc.client5.http.cookie.Cookie
import org.apache.hc.client5.http.impl.cookie.BasicClientCookie
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.jetbrains.exposed.sql.transactions.transaction
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.time.Instant
import java.util.*
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.io.path.Path
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.pathString
import kotlin.io.path.readLines
internal 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 LoggerDelegate()
private val running: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
@@ -37,8 +59,202 @@ internal class DownloadService(
private val lock = ReentrantLock()
private val condition = lock.newCondition()
init {
Thread.ofVirtual().name("Download Loop").unstarted(Runnable {
private var downloadMonitorThread: Thread? = null
internal class ImageDownloadContext(val imageEntity: ImageEntity, val settings: Settings) : KoinComponent {
private val log by LoggerDelegate()
init {
ApplicationProperties.VRIPPER_DIR
.listDirectoryEntries()
.filter { it.fileName.pathString.startsWith("cookies") }
.forEach { cookiesPath ->
loadCookies(cookiesPath).also { cookies ->
cookies.forEach { cookie ->
if (HTTPService.cookieStore.cookies.find { it.name == cookie.name } == null) {
log.info("Applying cookie: ${cookie.name}")
HTTPService.cookieStore.addCookie(cookie)
} else {
log.warn("Cookie already loaded: ${cookie.name}")
}
}
}
}
}
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val jobs = mutableListOf<Job>()
val httpContext: HttpClientContext = HttpClientContext.create().apply {
cookieStore = HTTPService.cookieStore
}
private fun loadCookies(cookiesPath: Path): List<Cookie> {
return cookiesPath.readLines()
.map { it.trim() }
.filter { it.isNotBlank() }
.filter { !it.startsWith("#") }
.map { line ->
val cookieComponents = line.split("\t")
BasicClientCookie(cookieComponents[5], cookieComponents[6]).apply {
domain = cookieComponents[0]
isHttpOnly = cookieComponents[1].toBoolean()
path = cookieComponents[2]
isSecure = cookieComponents[3].toBoolean()
setExpiryDate(Instant.ofEpochSecond(cookieComponents[4].toLong()))
}
}.also {
log.info("Found ${it.size} cookies in $cookiesPath")
}
}
val requests = mutableListOf<HttpUriRequestBase>()
val postId = imageEntity.postIdRef
fun cancelCoroutines() {
runBlocking {
coroutineScope.cancel()
jobs.forEach { job -> job.cancelAndJoin() }
}
}
fun launchCoroutine(block: suspend CoroutineScope.() -> Unit): Job {
return coroutineScope.launch(block = block).also { job -> jobs.add(job) }
}
}
internal class ImageDownloadRunnable(
val imageEntity: ImageEntity, val postRank: Int, private val settings: Settings
) : KoinComponent, CheckedRunnable {
private val log by LoggerDelegate()
private val dataTransaction: DataTransaction by inject()
private val vgauthService: VGAuthService by inject()
private val hosts: List<Host> = getKoin().getAll()
var completed = false
var stopped = false
private lateinit var context: ImageDownloadContext
fun download() {
try {
imageEntity.status = Status.DOWNLOADING
imageEntity.downloaded = 0
dataTransaction.updateImage(imageEntity)
synchronized(imageEntity.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId)
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataTransaction.updatePost(post)
vgauthService.leaveThanks(post)
}
}
log.debug("Getting image url and name from ${imageEntity.url} using ${imageEntity.host}")
val host = hosts.first { it.isSupported(imageEntity.url) }
val downloadedImage = host.downloadInternal(imageEntity, context)
log.debug("Resolved name for ${imageEntity.url}: ${downloadedImage.name}")
log.debug("Downloaded image {} to {}", imageEntity.url, downloadedImage.path)
synchronized(imageEntity.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId)
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
checkImageTypeAndRename(
downloadDirectory, downloadedImage, imageEntity.index
)
if (imageEntity.downloaded == imageEntity.size && imageEntity.size > 0) {
imageEntity.status = Status.FINISHED
post.done += 1
post.downloaded += imageEntity.size
dataTransaction.updatePost(post)
} else {
imageEntity.status = Status.ERROR
}
dataTransaction.updateImage(imageEntity)
}
} catch (e: Exception) {
if (stopped) {
return
}
imageEntity.status = Status.ERROR
dataTransaction.updateImage(imageEntity)
throw DownloadException(e)
}
}
@Throws(HostException::class)
private fun checkImageTypeAndRename(
downloadDirectory: String, downloadedImage: DownloadedImage, index: Int
) {
val existingExtension = getExtension(downloadedImage.name).lowercase()
val fileNameWithoutExtension = getFileNameWithoutExtension(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()) "${sanitize(downloadedImage.name)}.$extension" else "${
sanitize(
fileNameWithoutExtension
)
}.$extension"
try {
val downloadDestinationFolder = Path.of(downloadDirectory)
Files.createDirectories(downloadDestinationFolder)
val finalFilename = "${
if (settings.downloadSettings.forceOrder) String.format(
"%03d_", index + 1
) else ""
}$filename"
imageEntity.filename = finalFilename
val imageDownloadPath = downloadDestinationFolder.resolve(finalFilename)
Files.copy(downloadedImage.path, imageDownloadPath, StandardCopyOption.REPLACE_EXISTING)
} catch (e: Exception) {
throw HostException("Failed to rename the image", e)
} finally {
try {
Files.delete(downloadedImage.path)
} catch (_: IOException) {
}
}
}
override fun run() {
context = ImageDownloadContext(imageEntity, settings)
try {
if (stopped) {
return
}
download()
} finally {
completed = true
context.cancelCoroutines()
}
}
fun stop() {
stopped = true
context.requests.forEach { it.abort() }
context.cancelCoroutines()
dataTransaction.updateImage(context.imageEntity)
}
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 imageEntity.id == that.imageEntity.id
}
override fun hashCode(): Int {
return Objects.hash(imageEntity.id)
}
}
fun init() {
downloadMonitorThread?.interrupt()
downloadMonitorThread = Thread.ofVirtual().name("Download Monitor").unstarted(Runnable {
log.info("Scheduler have been initialized")
val accepted: MutableList<ImageDownloadRunnable> = mutableListOf()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
while (!Thread.currentThread().isInterrupted) {
@@ -59,12 +275,18 @@ internal class DownloadService(
candidates.clear()
try {
condition.await()
} catch (e: InterruptedException) {
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
}).start()
log.info("Scheduler have been shutdown")
})
downloadMonitorThread?.start()
}
fun halt() {
downloadMonitorThread?.interrupt()
}
fun stop(postIds: List<Long> = emptyList()) {
@@ -120,10 +342,6 @@ internal class DownloadService(
)
}
toProcess.keys.forEach {
vgAuthService.leaveThanks(it)
}
toProcess.entries.forEach { (post, images) ->
images.forEach { image ->
@@ -193,7 +411,7 @@ internal class DownloadService(
private fun candidateCount(): Map<Byte, Int> {
val map: MutableMap<Byte, Int> = mutableMapOf()
Host.getHosts().values.forEach { host: Byte ->
Host.Companion.getHosts().values.forEach { host: Byte ->
val imageDownloadRunnableList: List<ImageDownloadRunnable> = running.computeIfAbsent(
host
) { mutableListOf() }
@@ -231,7 +449,7 @@ internal class DownloadService(
log.debug("Scheduling a job for ${imageDownloadRunnable.imageEntity.url}")
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicy("Failed to download ${imageDownloadRunnable.imageEntity.url}: "))
.with(executorService)
.with(downloadRunner)
.onFailure {
log.error(
"Failed to download ${imageDownloadRunnable.imageEntity.url} after ${it.attemptCount} tries",
@@ -19,9 +19,11 @@ internal class DownloadSpeedService(
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val bytesCount = AtomicLong(0)
private var job: Job? = null
private var queueStateUpdateJob: Job? = null
init {
coroutineScope.launch {
fun init() {
queueStateUpdateJob?.cancel()
queueStateUpdateJob = coroutineScope.launch {
eventBus.events.filterIsInstance(QueueStateEvent::class).collect {
if (it.queueState.running + it.queueState.remaining > 0) {
if (job == null || job?.isActive == false) {
@@ -45,6 +47,11 @@ internal class DownloadSpeedService(
}
}
fun halt() {
queueStateUpdateJob?.cancel()
job?.cancel()
}
fun reportDownloadedBytes(count: Long) {
bytesCount.addAndGet(count)
}
@@ -1,100 +1,74 @@
package me.vripper.services
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.utilities.ApplicationProperties
import org.apache.hc.client5.http.config.ConnectionConfig
import org.apache.hc.client5.http.config.RequestConfig
import org.apache.hc.client5.http.cookie.BasicCookieStore
import org.apache.hc.client5.http.cookie.StandardCookieSpec
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient
import org.apache.hc.client5.http.impl.classic.HttpClients
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder
import org.apache.hc.client5.http.io.HttpClientConnectionManager
import org.apache.hc.core5.pool.PoolConcurrencyPolicy
import org.apache.hc.core5.pool.PoolReusePolicy
import org.apache.hc.core5.util.Timeout
internal class HTTPService(
private val eventBus: EventBus,
settingsService: SettingsService
) {
internal class HTTPService {
companion object {
const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0"
val cookieStore = BasicCookieStore()
}
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private lateinit var pcm: PoolingHttpClientConnectionManager
private lateinit var rc: RequestConfig
private lateinit var cc: ConnectionConfig
lateinit var client: CloseableHttpClient
private var connectionTimeout = settingsService.settings.connectionSettings.timeout
private var pcm: HttpClientConnectionManager = BasicHttpClientConnectionManager()
private var rc: RequestConfig = RequestConfig.DEFAULT
private var cc: ConnectionConfig = ConnectionConfig.DEFAULT
private var connectionExpiryJob: Job? = null
var client: CloseableHttpClient = HttpClients.createDefault()
init {
buildRequestConfig()
buildConnectionConfig()
buildConnectionPool()
buildClientBuilder()
coroutineScope.launch {
while (isActive) {
pcm.closeExpired()
delay(60_000)
}
}
coroutineScope.launch {
eventBus
.events
.filterIsInstance(SettingsUpdateEvent::class)
.collect {
if (connectionTimeout != it.settings.connectionSettings.timeout) {
connectionTimeout = it.settings.connectionSettings.timeout
client.close()
pcm.close()
buildRequestConfig()
buildConnectionConfig()
buildConnectionPool()
buildClientBuilder()
}
}
}
}
private fun buildConnectionPool() {
fun buildConnectionPool() {
pcm.close()
connectionExpiryJob?.cancel()
pcm = PoolingHttpClientConnectionManagerBuilder.create()
.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX)
.setConnPoolPolicy(PoolReusePolicy.FIFO)
.setDefaultConnectionConfig(cc)
.setMaxConnTotal(Int.MAX_VALUE)
.setMaxConnPerRoute(Int.MAX_VALUE)
.build()
.build().also {
connectionExpiryJob = coroutineScope.launch {
while (isActive) {
it.closeExpired()
delay(60_000)
}
}
}
}
private fun buildRequestConfig() {
fun buildRequestConfig(connectionTimeout: Long) {
rc = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofSeconds(connectionTimeout.toLong()))
.setConnectionRequestTimeout(Timeout.ofSeconds(connectionTimeout))
.setCookieSpec(StandardCookieSpec.RELAXED)
.build()
}
private fun buildConnectionConfig() {
fun buildConnectionConfig(connectionTimeout: Long) {
cc = ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofSeconds(connectionTimeout.toLong()))
.setSocketTimeout(Timeout.ofSeconds(connectionTimeout.toLong()))
.setConnectTimeout(Timeout.ofSeconds(connectionTimeout))
.setSocketTimeout(Timeout.ofSeconds(connectionTimeout))
.build()
}
private fun buildClientBuilder() {
fun buildClientBuilder() {
client = HttpClients.custom()
.setConnectionManager(pcm)
.setRedirectStrategy(DefaultRedirectStrategy.INSTANCE)
.setUserAgent(USER_AGENT)
.setUserAgent(ApplicationProperties.USER_AGENT)
.disableAutomaticRetries()
.setDefaultRequestConfig(rc)
.setDefaultCookieStore(cookieStore)
.build()
}
}
}
@@ -42,7 +42,7 @@ interface IAppEndpointService {
suspend fun loggedInUser(): String
suspend fun getVersion(): String
suspend fun renameToFirst(postIds: List<Long>)
fun ready(): Boolean
suspend fun dbMigration(): String
suspend fun initLogger()
fun connectionState(): String
}
@@ -1,111 +1,29 @@
package me.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withPermit
import me.vripper.entities.MetadataEntity
import me.vripper.exception.DownloadException
import me.vripper.exception.VripperException
import me.vripper.tasks.Tasks
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.RequestLimit
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.net.URIBuilder
import org.w3c.dom.Node
import java.util.concurrent.atomic.AtomicBoolean
import java.util.stream.Collectors
import me.vripper.tasks.FetchMetadataTask
import me.vripper.utilities.taskRunner
internal class MetadataService(
private val httpService: HTTPService,
private val settingsService: SettingsService,
private val dataTransaction: DataTransaction,
private val vgAuthService: VGAuthService,
private val settingsService: SettingsService,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val dictionary: List<String> = mutableListOf("download", "link", "rapidgator", "filefactory", "filefox")
fun init() {
fun fetchExisting() {
if (!settingsService.settings.viperSettings.fetchMetadata) {
return
}
dataTransaction.findAllPosts().filter { dataTransaction.findMetadataByPostId(it.postId).isEmpty }
.map { it.postId }.forEach(::fetchMetadata)
}
fun fetchMetadata(postId: Long) {
Tasks.increment()
coroutineScope.launch {
try {
val httpGet = HttpGet(URIBuilder(settingsService.settings.viperSettings.host + "/threads/").also {
it.setParameter(
"p", postId.toString()
)
}.build())
val response = RequestLimit.semaphore.withPermit {
httpService.client.execute(httpGet, vgAuthService.context) {
if (it.code / 100 != 2) {
throw DownloadException("Unexpected response code '${it.code}' for $httpGet")
}
EntityUtils.toString(it.entity)
}
}
val document = HtmlUtils.clean(response)
val postNode: Node = XpathUtils.getAsNode(
document,
"//li[@id='post_$postId']/div[contains(@class, 'postdetails')]",
) ?: throw VripperException("Unable to find post #'$postId'")
val postedBy: String = XpathUtils.getAsNode(
postNode, "./div[contains(@class, 'userinfo')]//a[contains(@class, 'username')]//font"
)?.textContent?.trim()
?: throw VripperException("Unable to find the poster for post #'$postId'")
val node: Node = XpathUtils.getAsNode(
document, java.lang.String.format("//div[@id='post_message_%s']", postId)
) ?: throw VripperException("Unable to locate post content")
val titles = findTitleInContent(node)
val metadataEntity = MetadataEntity(postId, MetadataEntity.Data(postedBy, titles))
dataTransaction.saveMetadata(metadataEntity)
} finally {
Tasks.decrement()
}
}
}
private fun findTitleInContent(node: Node): List<String> {
val altTitle: MutableList<String> = mutableListOf()
findTitle(node, altTitle, AtomicBoolean(true))
return altTitle.stream().distinct().collect(Collectors.toList())
}
private fun findTitle(node: Node, altTitle: MutableList<String>, keepGoing: AtomicBoolean) {
if (!keepGoing.get()) {
if (!settingsService.settings.viperSettings.fetchMetadata) {
return
}
if (node.nodeName == "a" || node.nodeName == "img") {
keepGoing.set(false)
return
}
if (node.nodeType == Node.ELEMENT_NODE) {
for (i in 0 until node.childNodes.length) {
val item = node.childNodes.item(i)
findTitle(item, altTitle, keepGoing)
if (!keepGoing.get()) {
return
}
}
} else if (node.nodeType == Node.TEXT_NODE) {
val text = node.textContent.trim()
if (text.isNotBlank() && dictionary.stream().noneMatch { e ->
text.lowercase().contains(e.lowercase())
}) {
altTitle.add(text)
}
}
taskRunner.submit(
FetchMetadataTask(
postId
)
)
}
}
@@ -1,32 +1,12 @@
package me.vripper.services
import dev.failsafe.RetryPolicy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.utilities.LoggerDelegate
import java.time.temporal.ChronoUnit
internal class RetryPolicyService(
val eventBus: EventBus, settingsService: SettingsService
) {
internal class RetryPolicyService {
private val log by LoggerDelegate()
private var maxAttempts: Int = settingsService.settings.connectionSettings.maxAttempts
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
init {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
if (maxAttempts != it.settings.connectionSettings.maxAttempts) {
maxAttempts = it.settings.connectionSettings.maxAttempts
}
}
}
}
var maxAttempts: Int = 3
fun <T> buildRetryPolicy(message: String): RetryPolicy<T> {
return RetryPolicy.builder<T>().withDelay(2, 5, ChronoUnit.SECONDS).withMaxAttempts(maxAttempts)
@@ -1,5 +1,6 @@
package me.vripper.services
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@@ -9,7 +10,7 @@ import me.vripper.exception.ValidationException
import me.vripper.model.Settings
import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR
import me.vripper.utilities.LoggerDelegate
import org.apache.commons.codec.digest.DigestUtils
import me.vripper.utilities.md5Hex
import java.io.FileWriter
import java.nio.file.*
import kotlin.io.path.readText
@@ -28,16 +29,13 @@ class SettingsService(private val eventBus: EventBus) {
}
var settings = Settings()
init {
init()
}
private fun init() {
fun init() {
loadViperProxies()
restore()
eventBus.publishEvent(SettingsUpdateEvent(settings))
}
@OptIn(ExperimentalSerializationApi::class)
private fun loadViperProxies() {
try {
SettingsService::class.java.getResourceAsStream("/proxies.json")?.use {
@@ -84,7 +82,7 @@ class SettingsService(private val eventBus: EventBus) {
check(settings)
val viperSettings = if (settings.viperSettings.login) {
if (this.settings.viperSettings.password != settings.viperSettings.password) {
settings.viperSettings.copy(password = DigestUtils.md5Hex(settings.viperSettings.password))
settings.viperSettings.copy(password = md5Hex(settings.viperSettings.password))
} else {
settings.viperSettings
}
@@ -137,7 +135,7 @@ class SettingsService(private val eventBus: EventBus) {
fun check(settings: Settings) {
val path: Path = try {
Paths.get(settings.downloadSettings.downloadPath)
} catch (e: InvalidPathException) {
} catch (_: InvalidPathException) {
throw ValidationException(
String.format(
"%s is invalid", settings.downloadSettings.downloadPath
@@ -188,5 +186,11 @@ class SettingsService(private val eventBus: EventBus) {
"Invalid clipboard monitoring polling rate settings, values must be >= 500"
)
}
if (settings.viperSettings.requestLimit < 1 || settings.viperSettings.requestLimit > 5) {
throw ValidationException(
"Invalid request rate limit, values must be in [1,5]"
)
}
}
}
@@ -2,35 +2,28 @@ package me.vripper.services
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.vgapi.ThreadItem
import me.vripper.vgapi.ThreadLookupAPIParser
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
internal class ThreadCacheService(val eventBus: EventBus) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun init() {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
cache.invalidateAll()
}
}
}
internal class ThreadCacheService(val dataTransaction: DataTransaction) {
private val cache: LoadingCache<Long, ThreadItem> =
Caffeine.newBuilder().expireAfterWrite(20, TimeUnit.MINUTES).build { threadId ->
ThreadLookupAPIParser(threadId).parse()
val threadItem = ThreadLookupAPIParser(threadId).parse()
dataTransaction.findThreadByThreadId(threadItem.threadId).ifPresent {
if (threadItem.postItemList.isNotEmpty()) {
dataTransaction.update(it.copy(total = threadItem.postItemList.size))
}
}
threadItem
}
fun invalidate() {
cache.invalidateAll()
}
@Throws(ExecutionException::class)
operator fun get(threadId: Long): ThreadItem {
return cache[threadId]
@@ -1,55 +1,39 @@
package me.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.entities.PostEntity
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.event.VGUserLoginEvent
import me.vripper.exception.VripperException
import me.vripper.model.Settings
import me.vripper.tasks.LeaveThanksTask
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.executorService
import me.vripper.utilities.taskRunner
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.cookie.BasicCookieStore
import org.apache.hc.client5.http.cookie.Cookie
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.client5.http.impl.cookie.BasicClientCookie
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.http.message.BasicNameValuePair
internal class VGAuthService(
private val cm: HTTPService,
private val settingsService: SettingsService,
private val eventBus: EventBus
private val eventBus: EventBus,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log by LoggerDelegate()
val context: HttpClientContext = HttpClientContext.create()
private val vgCookies: MutableList<Cookie> = mutableListOf()
var loggedUser = ""
private var authenticated = false
init {
context.cookieStore = BasicCookieStore()
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
authenticate(it.settings)
}
}
authenticate(settingsService.settings)
}
private fun authenticate(settings: Settings) {
authenticated = false
fun authenticate(settings: Settings) {
if (!settings.viperSettings.login) {
log.debug("Authentication option is disabled")
context.cookieStore.clear()
authenticated = false
loggedUser = ""
synchronized(vgCookies) {
vgCookies.clear()
}
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
@@ -57,8 +41,11 @@ internal class VGAuthService(
val password = settings.viperSettings.password
if (username.isEmpty() || password.isEmpty()) {
log.error("Cannot authenticate with ViperGirls credentials, username or password is empty")
context.cookieStore.clear()
authenticated = false
loggedUser = ""
synchronized(vgCookies) {
vgCookies.clear()
}
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
@@ -71,13 +58,14 @@ internal class VGAuthService(
BasicNameValuePair("vb_login_md5password", password)
)
)
it.addHeader("Referer", settings.viperSettings.host)
it.addHeader(
"Host", settings.viperSettings.host.replace("https://", "").replace("http://", "")
)
it.setAbsoluteRequestUri(true)
}
log.info("Authenticating: {}", postAuth)
try {
val context = HttpClientContext.create().apply {
cookieStore =
BasicCookieStore()
}
cm.client.execute(postAuth, context) { response ->
if (response.code / 100 != 2) {
throw VripperException("Unexpected response code returned ${response.code}")
@@ -85,31 +73,72 @@ internal class VGAuthService(
val responseBody = EntityUtils.toString(response.entity)
log.debug("Authentication with ViperGirls response body:{}", responseBody)
}
if (context.cookieStore.cookies.stream().map { obj: Cookie -> obj.name }
.noneMatch { e: String -> e == "vg_userid" }) {
val userIdCookie = context.cookieStore.cookies.find { it.name == "vg_userid" }
val passwordCookie = context.cookieStore.cookies.find { it.name == "vg_password" }
if (userIdCookie == null || passwordCookie == null) {
log.error(
"Failed to authenticate user with {}, missing vg_userid cookie",
"Failed to authenticate user with {}, missing vg_userid/vg_password cookie",
settings.viperSettings.host
)
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
synchronized(vgCookies) {
vgCookies.clear()
vgCookies.add(BasicClientCookie(userIdCookie.name, userIdCookie.value).apply {
domain = userIdCookie.domain
})
vgCookies.add(BasicClientCookie(passwordCookie.name, passwordCookie.value).apply {
domain = passwordCookie.domain
})
}
} catch (e: Exception) {
context.cookieStore.clear()
loggedUser = ""
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
log.error(
"Failed to authenticate user with " + settings.viperSettings.host, e
)
authenticated = false
loggedUser = ""
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
authenticated = true
loggedUser = username
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
log.info("Successfully logged in as: $loggedUser")
}
fun leaveThanks(postEntity: PostEntity) {
executorService.submit(
val context = createVgContext()
taskRunner.submit(
LeaveThanksTask(postEntity, authenticated, context)
)
}
fun createVgContext(): HttpClientContext {
val context = HttpClientContext().apply { cookieStore = BasicCookieStore() }
synchronized(vgCookies) {
if (authenticated && vgCookies.isNotEmpty()) {
vgCookies.forEach { c -> context.cookieStore.addCookie(c) }
}
}
return context
}
fun createClickContext(): HttpClientContext {
val context = HttpClientContext().apply { cookieStore = BasicCookieStore() }
synchronized(vgCookies) {
if (authenticated && vgCookies.isNotEmpty()) {
vgCookies.forEach { c ->
context.cookieStore.addCookie(
BasicClientCookie(
c.name,
c.value
).apply { domain = "viper.click" })
}
}
}
return context
}
}
@@ -1,12 +1,7 @@
package me.vripper.tasks
import kotlinx.coroutines.runBlocking
import me.vripper.download.DownloadService
import me.vripper.model.ThreadPostId
import me.vripper.services.DataTransaction
import me.vripper.services.MetadataService
import me.vripper.services.SettingsService
import me.vripper.services.ThreadCacheService
import me.vripper.services.*
import me.vripper.utilities.LoggerDelegate
import me.vripper.vgapi.PostItem
import me.vripper.vgapi.PostLookupAPIParser
@@ -27,18 +22,19 @@ internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponen
val toProcess = mutableListOf<PostItem>()
for ((threadId, postId) in items) {
if (dataTransaction.exists(postId)) {
log.info("Post $postId already loaded")
continue
}
val link =
"https://${settingsService.settings.viperSettings.host}/threads/$threadId?p=$postId&viewfull=1#post$postId"
"${settingsService.settings.viperSettings.host}/threads/$threadId?p=$postId&viewfull=1#post$postId"
val cachedThread = threadCacheService.getIfPresent(threadId)
val threadItem = cachedThread ?: runBlocking {
val threadItem = cachedThread ?:
PostLookupAPIParser(
threadId, postId
).parse()
}
if (threadItem == null) {
log.error("Failed to load $link")
@@ -61,6 +57,10 @@ internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponen
toProcess.add(postItem)
}
if (toProcess.isEmpty()) {
return
}
val posts = try {
dataTransaction.newPosts(toProcess.toList())
} catch (e: Exception) {
@@ -0,0 +1,110 @@
package me.vripper.tasks
import me.vripper.entities.MetadataEntity
import me.vripper.exception.DownloadException
import me.vripper.exception.VripperException
import me.vripper.services.DataTransaction
import me.vripper.services.HTTPService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.RequestLimit
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.net.URIBuilder
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.w3c.dom.Node
import java.util.concurrent.atomic.AtomicBoolean
import java.util.stream.Collectors
internal class FetchMetadataTask(
private val postId: Long,
) : KoinComponent, Runnable {
private val dictionary: List<String> = mutableListOf("download", "link", "rapidgator", "filefactory", "filefox")
private val log by LoggerDelegate()
private val settingsService: SettingsService by inject()
private val vgAuthService: VGAuthService by inject()
private val httpService: HTTPService by inject()
private val dataTransaction: DataTransaction by inject()
override fun run() {
try {
if (!settingsService.settings.viperSettings.fetchMetadata) {
log.debug("Fetching metadata is disabled")
return
}
Tasks.increment()
val httpGet = HttpGet(URIBuilder(settingsService.settings.viperSettings.host + "/threads/").also {
it.setParameter(
"p", postId.toString()
)
}.build()).also { it.setAbsoluteRequestUri(true) }
RequestLimit.getPermit(1)
log.info("{}", httpGet)
val response = httpService.client.execute(httpGet, vgAuthService.createVgContext()) {
if (it.code / 100 != 2) {
throw DownloadException("Unexpected response code '${it.code}' for $httpGet")
}
EntityUtils.toString(it.entity)
}
val document = HtmlUtils.clean(response)
val postNode: Node = XpathUtils.getAsNode(
document,
"//li[@id='post_$postId']/div[contains(@class, 'postdetails')]",
) ?: throw VripperException("Unable to find post #'$postId'")
val postedBy: String = XpathUtils.getAsNode(
postNode, "./div[contains(@class, 'userinfo')]//a[contains(@class, 'username')]//font"
)?.textContent?.trim()
?: throw VripperException("Unable to find the poster for post #'$postId'")
val node: Node = XpathUtils.getAsNode(
document, java.lang.String.format("//div[@id='post_message_%s']", postId)
) ?: throw VripperException("Unable to locate post content")
val titles = findTitleInContent(node)
val metadataEntity = MetadataEntity(postId, MetadataEntity.Data(postedBy, titles))
dataTransaction.saveMetadata(metadataEntity)
} finally {
Tasks.decrement()
}
}
private fun findTitleInContent(node: Node): List<String> {
val altTitle: MutableList<String> = mutableListOf()
findTitle(node, altTitle, AtomicBoolean(true))
return altTitle.stream().distinct().collect(Collectors.toList())
}
private fun findTitle(node: Node, altTitle: MutableList<String>, keepGoing: AtomicBoolean) {
if (!keepGoing.get()) {
return
}
if (node.nodeName == "a" || node.nodeName == "img") {
keepGoing.set(false)
return
}
if (node.nodeType == Node.ELEMENT_NODE) {
for (i in 0 until node.childNodes.length) {
val item = node.childNodes.item(i)
findTitle(item, altTitle, keepGoing)
if (!keepGoing.get()) {
return
}
}
} else if (node.nodeType == Node.TEXT_NODE) {
val text = node.textContent.trim()
if (text.isNotBlank() && dictionary.stream().noneMatch { e ->
text.lowercase().contains(e.lowercase())
}) {
altTitle.add(text)
}
}
}
}
@@ -1,10 +1,11 @@
package me.vripper.tasks
import me.vripper.entities.PostEntity
import me.vripper.services.DataTransaction
import me.vripper.exception.VripperException
import me.vripper.services.HTTPService
import me.vripper.services.SettingsService
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.RequestLimit
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.client5.http.protocol.HttpClientContext
@@ -20,7 +21,6 @@ internal class LeaveThanksTask(
private val log by LoggerDelegate()
private val cm: HTTPService by inject()
private val settingsService: SettingsService by inject()
private val dataTransaction: DataTransaction by inject()
override fun run() {
try {
@@ -31,25 +31,24 @@ internal class LeaveThanksTask(
if (!settingsService.settings.viperSettings.thanks) {
return
}
val postThanks =
HttpPost("${settingsService.settings.viperSettings.host}/post_thanks.php").also {
it.entity = UrlEncodedFormEntity(
listOf(
BasicNameValuePair("do", "post_thanks_add"),
BasicNameValuePair("using_ajax", "1"),
BasicNameValuePair("p", postEntity.postId.toString()),
BasicNameValuePair("securitytoken", postEntity.token)
)
)
it.addHeader("Referer", settingsService.settings.viperSettings.host)
it.addHeader(
"Host",
settingsService.settings.viperSettings.host.replace("https://", "")
.replace("http://", "")
val postThanks = HttpPost("${settingsService.settings.viperSettings.host}/post_thanks.php").also {
it.entity = UrlEncodedFormEntity(
listOf(
BasicNameValuePair("do", "post_thanks_add"),
BasicNameValuePair("using_ajax", "1"),
BasicNameValuePair("p", postEntity.postId.toString()),
BasicNameValuePair("securitytoken", postEntity.token)
)
)
it.setAbsoluteRequestUri(true)
}
RequestLimit.getPermit(1)
log.info("Posting {}", postThanks)
cm.client.execute(postThanks, context) { response ->
if (response.code / 100 != 2) {
throw VripperException("Unexpected response code '${response.code}' for $postThanks")
}
cm.client.execute(postThanks, context) { }
}
} catch (e: Exception) {
log.error("Failed to leave a thanks for $postEntity", e)
} finally {
@@ -7,7 +7,7 @@ import me.vripper.services.DataTransaction
import me.vripper.services.SettingsService
import me.vripper.services.ThreadCacheService
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.executorService
import me.vripper.utilities.taskRunner
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -33,7 +33,7 @@ internal class ThreadLookupTask(private val threadId: Long, private val settings
}
if (threadLookupResult.postItemList.size <= settings.downloadSettings.autoQueueThreshold) {
executorService.submit(
taskRunner.submit(
AddPostTask(threadLookupResult.postItemList.map {
ThreadPostId(
it.threadId, it.postId
@@ -0,0 +1,72 @@
package me.vripper.utilities
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.locks.ReentrantLock
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.concurrent.withLock
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
object AesUtils {
val mutex = ReentrantLock()
val secureRandom: SecureRandom = SecureRandom()
val cipher: Cipher = Cipher.getInstance(AES_ALGORITHM_GCM)
const val SHA_CRYPT: String = "SHA-256"
const val AES_ALGORITHM: String = "AES"
const val AES_ALGORITHM_GCM: String = "AES/GCM/NoPadding"
const val IV_LENGTH_ENCRYPT: Int = 12
const val TAG_LENGTH_ENCRYPT: Int = 16
fun aesDecrypt(data: ByteArray, passPhrase: String): ByteArray {
val key = generateAesKeyFromPassphrase(passPhrase)
val iv = ByteArray(IV_LENGTH_ENCRYPT)
System.arraycopy(data, 0, iv, 0, iv.size)
val encryptedText = ByteArray(data.size - IV_LENGTH_ENCRYPT)
System.arraycopy(data, IV_LENGTH_ENCRYPT, encryptedText, 0, encryptedText.size)
val gcmSpec = GCMParameterSpec(TAG_LENGTH_ENCRYPT * 8, iv)
val decryptedBytes = mutex.withLock {
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
cipher.doFinal(encryptedText)
}
return decryptedBytes
}
fun aesEncrypt(data: ByteArray, passPhrase: String): ByteArray {
val iv = ByteArray(IV_LENGTH_ENCRYPT)
secureRandom.nextBytes(iv)
val key = generateAesKeyFromPassphrase(passPhrase)
val gcmSpec = GCMParameterSpec(TAG_LENGTH_ENCRYPT * 8, iv)
val encryptedBytes = mutex.withLock {
cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec)
cipher.doFinal(data)
}
// Combine IV and encrypted text and encode them as Base64
val combinedIvAndCipherText = ByteArray(iv.size + encryptedBytes.size)
System.arraycopy(iv, 0, combinedIvAndCipherText, 0, iv.size)
System.arraycopy(encryptedBytes, 0, combinedIvAndCipherText, iv.size, encryptedBytes.size)
return combinedIvAndCipherText
}
fun generateAesKeyFromPassphrase(passPhrase: String): SecretKeySpec {
val sha256 = MessageDigest.getInstance(SHA_CRYPT)
val keyBytes = sha256.digest(passPhrase.toByteArray(StandardCharsets.UTF_8))
return SecretKeySpec(keyBytes, AES_ALGORITHM)
}
}
@@ -11,7 +11,9 @@ import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.exists
import kotlin.io.path.pathString
import kotlin.io.path.readLines
object ApplicationProperties {
val VERSION: String =
@@ -24,6 +26,11 @@ object ApplicationProperties {
private val json = Json {
ignoreUnknownKeys = true
}
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0"
val USER_AGENT: String
const val DEFAULT_IMX_SUBDOMAINS = "i.imx.to,i001.imx.to,i002.imx.to,i004.imx.to,i006.imx.to,i003.imx.to"
val IMX_SUBDOMAINS: Set<String>
@Serializable
internal data class ReleaseResponse(@SerialName("tag_name") val tagName: String)
@@ -31,6 +38,19 @@ object ApplicationProperties {
init {
Files.createDirectories(VRIPPER_DIR)
System.setProperty("VRIPPER_DIR", VRIPPER_DIR.toRealPath().pathString)
val userAgentOverride = VRIPPER_DIR.resolve("user-agent.txt")
USER_AGENT = if (userAgentOverride.exists()) {
userAgentOverride.readLines().firstOrNull() ?: DEFAULT_USER_AGENT
} else {
DEFAULT_USER_AGENT
}
val imxSubdomainsOverride = VRIPPER_DIR.resolve("imx-subdomains.txt")
IMX_SUBDOMAINS = if (imxSubdomainsOverride.exists()) {
imxSubdomainsOverride.readLines().firstOrNull() ?: DEFAULT_IMX_SUBDOMAINS
} else {
DEFAULT_IMX_SUBDOMAINS
}.split(",").toSet()
}
fun latestVersion(): String {
@@ -7,7 +7,6 @@ import liquibase.database.DatabaseFactory
import liquibase.database.jvm.JdbcConnection
import liquibase.resource.ClassLoaderResourceAccessor
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.TransactionManager
import java.sql.Connection
object DatabaseManager {
@@ -19,11 +18,7 @@ object DatabaseManager {
.also { update(it.connector.invoke().connection as Connection) }
}
fun disconnect() {
database?.let { TransactionManager.closeAndUnregister(it) }
}
fun update(connection: Connection) {
private fun update(connection: Connection) {
connection.use { cn ->
val database: liquibase.database.Database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(JdbcConnection(cn))
@@ -0,0 +1,7 @@
package me.vripper.utilities
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
val taskRunner: ExecutorService = Executors.newFixedThreadPool(6)
val downloadRunner: ExecutorService = Executors.newVirtualThreadPerTaskExecutor()
@@ -0,0 +1,10 @@
package me.vripper.utilities
import java.security.MessageDigest
import javax.xml.bind.DatatypeConverter
fun md5Hex(data: String): String {
val md: MessageDigest = MessageDigest.getInstance("MD5")
md.update(data.toByteArray())
return DatatypeConverter.printHexBinary(md.digest()).lowercase()
}
@@ -1,7 +1,42 @@
package me.vripper.utilities
import kotlinx.coroutines.sync.Semaphore
import io.github.bucket4j.Bucket
import io.github.bucket4j.BucketConfiguration
import io.github.bucket4j.TokensInheritanceStrategy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.services.SettingsService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.time.Duration
internal object RequestLimit {
val semaphore: Semaphore = Semaphore(2)
internal object RequestLimit : KoinComponent {
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val settingsService: SettingsService by inject()
private val eventBus: EventBus by inject()
private val bucket: Bucket = Bucket.builder().addLimit {
it.capacity(settingsService.settings.viperSettings.requestLimit)
.refillGreedy(settingsService.settings.viperSettings.requestLimit, Duration.ofSeconds(1))
}.build()
init {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collectLatest { event ->
bucket.replaceConfiguration(BucketConfiguration.builder().addLimit {
it.capacity(event.settings.viperSettings.requestLimit)
.refillGreedy(event.settings.viperSettings.requestLimit, Duration.ofSeconds(1))
}.build(), TokensInheritanceStrategy.RESET)
}
}
}
fun getPermit(tokens: Long) {
bucket.asBlocking().consume(tokens)
}
}
@@ -1,6 +0,0 @@
package me.vripper.utilities
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
val executorService: ExecutorService = Executors.newVirtualThreadPerTaskExecutor()
@@ -2,13 +2,10 @@ package me.vripper.vgapi
import dev.failsafe.Failsafe
import dev.failsafe.function.CheckedSupplier
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withPermit
import me.vripper.exception.DownloadException
import me.vripper.exception.PostParseException
import me.vripper.services.HTTPService
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
import me.vripper.tasks.Tasks
import me.vripper.utilities.LoggerDelegate
@@ -20,11 +17,9 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.ByteArrayInputStream
import javax.xml.parsers.SAXParserFactory
import kotlin.Throws
internal class PostLookupAPIParser(private val threadId: Long, private val postId: Long) : KoinComponent {
private val log by LoggerDelegate()
private val settingsService: SettingsService by inject()
private val retryPolicyService: RetryPolicyService by inject()
private val httpService: HTTPService by inject()
private val vgAuthService: VGAuthService by inject()
@@ -33,13 +28,12 @@ internal class PostLookupAPIParser(private val threadId: Long, private val postI
fun parse(): ThreadItem? {
log.debug("Parsing post $postId")
val httpGet =
HttpGet(URIBuilder("${settingsService.settings.viperSettings.host}/vr.php").also {
HttpGet(URIBuilder("https://viper.click/vr.php").also {
it.setParameter(
"p", postId.toString()
)
}.build())
}.build()).also { it.setAbsoluteRequestUri(true) }
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting {}", httpGet)
Tasks.increment()
return try {
Failsafe.with(retryPolicyService.buildRetryPolicy<Any>("Failed to parse $httpGet: ")).onFailure {
@@ -47,22 +41,22 @@ internal class PostLookupAPIParser(private val threadId: Long, private val postI
"Failed to process thread $threadId, post $postId", it.exception
)
}.get(CheckedSupplier {
runBlocking {
RequestLimit.semaphore.withPermit {
httpService.client.execute(
httpGet, vgAuthService.context
) { response ->
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
}
ByteArrayInputStream(EntityUtils.toByteArray(response.entity))
}
}.use {
RequestLimit.getPermit(1)
log.info("{}", httpGet)
httpService.client.execute(
httpGet,
vgAuthService.createClickContext()
) { response ->
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
}
ByteArrayInputStream(EntityUtils.toByteArray(response.entity)).use {
factory.newSAXParser()
.parse(it, threadLookupAPIResponseHandler)
threadLookupAPIResponseHandler.result
}
}
})
} catch (e: Exception) {
throw PostParseException(e)
@@ -2,13 +2,10 @@ package me.vripper.vgapi
import dev.failsafe.Failsafe
import dev.failsafe.function.CheckedSupplier
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withPermit
import me.vripper.exception.DownloadException
import me.vripper.exception.PostParseException
import me.vripper.services.HTTPService
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
import me.vripper.tasks.Tasks
import me.vripper.utilities.LoggerDelegate
@@ -20,27 +17,24 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.ByteArrayInputStream
import javax.xml.parsers.SAXParserFactory
import kotlin.Throws
internal class ThreadLookupAPIParser(private val threadId: Long) : KoinComponent {
private val log by 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 {
HttpGet(URIBuilder("https://viper.click/vr.php").also {
it.setParameter(
"t",
threadId.toString()
)
}.build())
}.build()).also { it.setAbsoluteRequestUri(true) }
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting {}", httpGet)
Tasks.increment()
return try {
Failsafe.with(retryPolicyService.buildRetryPolicy<Any>("Failed to parse $httpGet: ")).onFailure {
@@ -49,17 +43,15 @@ internal class ThreadLookupAPIParser(private val threadId: Long) : KoinComponent
it.exception
)
}.get(CheckedSupplier {
runBlocking {
RequestLimit.semaphore.withPermit {
cm.client.execute(
httpGet, vgAuthService.context
) { response ->
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
}
ByteArrayInputStream(EntityUtils.toByteArray(response.entity))
}
}.use {
RequestLimit.getPermit(1)
log.info("{}", httpGet)
cm.client.execute(
httpGet, vgAuthService.createClickContext()
) { response ->
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
}
ByteArrayInputStream(EntityUtils.toByteArray(response.entity)).use {
factory.newSAXParser().parse(
it,
threadLookupAPIResponseHandler
@@ -7,6 +7,8 @@ message ViperSettings {
string password = 3;
bool thanks = 4;
string host = 5;
int64 requestLimit = 6;
bool fetchMetadata = 7;
}
message DownloadSettings {
+5 -6
View File
@@ -19,18 +19,17 @@
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${VRIPPER_DIR}/vripper.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
<maxHistory>5</maxHistory>
</rollingPolicy>
</appender>
<appender name="RING" class="me.vripper.RingAppender">
<appender name="RING" class="me.vripper.RingAppender"/>
</appender>
<logger name="Exposed" level="OFF"/>
<logger name="me.vripper" level="info"/>
<logger name="Exposed" level="off"/>
<logger name="org.springframework.web.socket.config.WebSocketMessageBrokerStats" level="off"/>
<Logger name="org.apache.hc.client5.http" level="off"/>
<Logger name="org.apache.hc.client5.http.wire" level="off"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
+2 -2
View File
@@ -160,14 +160,14 @@
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
<configuration>
<jvmTarget>1.8</jvmTarget>
<jvmTarget>21</jvmTarget>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
@@ -31,7 +31,7 @@ class VripperGuiApplication : App(
private val log by LoggerDelegate()
private var initialized = false
private val widgetsController: WidgetsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
init {
APP_INSTANCE = this
@@ -39,7 +39,7 @@ class VripperGuiApplication : App(
override fun start(stage: Stage) {
Thread.setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler { t, e ->
log.error("Thread $t threw an exception", e)
log.error("Thread $t threw an exception: ${e.message}", e)
})
if (widgetsController.currentSettings.darkMode) {
setUserAgentStylesheet(CupertinoDark().userAgentStylesheet)
@@ -24,31 +24,55 @@ class AboutFragment : Fragment("About") {
spacing = 15.0
imageview("icons/64x64.png")
vbox(spacing = 5.0) {
text("VRipper") {
text("VRipper ${ApplicationProperties.VERSION}") {
style {
fontWeight = FontWeight.BOLD
fontSize = Dimension(18.0, Dimension.LinearUnits.px)
}
}
text("Version ${ApplicationProperties.VERSION}")
text("Developed by dev-claw and VRipper working group")
hbox(spacing = 5.0) {
hyperlink {
imageview("icons/github-mark.png").apply {
isPreserveRatio = true
fitHeight = 32.0
if (widgetsController.currentSettings.darkMode) {
imageview("icons/github-mark-white.png").apply {
isPreserveRatio = true
fitHeight = 32.0
}
} else {
imageview("icons/github-mark.png").apply {
isPreserveRatio = true
fitHeight = 32.0
}
}
action {
openLink("https://github.com/dev-claw/vripper-project")
}
}
hyperlink {
imageview("icons/buymeacoffee-logo.png").apply {
isPreserveRatio = true
fitHeight = 32.0
}
form {
fieldset("Donation") {
minWidth = 400.0
field {
hyperlink {
imageview("icons/buymeacoffee-logo.png").apply {
isPreserveRatio = true
fitHeight = 32.0
}
action {
openLink("https://buymeacoffee.com/devclaw")
}
}
}
action {
openLink("https://buymeacoffee.com/devclaw")
field("ETH:") {
textfield("0xDdac82B16dC5E3D742fc915ffF583D8548A301cA") {
isEditable = false
}
}
field("BTC:") {
textfield("bc1qcqudnkrndwyadsjwrxww42svkf8trnzx3c8vlr") {
isEditable = false
}
}
}
}
@@ -6,16 +6,13 @@ import javafx.geometry.Pos
import javafx.scene.control.TextArea
import javafx.scene.layout.Priority
import javafx.scene.layout.VBox
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import me.vripper.gui.controller.PostController
import tornadofx.*
class AddLinksFragment : Fragment("Add thread links") {
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val textAreaProperty = SimpleStringProperty()
private val postController: PostController by inject()
lateinit var input: TextArea
@@ -9,7 +9,7 @@ import tornadofx.*
class ConnectionSettingsFragment : Fragment("Connection Settings") {
private val settingsController: SettingsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var connectionSettings: ConnectionSettings
val connectionSettingsModel = ConnectionSettingsModel()
@@ -17,7 +17,7 @@ class ConnectionSettingsFragment : Fragment("Connection Settings") {
init {
coroutineScope.launch {
async { connectionSettings = settingsController.findConnectionSettings() }.await()
connectionSettings = settingsController.findConnectionSettings()
runLater {
with(root) {
form {
@@ -13,7 +13,7 @@ class DownloadSettingsFragment : Fragment("Download Settings") {
private val settingsController: SettingsController by inject()
private val widgetsController: WidgetsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var downloadSettings: DownloadSettings
val downloadSettingsModel = DownloadSettingsModel()
@@ -21,16 +21,15 @@ class DownloadSettingsFragment : Fragment("Download Settings") {
init {
coroutineScope.launch {
async {
downloadSettings = settingsController.findDownloadSettings()
downloadSettingsModel.downloadPath = downloadSettings.downloadPath
downloadSettingsModel.autoStart = downloadSettings.autoStart
downloadSettingsModel.forceOrder = downloadSettings.forceOrder
downloadSettingsModel.forumSubfolder = downloadSettings.forumSubDirectory
downloadSettingsModel.threadSubLocation = downloadSettings.threadSubLocation
downloadSettingsModel.clearCompleted = downloadSettings.clearCompleted
downloadSettingsModel.appendPostId = downloadSettings.appendPostId
}.await()
downloadSettings = settingsController.findDownloadSettings() ?: DownloadSettings()
downloadSettingsModel.downloadPath = downloadSettings.downloadPath
downloadSettingsModel.autoStart = downloadSettings.autoStart
downloadSettingsModel.forceOrder = downloadSettings.forceOrder
downloadSettingsModel.forumSubfolder = downloadSettings.forumSubDirectory
downloadSettingsModel.threadSubLocation = downloadSettings.threadSubLocation
downloadSettingsModel.clearCompleted = downloadSettings.clearCompleted
downloadSettingsModel.appendPostId = downloadSettings.appendPostId
runLater {
with(root) {
form {
@@ -2,34 +2,25 @@ package me.vripper.gui.components.fragments
import atlantafx.base.theme.Styles
import atlantafx.base.util.IntegerStringConverter
import io.grpc.ConnectivityState
import javafx.beans.property.SimpleBooleanProperty
import javafx.beans.property.SimpleStringProperty
import javafx.geometry.Pos
import javafx.scene.control.RadioButton
import javafx.scene.control.Spinner
import javafx.scene.control.ToggleGroup
import javafx.scene.layout.VBox
import kotlinx.coroutines.*
import kotlinx.coroutines.runBlocking
import me.vripper.gui.VripperGuiApplication
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.listener.GuiStartupLister
import me.vripper.gui.services.GrpcEndpointService
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.DatabaseManager
import me.vripper.gui.utils.AppEndpointManager
import me.vripper.listeners.AppManager
import tornadofx.*
class SessionFragment : Fragment("Change Session") {
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val widgetsController: WidgetsController by inject()
private val grpcEndpointService: GrpcEndpointService by di("remoteAppEndpointService")
private val appEndpointService: IAppEndpointService by di("localAppEndpointService")
private val toggleGroup = ToggleGroup()
private val disableConfirm = SimpleBooleanProperty(false)
private val message = SimpleStringProperty()
override val root = VBox().apply {
alignment = Pos.CENTER
padding = insets(all = 5)
@@ -60,99 +51,57 @@ class SessionFragment : Fragment("Change Session") {
IntegerStringConverter.createFor(this)
})
}
field("Passcode") {
enableWhen { remoteRadio.selectedProperty() }
passwordfield(widgetsController.currentSettings.remoteSessionModel.passcodeProperty) {}
}
}
}
borderpane {
left {
padding = insets(all = 5.0)
label(message) {
disableWhen { message.isBlank() }
}
}
right {
padding = insets(all = 5.0)
button("Ok") {
enableWhen { toggleGroup.selectedToggleProperty().isNotNull.and(disableConfirm.not()) }
enableWhen { toggleGroup.selectedToggleProperty().isNotNull }
addClass(Styles.ACCENT)
isDefaultButton = true
action {
val selectedToggle = toggleGroup.selectedToggle
if (selectedToggle == null) {
VripperGuiApplication.APP_INSTANCE.stop()
runBlocking {
GuiEventBus.publishEvent(GuiEventBus.ChangingSession)
AppManager.stop()
grpcEndpointService.disconnect()
}
when ((selectedToggle as RadioButton).id) {
"localSession" -> {
coroutineScope.launch {
runLater {
disableConfirm.set(true)
message.set("Starting...")
}
runBlocking {
widgetsController.currentSettings.localSession = true
GuiEventBus.publishEvent(GuiEventBus.ChangingSession)
while (ActiveUICoroutines.all().isNotEmpty()) {
delay(200)
}
grpcEndpointService.disconnect()
DatabaseManager.connect()
GuiStartupLister().run()
AppEndpointManager.set(GuiEventBus.LocalSession)
AppManager.start()
GuiEventBus.publishEvent(GuiEventBus.LocalSession)
runLater {
close()
}
}
}
"remoteSession" -> {
coroutineScope.launch {
runLater {
disableConfirm.set(true)
message.set("Connecting...")
}
if (widgetsController.currentSettings.localSession) {
appEndpointService.stopAll()
}
runBlocking {
widgetsController.currentSettings.localSession = false
GuiEventBus.publishEvent(GuiEventBus.ChangingSession)
while (ActiveUICoroutines.all().isNotEmpty()) {
delay(200)
}
DatabaseManager.disconnect()
grpcEndpointService.disconnect()
AppEndpointManager.set(GuiEventBus.RemoteSession)
grpcEndpointService.connect(
widgetsController.currentSettings.remoteSessionModel.host,
widgetsController.currentSettings.remoteSessionModel.port
widgetsController.currentSettings.remoteSessionModel.port,
widgetsController.currentSettings.remoteSessionModel.passcode,
)
repeat(10) {
if (grpcEndpointService.connectionState() == ConnectivityState.READY) {
GuiEventBus.publishEvent(GuiEventBus.RemoteSession)
runLater {
close()
}
cancel()
}
delay(333)
}
runLater {
runBlocking {
GuiEventBus.publishEvent(GuiEventBus.RemoteSessionFailure)
}
disableConfirm.set(false)
message.set("Unable to connect")
}
GuiEventBus.publishEvent(GuiEventBus.RemoteSession)
}
}
else -> VripperGuiApplication.APP_INSTANCE.stop()
}
runLater {
close()
}
}
}
}
}
}
}
override fun onUndock() {
coroutineScope.cancel()
}
}
@@ -6,10 +6,7 @@ import javafx.scene.control.Alert
import javafx.scene.control.TabPane
import javafx.scene.layout.Priority
import javafx.scene.layout.VBox
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import me.vripper.exception.ValidationException
import me.vripper.gui.controller.SettingsController
import org.kordamp.ikonli.feather.Feather
@@ -20,7 +17,7 @@ import tornadofx.*
class SettingsFragment : Fragment("Settings") {
private val settingsController: SettingsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val downloadSettingsFragment: DownloadSettingsFragment = find()
private val connectionSettingsFragment: ConnectionSettingsFragment = find()
private val viperSettingsFragment: ViperSettingsFragment = find()
@@ -13,7 +13,7 @@ import tornadofx.*
class SystemSettingsFragment : Fragment("System Settings") {
private val settingsController: SettingsController by inject()
private val widgetsController: WidgetsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var systemSettings: SystemSettings
val systemSettingsModel = SystemSettingsModel()
@@ -21,10 +21,8 @@ class SystemSettingsFragment : Fragment("System Settings") {
init {
coroutineScope.launch {
async {
systemSettings = settingsController.findSystemSettings()
systemSettingsModel.tempPath = systemSettings.tempPath
}.await()
systemSettings = settingsController.findSystemSettings()
systemSettingsModel.tempPath = systemSettings.tempPath
runLater {
with(root) {
form {
@@ -2,6 +2,7 @@ package me.vripper.gui.components.fragments
import atlantafx.base.controls.ToggleSwitch
import javafx.collections.FXCollections
import javafx.scene.control.Spinner
import kotlinx.coroutines.*
import me.vripper.gui.controller.SettingsController
import me.vripper.gui.model.settings.ViperSettingsModel
@@ -11,7 +12,7 @@ import tornadofx.*
class ViperSettingsFragment : Fragment("Viper Settings") {
private val settingsController: SettingsController by inject()
private val proxies = FXCollections.observableArrayList<String>()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var viperGirlsSettings: ViperSettings
val viperSettingsModel = ViperSettingsModel()
@@ -19,22 +20,34 @@ class ViperSettingsFragment : Fragment("Viper Settings") {
init {
coroutineScope.launch {
async {
viperGirlsSettings = settingsController.findViperGirlsSettings()
viperSettingsModel.username = viperGirlsSettings.username
viperSettingsModel.password = viperGirlsSettings.password
viperSettingsModel.thanks = viperGirlsSettings.thanks
viperSettingsModel.host = viperGirlsSettings.host
proxies.addAll(settingsController.getProxies())
}.await()
viperGirlsSettings = settingsController.findViperGirlsSettings()
viperSettingsModel.username = viperGirlsSettings.username
viperSettingsModel.password = viperGirlsSettings.password
viperSettingsModel.thanks = viperGirlsSettings.thanks
viperSettingsModel.host = viperGirlsSettings.host
viperSettingsModel.requestLimit = viperGirlsSettings.requestLimit
viperSettingsModel.fetchMetadata = viperGirlsSettings.fetchMetadata
proxies.addAll(settingsController.getProxies())
runLater {
with(root) {
form {
fieldset {
field("Select a proxy") {
field("Viper domain") {
combobox(viperSettingsModel.hostProperty, proxies)
}
field("Enable ViperGirls Authentication") {
field("Rate limit (requests/s)") {
add(Spinner<Int>(1, 5, viperGirlsSettings.requestLimit.toInt()).apply {
viperSettingsModel.requestLimitProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Fetch Metadata") {
checkbox {
bind(viperSettingsModel.fetchMetadataProperty)
}
}
field("Authentication") {
add(ToggleSwitch().apply {
isSelected = viperGirlsSettings.login
viperSettingsModel.loginProperty.bind(selectedProperty())
@@ -42,13 +55,13 @@ class ViperSettingsFragment : Fragment("Viper Settings") {
}
fieldset {
visibleWhen(viperSettingsModel.loginProperty)
field("ViperGirls Username") {
field("Username") {
textfield(viperSettingsModel.usernameProperty)
}
field("ViperGirls Password") {
field("Password") {
passwordfield(viperSettingsModel.passwordProperty)
}
field("Leave like") {
field("Leave likes") {
checkbox {
bind(viperSettingsModel.thanksProperty)
}
@@ -7,50 +7,26 @@ import javafx.scene.control.ButtonType
import javafx.scene.control.ContentDisplay
import javafx.scene.layout.Priority
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import me.vripper.gui.components.Shared
import me.vripper.gui.components.fragments.AddLinksFragment
import me.vripper.gui.components.fragments.SettingsFragment
import me.vripper.gui.controller.ActionBarController
import me.vripper.gui.controller.PostController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
class ActionBarView : View() {
private val logger by LoggerDelegate()
private val downloadActiveProperty = SimpleBooleanProperty(true)
private val postController: PostController by inject()
private val postsTableView: PostsTableView by inject()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val grpcEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val localEndpointService: IAppEndpointService by di("localAppEndpointService")
private val actionBarController: ActionBarController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val running = SimpleIntegerProperty(0)
override val root = toolbar {}
init {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
connect(localEndpointService)
}
is GuiEventBus.RemoteSession -> {
connect(grpcEndpointService)
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.actionBar.forEach { it.cancelAndJoin() }
ActiveUICoroutines.actionBar.clear()
}
}
}
}
with(root) {
id = "action_toolbar"
padding = insets(all = 5)
@@ -127,18 +103,13 @@ class ActionBarView : View() {
}
}
downloadActiveProperty.bind(running.greaterThan(0))
}
private fun connect(appEndpointService: IAppEndpointService) {
coroutineScope.launch {
appEndpointService.onQueueStateUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
actionBarController.onQueueStateUpdate.collect {
runLater {
running.set(it.running)
}
}
}.also { ActiveUICoroutines.actionBar.add(it) }
}
}
}
@@ -19,12 +19,9 @@ import me.vripper.gui.components.cells.ProgressTableCell
import me.vripper.gui.components.cells.StatusTableCell
import me.vripper.gui.controller.ImageController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.ImageModel
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.gui.utils.Preview
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
@@ -35,40 +32,14 @@ class ImagesTableView : View("Photos") {
private val tableView: TableView<ImageModel>
private val imageController: ImageController by inject()
private val widgetsController: WidgetsController by inject()
private val localAppEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteAppEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val items: ObservableList<ImageModel> = FXCollections.observableArrayList()
private var preview: Preview? = null
val jobs = mutableListOf<Job>()
override val root = vbox(alignment = Pos.CENTER_RIGHT) {}
init {
if (widgetsController.currentSettings.localSession) {
imageController.appEndpointService = localAppEndpointService
} else {
imageController.appEndpointService = remoteAppEndpointService
}
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
imageController.appEndpointService = localAppEndpointService
}
is GuiEventBus.RemoteSession -> {
imageController.appEndpointService = remoteAppEndpointService
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.images.forEach { it.cancelAndJoin() }
ActiveUICoroutines.images.clear()
}
}
}
}
with(root) {
tableView = tableview(items) {
isTableMenuButtonVisible = true
@@ -271,8 +242,10 @@ class ImagesTableView : View("Photos") {
}
fun setPostId(postId: Long?) {
ActiveUICoroutines.images.forEach { it.cancel() }
ActiveUICoroutines.images.clear()
runBlocking {
jobs.forEach { it.cancelAndJoin() }
jobs.clear()
}
runLater {
items.clear()
}
@@ -280,15 +253,14 @@ class ImagesTableView : View("Photos") {
return
}
coroutineScope.launch {
val list = coroutineScope.async {
imageController.findImages(postId)
}.await()
val list = imageController.findImages(postId)
runLater {
items.addAll(list)
tableView.sort()
tableView.placeholder = Label("No content in table")
}
}
coroutineScope.launch {
imageController.onUpdateImages(postId).collect { image ->
runLater {
@@ -303,7 +275,7 @@ class ImagesTableView : View("Photos") {
)
}
}
}.also { ActiveUICoroutines.images.add(it) }
}.also { jobs.add(it) }
coroutineScope.launch {
imageController.onStopped().collect {
@@ -315,6 +287,6 @@ class ImagesTableView : View("Photos") {
}
}
}
}.also { ActiveUICoroutines.images.add(it) }
}.also { jobs.add(it) }
}
}
@@ -4,13 +4,13 @@ import javafx.scene.control.ProgressIndicator.INDETERMINATE_PROGRESS
import javafx.scene.effect.DropShadow
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.gui.VripperGuiApplication
import me.vripper.gui.components.fragments.SessionFragment
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.listener.GuiStartupLister
import me.vripper.gui.services.GrpcEndpointService
import me.vripper.gui.utils.AppEndpointManager
import me.vripper.gui.utils.ClipboardManager
import me.vripper.gui.utils.Watcher
import me.vripper.listeners.AppManager
import me.vripper.utilities.DatabaseManager
import tornadofx.*
@@ -18,50 +18,29 @@ class LoadingView : View("VRipper") {
private val widgetsController: WidgetsController by inject()
private val grpcEndpointService: GrpcEndpointService by di("remoteAppEndpointService")
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val root = borderpane {}
init {
DatabaseManager.connect()
ClipboardManager.init()
coroutineScope.launch {
GuiEventBus.events.filterIsInstance(GuiEventBus.ApplicationInitialized::class).collect {
if (widgetsController.currentSettings.localSession) {
DatabaseManager.connect()
GuiStartupLister().run()
runLater {
replaceWith(find<AppView>())
}
AppEndpointManager.set(GuiEventBus.LocalSession)
AppManager.start()
} else {
AppEndpointManager.set(GuiEventBus.RemoteSession)
grpcEndpointService.connect(
widgetsController.currentSettings.remoteSessionModel.host,
widgetsController.currentSettings.remoteSessionModel.port
widgetsController.currentSettings.remoteSessionModel.port,
widgetsController.currentSettings.remoteSessionModel.passcode,
)
repeat(10) {
if (grpcEndpointService.ready()) {
return@repeat
}
delay(333)
}
runLater {
replaceWith(find<AppView>())
runBlocking {
GuiEventBus.publishEvent(GuiEventBus.RemoteSessionFailure)
}
}
if (!grpcEndpointService.ready()) {
val sessionView = find<SessionFragment>()
runLater {
sessionView.openModal()?.apply {
setOnCloseRequest {
VripperGuiApplication.APP_INSTANCE.stop()
}
minWidth = 100.0
minHeight = 100.0
}
}
}
}
runLater {
replaceWith(find<AppView>())
}
if (it.args.isNotEmpty()) {
Watcher.notify(it.args[0])
}
@@ -13,8 +13,6 @@ import me.vripper.gui.controller.LogController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.LogModel
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.services.IAppEndpointService
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
@@ -23,12 +21,10 @@ class LogTableView : View() {
private val logController: LogController by inject()
private val widgetsController: WidgetsController by inject()
private val localAppEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteAppEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val tableView: TableView<LogModel>
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val items: ObservableList<LogModel> = FXCollections.observableArrayList()
private var maxLogEvent = 0
private var maxLogEvent = 100
override val root = vbox {}
@@ -156,72 +152,55 @@ class LogTableView : View() {
tableView.prefHeightProperty().bind(root.heightProperty())
tableView.placeholder = Label("Loading")
tableView.sortOrder.add(tableView.columns.first { it.id == "time" })
}
override fun onDock() {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
runLater {
tableView.placeholder = Label("No Content in table")
launch {
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
while (isActive) {
val result = runCatching { logController.getMaxEventLog() }
if (result.isSuccess) {
maxLogEvent = result.getOrNull()!!
break
}
}
while (isActive) {
val result = runCatching { logController.initLogger() }
if (result.isSuccess) {
break
}
}
}
logController.appEndpointService = localAppEndpointService
connect()
}
is GuiEventBus.RemoteSession -> {
runLater {
tableView.placeholder = Label("No Content in table")
}
logController.appEndpointService = remoteAppEndpointService
connect()
}
is GuiEventBus.RemoteSessionFailure -> {
runLater {
GuiEventBus.ChangingSession -> runLater {
items.clear()
tableView.placeholder = Label("Connection Failure")
}
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.logs.forEach { it.cancelAndJoin() }
ActiveUICoroutines.logs.clear()
runLater {
tableView.placeholder = Label("Loading")
items.clear()
}
}
}
}
}
}
private fun connect() {
runBlocking {
maxLogEvent = logController.appEndpointService.getSettings().systemSettings.maxEventLog
}
coroutineScope.launch {
logController.onNewLog().collect {
runLater {
items.sortWith(Comparator.comparing { it.sequence })
while (items.isNotEmpty() && (items.size >= maxLogEvent)) {
items.removeFirst()
else -> {}
}
items.add(it)
tableView.sort()
}
}
}.also { ActiveUICoroutines.logs.add(it) }
coroutineScope.launch {
logController.appEndpointService.onUpdateSettings().collect {
maxLogEvent = it.systemSettings.maxEventLog
launch {
logController.newLogs.collect {
runLater {
items.sortWith(Comparator.comparing { it.sequence })
while (items.isNotEmpty() && (items.size >= maxLogEvent)) {
items.removeFirst()
}
items.add(it)
tableView.sort()
}
}
}
}.also { ActiveUICoroutines.logs.add(it) }
runLater {
runBlocking {
logController.initLogger()
launch {
logController.updateSettings.collect {
maxLogEvent = it.systemSettings.maxEventLog
}
}
}
}
@@ -7,59 +7,34 @@ import javafx.scene.input.KeyCode
import javafx.scene.input.KeyCodeCombination
import javafx.scene.input.KeyCombination
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import me.vripper.gui.VripperGuiApplication
import me.vripper.gui.components.fragments.AboutFragment
import me.vripper.gui.components.fragments.AddLinksFragment
import me.vripper.gui.components.fragments.SessionFragment
import me.vripper.gui.components.fragments.SettingsFragment
import me.vripper.gui.controller.ActionBarController
import me.vripper.gui.controller.PostController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.ApplicationProperties
import me.vripper.utilities.LoggerDelegate
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
class MenuBarView : View() {
private val logger by LoggerDelegate()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val downloadActiveProperty = SimpleBooleanProperty(false)
private val postsTableView: PostsTableView by inject()
private val widgetsController: WidgetsController by inject()
private val postController: PostController by inject()
private val grpcEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val localEndpointService: IAppEndpointService by di("localAppEndpointService")
private val actionBarController: ActionBarController by inject()
private lateinit var appEndpointService: IAppEndpointService
private val running = SimpleIntegerProperty(0)
override val root = menubar {}
init {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
appEndpointService = localEndpointService
connect(localEndpointService)
}
is GuiEventBus.RemoteSession -> {
appEndpointService = grpcEndpointService
connect(grpcEndpointService)
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.menuBar.forEach { it.cancelAndJoin() }
ActiveUICoroutines.menuBar.clear()
}
}
}
}
with(root) {
menu("File") {
item("Add links", KeyCodeCombination(KeyCode.L, KeyCombination.CONTROL_DOWN)).apply {
@@ -167,7 +142,8 @@ class MenuBarView : View() {
title = "Database Migration"
) {
coroutineScope.launch {
val message = appEndpointService.dbMigration()
val message =
runCatching { appEndpointService.dbMigration() }.getOrDefault("Migration failed")
runLater {
information(
header = "",
@@ -223,18 +199,13 @@ class MenuBarView : View() {
}
}
downloadActiveProperty.bind(running.greaterThan(0))
}
private fun connect(appEndpointService: IAppEndpointService) {
coroutineScope.launch {
appEndpointService.onQueueStateUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
actionBarController.onQueueStateUpdate.collect {
runLater {
running.set(it.running)
}
}
}.also { ActiveUICoroutines.menuBar.add(it) }
}
}
}
@@ -2,23 +2,20 @@ package me.vripper.gui.components.views
import javafx.collections.FXCollections
import javafx.scene.control.TabPane
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.gui.controller.PostController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.PostModel
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.utilities.LoggerDelegate
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
class PostInfoView : View() {
private val logger by LoggerDelegate()
private val postController: PostController by inject()
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val imagesTableView: ImagesTableView by inject()
private val postModel: PostModel = PostModel(
-1, "", 0.0, "", "", 0, 0, "", "", -1, "", "", "", emptyList(), emptyList(), "", 0
@@ -27,12 +24,6 @@ class PostInfoView : View() {
override val root = tabpane()
init {
coroutineScope.launch {
GuiEventBus.events.filterIsInstance(GuiEventBus.ChangingSession::class).collect {
ActiveUICoroutines.postInfo.forEach { it.cancelAndJoin() }
ActiveUICoroutines.postInfo.clear()
}
}
with(root) {
id = "postinfo_panel"
tabClosingPolicy = TabPane.TabClosingPolicy.UNAVAILABLE
@@ -87,8 +78,6 @@ class PostInfoView : View() {
}
fun setPostId(postId: Long?) {
ActiveUICoroutines.postInfo.forEach { it.cancel() }
ActiveUICoroutines.postInfo.clear()
imagesTableView.setPostId(postId)
if (postId == null) {
postModel.apply {
@@ -112,13 +101,7 @@ class PostInfoView : View() {
return
}
coroutineScope.launch {
val model: PostModel? = async {
try {
postController.find(postId)
} catch (e: Exception) {
null
}
}.await()
val model: PostModel? = postController.find(postId)
if (model == null) {
return@launch
}
@@ -144,10 +127,7 @@ class PostInfoView : View() {
}
}
coroutineScope.launch {
postController.onUpdatePosts().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.filter {
postController.updatePostsFlow.filter {
it.postId == postModel.postId
}.collect { post ->
runLater {
@@ -164,13 +144,10 @@ class PostInfoView : View() {
postModel.folderName = post.folderName
}
}
}.also { ActiveUICoroutines.postInfo.add(it) }
}
coroutineScope.launch {
postController.onUpdateMetadata().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.filter {
postController.updateMetadataFlow.filter {
it.postId == postModel.postId
}.collect {
runLater {
@@ -178,6 +155,6 @@ class PostInfoView : View() {
postModel.postedBy = it.data.postedBy
}
}
}.also { ActiveUICoroutines.postInfo.add(it) }
}
}
}
@@ -11,9 +11,13 @@ import javafx.scene.input.KeyCode
import javafx.scene.input.KeyEvent
import javafx.scene.input.MouseButton
import javafx.util.Callback
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.javafx.asFlow
import kotlinx.coroutines.launch
import me.vripper.gui.components.Shared
import me.vripper.gui.components.cells.PreviewTableCell
import me.vripper.gui.components.cells.ProgressTableCell
@@ -24,26 +28,19 @@ import me.vripper.gui.controller.PostController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.PostModel
import me.vripper.gui.services.ClipboardService
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.gui.utils.Preview
import me.vripper.gui.utils.openFileDirectory
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
import kotlin.io.path.Path
class PostsTableView : View() {
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val postController: PostController by inject()
private val widgetsController: WidgetsController by inject()
private val clipboardService: ClipboardService by inject()
private val mainView: MainView by inject()
private val localAppEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteAppEndpointService: IAppEndpointService by di("remoteAppEndpointService")
val tableView: TableView<PostModel>
var items: SortedFilteredList<PostModel> = SortedFilteredList()
@@ -52,7 +49,6 @@ class PostsTableView : View() {
override val root = vbox {}
init {
items.filterWhen(Shared.searchInput) { query, item ->
item.title.contains(query, ignoreCase = true)
|| item.postId.toString().contains(query)
@@ -61,36 +57,6 @@ class PostsTableView : View() {
|| item.status.contains(query, ignoreCase = true)
|| item.path.contains(query, ignoreCase = true)
}
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
postController.appEndpointService = localAppEndpointService
connect()
}
is GuiEventBus.RemoteSession -> {
postController.appEndpointService = remoteAppEndpointService
connect()
}
is GuiEventBus.RemoteSessionFailure -> {
runLater {
items.clear()
tableView.placeholder = Label("Connection Failure")
}
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.posts.forEach { it.cancelAndJoin() }
ActiveUICoroutines.posts.clear()
runLater {
tableView.placeholder = Label("Loading")
}
}
}
}
}
with(root) {
tableView = tableview(items) {
@@ -398,72 +364,83 @@ class PostsTableView : View() {
}
tableView.prefHeightProperty().bind(root.heightProperty())
tableView.placeholder = Label("Loading")
coroutineScope.launch {
launch {
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
val postModelList = postController.findAllPosts().toList()
runLater {
items.addAll(postModelList)
tableView.sort()
tableView.placeholder = Label("No content in table")
}
}
GuiEventBus.ChangingSession -> runLater {
tableView.placeholder = Label("Loading")
items.clear()
}
else -> {}
}
}
}
launch {
postController.updateMetadataFlow.collect {
runLater {
val postModel = items.find { it.postId == it.postId } ?: return@runLater
postModel.altTitles = FXCollections.observableArrayList(it.data.resolvedNames)
postModel.postedBy = it.data.postedBy
}
}
}
launch {
postController.deletedPostsFlow.collect {
runLater {
items.items.removeIf { p -> p.postId == it }
tableView.sort()
}
}
}
launch {
postController.updatePostsFlow.collect { post ->
runLater {
val postModel = items.find { it.postId == post.postId } ?: return@runLater
postModel.status = post.status.name
postModel.progressCount = postController.progressCount(
post.total, post.done, post.downloaded
)
postModel.order = post.rank + 1
postModel.done = post.done
postModel.progress = postController.progress(
post.total, post.done
)
postModel.path = post.getDownloadFolder()
postModel.folderName = post.folderName
}
}
}
launch {
postController.newPostsFlow.collect {
runLater {
items.addAll(it)
tableView.sort()
}
}
}
}
}
private fun isCurrentTab(): Boolean = mainView.root.selectionModel.selectedItem.id == "download-tab"
private fun connect() {
coroutineScope.launch {
val postModelList = async { postController.findAllPosts() }.await()
runLater {
items.clear()
items.addAll(postModelList)
tableView.sort()
tableView.placeholder = Label("No content in table")
clipboardService.init(postController.appEndpointService)
}
}
coroutineScope.launch {
postController.onNewPosts().collect {
runLater {
items.addAll(it)
tableView.sort()
}
}
}.also { ActiveUICoroutines.posts.add(it) }
coroutineScope.launch {
postController.onUpdatePosts().collect { post ->
runLater {
val postModel = items.find { it.postId == post.postId } ?: return@runLater
postModel.status = post.status.name
postModel.progressCount = postController.progressCount(
post.total, post.done, post.downloaded
)
postModel.order = post.rank + 1
postModel.done = post.done
postModel.progress = postController.progress(
post.total, post.done
)
postModel.path = post.getDownloadFolder()
postModel.folderName = post.folderName
}
}
}.also { ActiveUICoroutines.posts.add(it) }
coroutineScope.launch {
postController.onDeletePosts().collect {
runLater {
items.items.removeIf { p -> p.postId == it }
tableView.sort()
}
}
}.also { ActiveUICoroutines.posts.add(it) }
coroutineScope.launch {
postController.onUpdateMetadata().collect {
runLater {
val postModel = items.find { it.postId == it.postId } ?: return@runLater
postModel.altTitles = FXCollections.observableArrayList(it.data.resolvedNames)
postModel.postedBy = it.data.postedBy
}
}
}.also { ActiveUICoroutines.posts.add(it) }
}
private fun rename(post: PostModel) {
find<RenameFragment>(
mapOf(
@@ -477,13 +454,6 @@ class PostsTableView : View() {
}
}
fun renameSelected() {
val selectedItem = tableView.selectionModel.selectedItem
if (selectedItem != null) {
rename(selectedItem)
}
}
fun bulkRenameSelected() {
val selectedItems = tableView.selectionModel.selectedItems
coroutineScope.launch {
@@ -6,21 +6,16 @@ import javafx.beans.property.SimpleStringProperty
import javafx.geometry.Orientation
import javafx.geometry.Pos
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import me.vripper.gui.controller.StatusBarController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.formatSI
import tornadofx.*
class StatusBarView : View("Status bar") {
private val logger by LoggerDelegate()
private val widgetsController: WidgetsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob())
private val grpcEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val localEndpointService: IAppEndpointService by di("localAppEndpointService")
private val statusBarController: StatusBarController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val remoteText = SimpleStringProperty()
private val loggedUser = SimpleStringProperty()
private val tasksRunning = SimpleBooleanProperty(false)
@@ -31,121 +26,82 @@ class StatusBarView : View("Status bar") {
init {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
runLater {
tasksRunning.set(false)
downloadSpeed.set(0L.formatSI())
running.set(0)
pending.set(0)
error.set(0)
}
when (event) {
is GuiEventBus.LocalSession -> {
connect(localEndpointService)
}
is GuiEventBus.RemoteSession -> {
connect(grpcEndpointService)
}
is GuiEventBus.RemoteSessionFailure -> {
runLater {
remoteText.set("Unable to connect to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}")
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
while (isActive) {
val result = runCatching { statusBarController.loggedInUser() }
if (result.isSuccess) {
runLater {
loggedUser.set(result.getOrNull())
}
break
}
delay(1000)
}
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.statusBar.forEach { it.cancelAndJoin() }
ActiveUICoroutines.statusBar.clear()
runLater {
remoteText.set("Connecting to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}")
}
}
else -> {}
}
}
}
}
private fun connect(endpointService: IAppEndpointService) {
coroutineScope.launch {
val user = async { endpointService.loggedInUser() }.await()
runLater {
loggedUser.set(user)
}
}
coroutineScope.launch {
endpointService.onVGUserUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.vgUserUpdate.collect {
runLater {
loggedUser.set(it)
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
}
coroutineScope.launch {
endpointService.onTasksRunning().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.tasksRunning.collect {
runLater {
tasksRunning.set(it)
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
}
coroutineScope.launch {
endpointService.onDownloadSpeed().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.downloadSpeed.collect {
runLater {
downloadSpeed.set(it.speed.formatSI())
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
}
coroutineScope.launch {
endpointService.onQueueStateUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.queueStateUpdate.collect {
runLater {
running.set(it.running)
pending.set(it.remaining)
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
}
coroutineScope.launch {
endpointService.onErrorCountUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.errorCountUpdate.collect {
runLater {
error.set(it.count)
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
if (widgetsController.currentSettings.localSession) {
runLater {
remoteText.set("")
}
} else {
coroutineScope.launch {
if (grpcEndpointService.ready()) {
val version = grpcEndpointService.getVersion()
runLater {
remoteText.set("Connected to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port} v$version")
}
} else {
runLater {
remoteText.set("Unable to connect to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}")
}
}
coroutineScope.launch {
while (isActive) {
val text = when (statusBarController.connectionState()) {
"CONNECTING" -> "Connecting to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}"
"READY" -> "Connected to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port} ${statusBarController.getVersion()}"
"TRANSIENT_FAILURE" -> "Failing to connect to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}"
"IDLE" -> "Idle connection to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}"
"SHUTDOWN" -> "Connection shutdown to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}"
else -> ""
}
runLater {
remoteText.set(text)
}
delay(1000)
}
}
}
@@ -7,59 +7,35 @@ import javafx.collections.ObservableList
import javafx.scene.control.*
import javafx.scene.input.KeyCode
import javafx.scene.input.KeyEvent
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.javafx.asFlow
import kotlinx.coroutines.launch
import me.vripper.gui.components.fragments.ThreadSelectionTableFragment
import me.vripper.gui.controller.ThreadController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.ThreadModel
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
class ThreadTableView : View() {
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val threadController: ThreadController by inject()
private val widgetsController: WidgetsController by inject()
private val mainView: MainView by inject()
private val localAppEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteAppEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val tableView: TableView<ThreadModel>
private val items: ObservableList<ThreadModel> = FXCollections.observableArrayList()
override val root = vbox {}
init {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
threadController.appEndpointService = localAppEndpointService
connect()
}
is GuiEventBus.RemoteSession -> {
threadController.appEndpointService = remoteAppEndpointService
connect()
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.threads.forEach { it.cancelAndJoin() }
ActiveUICoroutines.threads.clear()
runLater {
tableView.placeholder = Label("Loading")
}
}
}
}
}
with(root) {
tableView = tableview(items) {
isTableMenuButtonVisible = true
@@ -179,52 +155,65 @@ class ThreadTableView : View() {
})
tableView.prefHeightProperty().bind(root.heightProperty())
tableView.placeholder = Label("Loading")
}
fun connect() {
coroutineScope.launch {
val list = async {
threadController.findAll()
}.await()
runLater {
items.clear()
items.addAll(list)
tableView.placeholder = Label("No content in table")
launch {
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
val list = threadController.findAll().toList()
runLater {
items.clear()
items.addAll(list)
tableView.placeholder = Label("No content in table")
}
}
GuiEventBus.ChangingSession -> runLater {
tableView.placeholder = Label("Loading")
items.clear()
}
else -> {}
}
}
}
launch {
threadController.newThread.collect {
runLater {
items.add(it)
}
}
}
launch {
threadController.updateThread.collect { thread ->
runLater {
val threadModel = items.find { it.threadId == thread.threadId } ?: return@runLater
threadModel.total = thread.total
threadModel.title = thread.title
}
}
}
launch {
threadController.deleteThread.collect { threadId ->
runLater {
tableView.items.removeIf { it.threadId == threadId }
}
}
}
launch {
threadController.clearThreads.collect {
runLater {
tableView.items.clear()
}
}
}
}
coroutineScope.launch {
threadController.onNewThread().collect {
runLater {
items.add(it)
}
}
}.also { ActiveUICoroutines.threads.add(it) }
coroutineScope.launch {
threadController.onUpdateThread().collect { thread ->
runLater {
val threadModel = items.find { it.threadId == thread.threadId } ?: return@runLater
threadModel.total = thread.total
threadModel.title = thread.title
}
}
}.also { ActiveUICoroutines.threads.add(it) }
coroutineScope.launch {
threadController.onDeleteThread().collect { threadId ->
runLater {
tableView.items.removeIf { it.threadId == threadId }
}
}
}.also { ActiveUICoroutines.threads.add(it) }
coroutineScope.launch {
threadController.onClearThreads().collect {
runLater {
tableView.items.clear()
}
}
}.also { ActiveUICoroutines.threads.add(it) }
}
private fun isCurrentTab(): Boolean = mainView.root.selectionModel.selectedItem.id == "thread-tab"
@@ -0,0 +1,14 @@
package me.vripper.gui.controller
import me.vripper.gui.utils.AppEndpointManager.localAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.remoteAppEndpointService
import me.vripper.gui.utils.ChannelFlowBuilder
import tornadofx.Controller
class ActionBarController : Controller() {
val onQueueStateUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onQueueStateUpdate,
remoteAppEndpointService::onQueueStateUpdate
)
}
@@ -1,19 +1,18 @@
package me.vripper.gui.controller
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.retryWhen
import me.vripper.gui.model.ImageModel
import me.vripper.gui.utils.AppEndpointManager.currentAppEndpointService
import me.vripper.model.Image
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import tornadofx.Controller
class ImageController : Controller() {
private val logger by LoggerDelegate()
lateinit var appEndpointService: IAppEndpointService
suspend fun findImages(postId: Long): List<ImageModel> {
return appEndpointService.findImagesByPostId(postId).map(::mapper)
return runCatching { currentAppEndpointService().findImagesByPostId(postId).map(::mapper) }.getOrDefault(
emptyList()
)
}
private fun mapper(it: Image): ImageModel {
@@ -39,14 +38,7 @@ class ImageController : Controller() {
}
fun onUpdateImages(postId: Long) =
appEndpointService.onUpdateImagesByPostId(postId).catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onStopped() = appEndpointService.onStopped().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
currentAppEndpointService().onUpdateImagesByPostId(postId).retryWhen { _, _ -> delay(1000);true }
fun onStopped() = currentAppEndpointService().onStopped().retryWhen { _, _ -> delay(1000);true }
}
@@ -1,18 +1,26 @@
package me.vripper.gui.controller
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import me.vripper.gui.model.LogModel
import me.vripper.gui.utils.AppEndpointManager.currentAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.localAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.remoteAppEndpointService
import me.vripper.gui.utils.ChannelFlowBuilder
import me.vripper.model.LogEntry
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import tornadofx.Controller
class LogController : Controller() {
private val logger by LoggerDelegate()
lateinit var appEndpointService: IAppEndpointService
val newLogs = ChannelFlowBuilder.build(
{ localAppEndpointService.onNewLog().map(::mapper) },
{ remoteAppEndpointService.onNewLog().map(::mapper) }
)
val updateSettings =
ChannelFlowBuilder.build(
localAppEndpointService::onUpdateSettings,
remoteAppEndpointService::onUpdateSettings
)
private fun mapper(it: LogEntry): LogModel {
return LogModel(
@@ -26,12 +34,11 @@ class LogController : Controller() {
)
}
fun onNewLog() = appEndpointService.onNewLog().map(::mapper).catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
suspend fun initLogger() {
currentAppEndpointService().initLogger()
}
suspend fun initLogger() {
appEndpointService.initLogger()
suspend fun getMaxEventLog(): Int {
return currentAppEndpointService().getSettings().systemSettings.maxEventLog
}
}
@@ -1,58 +1,93 @@
package me.vripper.gui.controller
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.vripper.gui.model.PostModel
import me.vripper.gui.utils.AppEndpointManager.currentAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.localAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.remoteAppEndpointService
import me.vripper.gui.utils.ChannelFlowBuilder
import me.vripper.gui.utils.ChannelFlowBuilder.toFlow
import me.vripper.model.Post
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.formatSI
import tornadofx.Controller
import java.time.format.DateTimeFormatter
class PostController : Controller() {
private val logger by LoggerDelegate()
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss")
lateinit var appEndpointService: IAppEndpointService
val updatePostsFlow =
ChannelFlowBuilder.build(
localAppEndpointService::onUpdatePosts,
remoteAppEndpointService::onUpdatePosts
)
val newPostsFlow = ChannelFlowBuilder.build(
{
localAppEndpointService.onNewPosts().map { post ->
mapper(post)
}
}, {
remoteAppEndpointService.onNewPosts().map { post ->
mapper(post)
}
}
)
val deletedPostsFlow =
ChannelFlowBuilder.build(
localAppEndpointService::onDeletePosts,
remoteAppEndpointService::onDeletePosts
)
val updateMetadataFlow = ChannelFlowBuilder.build(
localAppEndpointService::onUpdateMetadata,
remoteAppEndpointService::onUpdateMetadata,
)
suspend fun scan(postLinks: String) {
appEndpointService.scanLinks(postLinks)
runCatching { currentAppEndpointService().scanLinks(postLinks) }
}
suspend fun start(postIdList: List<Long>) {
appEndpointService.restartAll(postIdList)
runCatching { currentAppEndpointService().restartAll(postIdList) }
}
suspend fun startAll() {
appEndpointService.restartAll()
runCatching { currentAppEndpointService().restartAll() }
}
suspend fun delete(postIdList: List<Long>) {
appEndpointService.remove(postIdList)
runCatching { currentAppEndpointService().remove(postIdList) }
}
suspend fun stop(postIdList: List<Long>) {
appEndpointService.stopAll(postIdList)
runCatching { currentAppEndpointService().stopAll(postIdList) }
}
suspend fun clearPosts(): List<Long> {
return appEndpointService.clearCompleted()
return runCatching { return currentAppEndpointService().clearCompleted() }.getOrDefault(emptyList())
}
suspend fun stopAll() {
appEndpointService.stopAll()
runCatching { currentAppEndpointService().stopAll() }
}
suspend fun find(postId: Long): PostModel {
return mapper(appEndpointService.findPost(postId))
suspend fun find(postId: Long): PostModel? {
return runCatching { mapper(currentAppEndpointService().findPost(postId)) }.getOrNull()
}
suspend fun findAllPosts(): List<PostModel> {
return appEndpointService.findAllPosts().map(::mapper)
fun findAllPosts(): Flow<PostModel> {
return toFlow { currentAppEndpointService().findAllPosts().map(::mapper) }
}
suspend fun rename(postId: Long, value: String) {
runCatching { currentAppEndpointService().rename(postId, value) }
}
suspend fun renameToFirst(postIds: List<Long>) {
runCatching { currentAppEndpointService().renameToFirst(postIds) }
}
private fun mapper(post: Post): PostModel {
@@ -84,39 +119,4 @@ class PostController : Controller() {
fun progress(total: Int, done: Int): Double {
return if (done == 0 && total == 0) 0.0 else (done.toDouble() / total)
}
suspend fun rename(postId: Long, value: String) {
appEndpointService.rename(postId, value)
}
suspend fun renameToFirst(postIds: List<Long>) {
appEndpointService.renameToFirst(postIds)
}
fun onNewPosts() =
appEndpointService.onNewPosts().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.map { post ->
mapper(post)
}
fun onUpdatePosts() =
appEndpointService.onUpdatePosts().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onDeletePosts() =
appEndpointService.onDeletePosts().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onUpdateMetadata() =
appEndpointService.onUpdateMetadata().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
}
@@ -4,29 +4,36 @@ import me.vripper.gui.model.settings.ConnectionSettingsModel
import me.vripper.gui.model.settings.DownloadSettingsModel
import me.vripper.gui.model.settings.SystemSettingsModel
import me.vripper.gui.model.settings.ViperSettingsModel
import me.vripper.gui.utils.AppEndpointManager
import me.vripper.model.*
import me.vripper.services.IAppEndpointService
import tornadofx.Controller
class SettingsController : Controller() {
private val widgetsController: WidgetsController by inject()
private val appEndpointService: IAppEndpointService by di(if (widgetsController.currentSettings.localSession) "localAppEndpointService" else "remoteAppEndpointService")
suspend fun findDownloadSettings(): DownloadSettings {
return appEndpointService.getSettings().downloadSettings
return runCatching {
AppEndpointManager.currentAppEndpointService().getSettings().downloadSettings
}.getOrDefault(
DownloadSettings()
)
}
suspend fun findConnectionSettings(): ConnectionSettings {
return appEndpointService.getSettings().connectionSettings
return runCatching {
AppEndpointManager.currentAppEndpointService().getSettings().connectionSettings
}.getOrDefault(ConnectionSettings())
}
suspend fun findViperGirlsSettings(): ViperSettings {
return appEndpointService.getSettings().viperSettings
return runCatching { AppEndpointManager.currentAppEndpointService().getSettings().viperSettings }.getOrDefault(
ViperSettings()
)
}
suspend fun findSystemSettings(): SystemSettings {
return appEndpointService.getSettings().systemSettings
return runCatching { AppEndpointManager.currentAppEndpointService().getSettings().systemSettings }.getOrDefault(
SystemSettings()
)
}
suspend fun saveNewSettings(
@@ -35,43 +42,47 @@ class SettingsController : Controller() {
viperSettingsModel: ViperSettingsModel,
systemSettingsModel: SystemSettingsModel
) {
appEndpointService.saveSettings(
Settings(
downloadSettings = DownloadSettings(
downloadSettingsModel.downloadPath,
downloadSettingsModel.autoStart,
downloadSettingsModel.autoQueueThreshold,
downloadSettingsModel.forceOrder,
downloadSettingsModel.forumSubfolder,
downloadSettingsModel.threadSubLocation,
downloadSettingsModel.clearCompleted,
downloadSettingsModel.appendPostId
),
connectionSettings = ConnectionSettings(
connectionSettingsModel.maxThreads,
connectionSettingsModel.maxTotalThreads,
connectionSettingsModel.timeout,
connectionSettingsModel.maxAttempts,
),
viperSettings = ViperSettings(
viperSettingsModel.login,
viperSettingsModel.username,
viperSettingsModel.password,
viperSettingsModel.thanks,
viperSettingsModel.host,
),
systemSettings =
SystemSettings(
systemSettingsModel.tempPath,
systemSettingsModel.enable,
systemSettingsModel.pollingRate,
systemSettingsModel.logEntries
runCatching {
AppEndpointManager.currentAppEndpointService().saveSettings(
Settings(
downloadSettings = DownloadSettings(
downloadSettingsModel.downloadPath,
downloadSettingsModel.autoStart,
downloadSettingsModel.autoQueueThreshold,
downloadSettingsModel.forceOrder,
downloadSettingsModel.forumSubfolder,
downloadSettingsModel.threadSubLocation,
downloadSettingsModel.clearCompleted,
downloadSettingsModel.appendPostId
),
connectionSettings = ConnectionSettings(
connectionSettingsModel.maxThreads,
connectionSettingsModel.maxTotalThreads,
connectionSettingsModel.timeout,
connectionSettingsModel.maxAttempts,
),
viperSettings = ViperSettings(
viperSettingsModel.login,
viperSettingsModel.username,
viperSettingsModel.password,
viperSettingsModel.thanks,
viperSettingsModel.host,
viperSettingsModel.requestLimit,
viperSettingsModel.fetchMetadata,
),
systemSettings =
SystemSettings(
systemSettingsModel.tempPath,
systemSettingsModel.enable,
systemSettingsModel.pollingRate,
systemSettingsModel.logEntries
)
)
)
)
}
}
suspend fun getProxies(): List<String> {
return appEndpointService.getProxies()
return runCatching { AppEndpointManager.currentAppEndpointService().getProxies() }.getOrDefault(emptyList())
}
}
@@ -0,0 +1,46 @@
package me.vripper.gui.controller
import me.vripper.gui.utils.AppEndpointManager.currentAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.localAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.remoteAppEndpointService
import me.vripper.gui.utils.ChannelFlowBuilder
import tornadofx.Controller
class StatusBarController : Controller() {
suspend fun loggedInUser(): String {
return currentAppEndpointService().loggedInUser()
}
fun connectionState(): String {
return currentAppEndpointService().connectionState()
}
suspend fun getVersion(): String {
return currentAppEndpointService().getVersion()
}
val vgUserUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onVGUserUpdate,
remoteAppEndpointService::onVGUserUpdate,
)
val tasksRunning = ChannelFlowBuilder.build(
localAppEndpointService::onTasksRunning,
remoteAppEndpointService::onTasksRunning,
)
val downloadSpeed = ChannelFlowBuilder.build(
localAppEndpointService::onDownloadSpeed,
remoteAppEndpointService::onDownloadSpeed,
)
val queueStateUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onQueueStateUpdate,
remoteAppEndpointService::onQueueStateUpdate,
)
val errorCountUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onErrorCountUpdate,
remoteAppEndpointService::onErrorCountUpdate
)
}
@@ -1,24 +1,47 @@
package me.vripper.gui.controller
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.vripper.entities.ThreadEntity
import me.vripper.gui.model.ThreadModel
import me.vripper.gui.model.ThreadSelectionModel
import me.vripper.gui.utils.AppEndpointManager.currentAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.localAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.remoteAppEndpointService
import me.vripper.gui.utils.ChannelFlowBuilder
import me.vripper.gui.utils.ChannelFlowBuilder.toFlow
import me.vripper.model.ThreadPostId
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import org.koin.core.component.KoinComponent
import tornadofx.Controller
class ThreadController : KoinComponent, Controller() {
private val logger by LoggerDelegate()
lateinit var appEndpointService: IAppEndpointService
suspend fun findAll(): List<ThreadModel> {
return appEndpointService.findAllThreads().map(::threadModelMapper)
val newThread = ChannelFlowBuilder.build(
{
localAppEndpointService.onNewThread().map(::threadModelMapper)
},
{
remoteAppEndpointService.onNewThread().map(::threadModelMapper)
},
)
val updateThread = ChannelFlowBuilder.build(
localAppEndpointService::onUpdateThread,
remoteAppEndpointService::onUpdateThread,
)
val deleteThread = ChannelFlowBuilder.build(
localAppEndpointService::onDeleteThread,
remoteAppEndpointService::onDeleteThread,
)
val clearThreads = ChannelFlowBuilder.build(
localAppEndpointService::onClearThreads,
remoteAppEndpointService::onClearThreads,
)
fun findAll(): Flow<ThreadModel> {
return toFlow { currentAppEndpointService().findAllThreads().map(::threadModelMapper) }
}
private fun threadModelMapper(it: ThreadEntity): ThreadModel {
@@ -31,15 +54,15 @@ class ThreadController : KoinComponent, Controller() {
}
suspend fun delete(threadIdList: List<Long>) {
appEndpointService.threadRemove(threadIdList)
currentAppEndpointService().threadRemove(threadIdList)
}
suspend fun clearAll() {
appEndpointService.threadClear()
currentAppEndpointService().threadClear()
}
suspend fun grab(threadId: Long): List<ThreadSelectionModel> =
appEndpointService.grab(threadId).map { postItem ->
currentAppEndpointService().grab(threadId).map { postItem ->
ThreadSelectionModel(
postItem.number,
postItem.title,
@@ -51,33 +74,12 @@ class ThreadController : KoinComponent, Controller() {
)
}
suspend fun download(selectedItems: List<ThreadSelectionModel>) {
appEndpointService.download(selectedItems.map {
currentAppEndpointService().download(selectedItems.map {
ThreadPostId(
it.threadId,
it.postId
)
})
}
fun onNewThread() = appEndpointService.onNewThread().map(::threadModelMapper).catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onUpdateThread() = appEndpointService.onUpdateThread().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onDeleteThread() = appEndpointService.onDeleteThread().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onClearThreads() = appEndpointService.onClearThreads().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
}
@@ -235,6 +235,9 @@ class WidgetsController : Controller() {
currentSettings.remoteSessionModel.portProperty.onChange {
WidgetSettings.update(currentSettings)
}
currentSettings.remoteSessionModel.passcodeProperty.onChange {
WidgetSettings.update(currentSettings)
}
currentSettings.cachePathProperty.onChange {
WidgetSettings.update(currentSettings)
}
@@ -1,20 +1,22 @@
package me.vripper.gui.event
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object GuiEventBus {
private val _events = MutableSharedFlow<Any>()
private val _events = MutableSharedFlow<GUIEvent>(0, Int.MAX_VALUE, BufferOverflow.DROP_OLDEST)
val events = _events.asSharedFlow()
suspend fun publishEvent(event: Any) {
suspend fun publishEvent(event: GUIEvent) {
_events.emit(event)
}
data class ApplicationInitialized(val args: List<String>)
object ChangingSession
object LocalSession
object RemoteSession
object RemoteSessionFailure
data class ApplicationInitialized(val args: List<String>) : GUIEvent
object ChangingSession : GUIEvent
object LocalSession : GUIEvent
object RemoteSession : GUIEvent
}
sealed interface GUIEvent
@@ -1,10 +0,0 @@
package me.vripper.gui.listener
import me.vripper.listeners.OnStartupListener
class GuiStartupLister : OnStartupListener() {
override fun run() {
super.run()
}
}
@@ -8,10 +8,14 @@ import tornadofx.setValue
class RemoteSessionModel(
host: String,
port: Int,
passcode: String,
) {
val hostProperty = SimpleStringProperty(host)
var host: String by hostProperty
val portProperty = SimpleIntegerProperty(port)
var port: Int by portProperty
val passcodeProperty = SimpleStringProperty(passcode)
var passcode: String by passcodeProperty
}
@@ -159,6 +159,7 @@ class WidgetsViewModel(
val remoteSessionModel = RemoteSessionModel(
remoteSession.host,
remoteSession.port
remoteSession.port,
remoteSession.passPhrase,
)
}
@@ -1,6 +1,7 @@
package me.vripper.gui.model.settings
import javafx.beans.property.SimpleBooleanProperty
import javafx.beans.property.SimpleLongProperty
import javafx.beans.property.SimpleStringProperty
import tornadofx.getValue
import tornadofx.setValue
@@ -20,4 +21,10 @@ class ViperSettingsModel {
val hostProperty = SimpleStringProperty()
var host: String by hostProperty
val requestLimitProperty = SimpleLongProperty()
var requestLimit: Long by requestLimitProperty
val fetchMetadataProperty = SimpleBooleanProperty()
var fetchMetadata: Boolean by fetchMetadataProperty
}
@@ -0,0 +1,22 @@
package me.vripper.gui.services
import io.grpc.*
import io.grpc.ForwardingClientCall.SimpleForwardingClientCall
class ClientE2eEncryptingInterceptor(private val passPhrase: String) : ClientInterceptor {
override fun <ReqT, RespT> interceptCall(
method: MethodDescriptor<ReqT?, RespT?>,
callOptions: CallOptions?, next: Channel
): ClientCall<ReqT, RespT> {
return object : SimpleForwardingClientCall<ReqT, RespT>(
next.newCall(
method.toBuilder(
ClientRequestEncryptor(method.getRequestMarshaller(), passPhrase),
ClientResponseDecryptor(method.getResponseMarshaller(), passPhrase)
).build(),
callOptions
)
) {}
}
}
@@ -0,0 +1,22 @@
package me.vripper.gui.services
import io.grpc.MethodDescriptor
import me.vripper.utilities.AesUtils
import java.io.ByteArrayInputStream
import java.io.InputStream
class ClientRequestEncryptor<T>(
private val protosMarshaller: MethodDescriptor.Marshaller<T>,
private val passPhrase: String
) :
MethodDescriptor.Marshaller<T> {
override fun stream(value: T): InputStream {
return ByteArrayInputStream(AesUtils.aesEncrypt(protosMarshaller.stream(value).readBytes(), passPhrase))
}
override fun parse(stream: InputStream): T {
return protosMarshaller.parse(stream)
}
}
@@ -0,0 +1,22 @@
package me.vripper.gui.services
import io.grpc.MethodDescriptor
import me.vripper.utilities.AesUtils
import java.io.ByteArrayInputStream
import java.io.InputStream
class ClientResponseDecryptor<T>(
private val protoMarshaller: MethodDescriptor.Marshaller<T>,
private val passPhrase: String
) :
MethodDescriptor.Marshaller<T> {
override fun stream(value: T): InputStream {
return protoMarshaller.stream(value)
}
override fun parse(encryptedStream: InputStream): T {
return protoMarshaller.parse(ByteArrayInputStream(AesUtils.aesDecrypt(encryptedStream.readBytes(), passPhrase)))
}
}
@@ -1,61 +0,0 @@
package me.vripper.gui.services
import javafx.scene.input.Clipboard
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.gui.event.GuiEventBus
import me.vripper.model.Settings
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import tornadofx.Controller
import tornadofx.runLater
class ClipboardService : Controller() {
private val logger by LoggerDelegate()
private var current: String? = null
private var coroutineScope = CoroutineScope(SupervisorJob())
private var pollJob: Job? = null
private var subscribeJob: Job? = null
fun init(appEndpointService: IAppEndpointService) {
subscribeJob?.cancel()
subscribeJob = coroutineScope.launch {
appEndpointService.onUpdateSettings().catch { logger.error("gRPC error", it) }.collect {
run(it, appEndpointService)
}
}
coroutineScope.launch {
GuiEventBus.events.filterIsInstance(GuiEventBus.ChangingSession::class).collect {
subscribeJob?.cancel()
}
}
runBlocking {
run(appEndpointService.getSettings(), appEndpointService)
}
}
private fun run(settings: Settings, appEndpointService: IAppEndpointService) {
pollJob?.cancel()
if (settings.systemSettings.enableClipboardMonitoring) {
pollJob = coroutineScope.launch {
var value: String? = null
while (isActive) {
runLater {
val clipboard = Clipboard.getSystemClipboard()
if (clipboard.hasString()) {
value = clipboard.string
}
}
if (!value.isNullOrBlank() && value != current) {
current = value
appEndpointService.scanLinks(value)
}
delay(settings.systemSettings.clipboardPollingRate.toLong())
}
}
} else {
current = null
}
}
}
@@ -20,7 +20,7 @@ import me.vripper.services.IAppEndpointService
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class GrpcEndpointService : IAppEndpointService {
internal class GrpcEndpointService : IAppEndpointService {
private var channel: ManagedChannel? = null
private var endpointServiceCoroutineStub: EndpointServiceGrpcKt.EndpointServiceCoroutineStub? = null
@@ -48,11 +48,9 @@ class GrpcEndpointService : IAppEndpointService {
override suspend fun findPost(postId: Long): Post =
mapper(endpointServiceCoroutineStub!!.findPost(Id.newBuilder().setId(postId).build()))
override suspend fun findAllPosts(): List<Post> =
endpointServiceCoroutineStub!!.findAllPosts(EmptyRequest.getDefaultInstance()).postsList.map(::mapper)
override suspend fun rename(postId: Long, newName: String) {
endpointServiceCoroutineStub!!.rename(
Rename.newBuilder().setPostId(postId).setName(newName).build()
@@ -176,6 +174,8 @@ class GrpcEndpointService : IAppEndpointService {
password = settings.viperSettings.password
thanks = settings.viperSettings.thanks
host = settings.viperSettings.host
requestLimit = settings.viperSettings.requestLimit
fetchMetadata = settings.viperSettings.fetchMetadata
build()
}
@@ -241,6 +241,7 @@ class GrpcEndpointService : IAppEndpointService {
password = settings.viperSettings.password,
thanks = settings.viperSettings.thanks,
host = settings.viperSettings.host,
requestLimit = settings.viperSettings.requestLimit,
),
downloadSettings = DownloadSettings(
downloadPath = settings.downloadSettings.downloadPath,
@@ -353,8 +354,14 @@ class GrpcEndpointService : IAppEndpointService {
)
}
fun connect(host: String, port: Int) {
channel = ManagedChannelBuilder.forAddress(host, port).maxInboundMessageSize(134217728).usePlaintext().build()
fun connect(host: String, port: Int, passPhrase: String) {
disconnect()
channel = ManagedChannelBuilder
.forAddress(host, port)
.maxInboundMessageSize(Integer.MAX_VALUE)
.usePlaintext()
.intercept(ClientE2eEncryptingInterceptor(passPhrase))
.build()
endpointServiceCoroutineStub = EndpointServiceGrpcKt.EndpointServiceCoroutineStub(channel!!)
}
@@ -366,9 +373,5 @@ class GrpcEndpointService : IAppEndpointService {
}
}
fun connectionState(): ConnectivityState = channel?.getState(true) ?: ConnectivityState.SHUTDOWN
override fun ready(): Boolean {
return connectionState() == ConnectivityState.READY
}
}
override fun connectionState(): String = channel?.getState(true)?.name ?: ConnectivityState.SHUTDOWN.name
}
@@ -1,25 +0,0 @@
package me.vripper.gui.utils
import kotlinx.coroutines.Job
object ActiveUICoroutines {
val posts: MutableList<Job> = mutableListOf()
val actionBar: MutableList<Job> = mutableListOf()
val images: MutableList<Job> = mutableListOf()
val logs: MutableList<Job> = mutableListOf()
val menuBar: MutableList<Job> = mutableListOf()
val postInfo: MutableList<Job> = mutableListOf()
val statusBar: MutableList<Job> = mutableListOf()
val threads: MutableList<Job> = mutableListOf()
fun all() = listOf(
posts,
actionBar,
images,
logs,
menuBar,
postInfo,
statusBar,
threads
).flatten()
}
@@ -0,0 +1,32 @@
package me.vripper.gui.utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import me.vripper.gui.event.GUIEvent
import me.vripper.gui.event.GuiEventBus
import me.vripper.services.IAppEndpointService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
object AppEndpointManager : KoinComponent {
val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val localAppEndpointService: IAppEndpointService by inject(named("localAppEndpointService"))
val remoteAppEndpointService: IAppEndpointService by inject(named("remoteAppEndpointService"))
private lateinit var current: GUIEvent
fun set(event: GUIEvent) {
this.current = event
}
fun currentAppEndpointService(): IAppEndpointService {
return when (current) {
GuiEventBus.LocalSession -> localAppEndpointService
GuiEventBus.RemoteSession -> remoteAppEndpointService
else -> throw IllegalStateException("Unknown current state: $current")
}
}
}

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