Compare commits

...
73 Commits
Author SHA1 Message Date
dev-claw eec3d5d75c Update version 2026-04-20 17:41:37 +01:00
dev-clawandGitHub 3f5127cbe3 fixes #298 (#299) 2026-04-20 17:40:32 +01:00
dev-claw b28fbb98c2 Update version 2026-04-18 22:00:37 +01:00
dev-clawandGitHub c806eb9d25 Merge pull request #297
* fixes #296
2026-04-18 21:59:57 +01:00
dev-claw 4ba422eee1 Update version 2026-04-18 01:39:04 +01:00
dev-claw e80d704b03 fixes #295
fixes #294
fixes #293
fixes #291
fixes #286
fixes #285
2026-04-18 01:37:49 +01:00
EVgZcQvc8iandGitHub 562c36bf28 Fix open file directory on Linux when the path contains spaces (#282) 2026-04-15 22:09:15 +01:00
dev-claw c3d07a8503 Update release.yml 2025-12-23 14:19:38 +01:00
dev-claw 30c9c9f24d Update version 2025-12-23 14:13:02 +01:00
dev-claw ecf74b7b00 bug fixes 2025-12-23 14:11:22 +01:00
dev-claw ed0c5f76a6 bug fixes 2025-10-12 14:08:00 +01:00
dev-claw 46ddb66dc7 bug fixes 2025-10-11 22:35:40 +01:00
dev-claw 11c9b665ce Update version 2025-10-11 17:39:25 +01:00
dev-claw 5b17f17e18 Update package.yml 2025-10-11 17:35:57 +01:00
dev-claw d84f19b4af Create package.yml 2025-10-11 17:20:49 +01:00
dev-claw 12fad45e8b remove release-manual.yml 2025-10-11 17:01:00 +01:00
dev-claw dc74e1573c bug fixes 2025-10-11 16:22:48 +01:00
dev-clawandGitHub f9b89c0b4d Update package.yml 2025-10-11 16:14:02 +01:00
dev-clawandGitHub 1bf75f0e8e Update release.yml 2025-10-10 21:51:44 +01:00
dev-clawandGitHub c13a9f93b3 Update release.yml (#278) 2025-10-10 21:49:32 +01:00
dev-claw 3302ceda20 Update release.yml & package.yml 2025-10-10 21:37:57 +01:00
dev-claw 3adf5a3254 Update version 2025-10-10 21:22:22 +01:00
dev-clawandGitHub ba22747384 fixes #274 (#277)
fixes #274
fixes #275
fixes #276
2025-10-10 21:06:04 +01:00
dev-claw 455833377d Update package.yml & release.yml 2025-10-10 21:02:32 +01:00
dev-claw 27be6e7167 Update package.yml 2025-10-10 20:41:45 +01:00
dev-clawandGitHub ab54aae48e Update README.md 2025-09-30 15:37:51 +01:00
dev-claw 34b31a51a5 version bump 2025-09-26 12:15:37 +01:00
dev-clawandGitHub 99a8aa0ce0 fixes #267 (#269) 2025-09-26 12:10:50 +01:00
dev-claw bdb67475a6 update README.md 2025-09-19 21:15:25 +01:00
dev-claw 24c961644e update README.md 2025-09-19 21:07:39 +01:00
dev-claw 3c3b79f581 update README.md 2025-09-19 20:22:09 +01:00
dev-claw 8c30e5a02d fixes #239 2025-09-19 20:09:05 +01:00
dev-claw 398220d518 update README.md 2025-09-19 20:04:54 +01:00
dev-clawandGitHub af9a439d81 fixes #239 (#266) 2025-09-19 19:13:18 +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
dev-claw b872d5a1a5 update links 2024-12-27 19:27:28 +01:00
dev-claw 61ce16d219 version bump 2024-12-27 19:14:46 +01:00
dev-clawandGitHub c74d711702 fixes #208 (#209) 2024-12-27 19:12:07 +01:00
death-clawandGitHub 095e69d3c9 Update README.md 2024-12-27 11:45:42 +01:00
death-clawandGitHub 60bee4df2c Update README.md 2024-12-27 11:39:59 +01:00
death-clawandGitHub 3d68691037 Update README.md 2024-12-26 22:46:03 +01:00
death-claw 4456d3d797 version bump 2024-12-25 16:24:46 +01:00
death-clawandGitHub 2bcdd81eeb fixes #206 (#207) 2024-12-25 16:23:13 +01:00
death-claw 9652c7725e version bump 2024-12-21 17:59:27 +01:00
death-clawandGitHub 01ed204d12 Update README.md 2024-12-21 17:47:27 +01:00
death-clawandGitHub b8639be241 fixes #204 (#205) 2024-12-21 17:44:28 +01:00
162 changed files with 4613 additions and 3770 deletions
+174 -37
View File
@@ -2,54 +2,191 @@ name: Package
on:
workflow_dispatch:
inputs:
release:
description: 'Release'
required: true
type: string
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
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
server-id: github # Value of the distributionManagement/repository/id field of the pom.xml
settings-path: ${{ github.workspace }} # location for the settings.xml file
- name: Set up jdk25
uses: actions/setup-java@v3
with:
java-version: '25'
distribution: 'zulu'
- name: Build with Maven
run: mvn -B install --file pom.xml
- 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-${{ inputs.release }}-jar-with-dependencies.jar vripper-gui/target/vripper-noarch-gui-${{ inputs.release }}.jar
- name: Rename GUI artifact
run: mv vripper-gui/target/vripper-gui-*.jar vripper-gui/target/vripper-gui.jar
- if: matrix.os == 'ubuntu-latest'
name: Release gui Jar
uses: softprops/action-gh-release@v1
with:
files: vripper-gui/target/vripper-noarch-gui-${{ inputs.release }}.jar
# Start building WEB jar in ubuntu only
- if: matrix.os == 'ubuntu-latest'
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-${{ inputs.release }}.jar vripper-web/target/vripper-noarch-web-${{ inputs.release }}.jar
- name: Rename WEB artifact
run: mv vripper-web/target/vripper-web-*.jar vripper-web/target/vripper-web.jar
- if: matrix.os == 'ubuntu-latest'
name: Release web Jar
uses: softprops/action-gh-release@v1
with:
files: vripper-web/target/vripper-noarch-web-${{ inputs.release }}.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: Package Linux binaries
run: |
cd jpackage
cp ../vripper-gui/target/vripper-gui.jar jar/vripper-gui.jar
jpackage --type deb "@jpackage.cfg" "@jpackage-linux.cfg"
jpackage --type rpm "@jpackage.cfg" "@jpackage-linux.cfg"
ls -la dist
- 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-${{ inputs.release }}.jar docker_build/vripper-web.jar
docker build -t ghcr.io/dev-claw/vripper-web docker_build
docker tag ghcr.io/dev-claw/vripper-web:latest ghcr.io/dev-claw/vripper-web:${{ inputs.release }}
docker push ghcr.io/dev-claw/vripper-web:latest
docker push ghcr.io/dev-claw/vripper-web:${{ inputs.release }}
# End building docker image in ubuntu only
- if: matrix.os == 'windows-latest'
name: Package Windows binaries
run: |
cd jpackage
cp ../vripper-gui/target/vripper-gui.jar jar/vripper-gui.jar
jpackage "@jpackage.cfg" "@jpackage-windows.cfg"
dir dist
- name: Prepare Packaging
run: |
cp vripper-gui/target/vripper-noarch-gui-${{ inputs.release }}.jar jpackage/jar/vripper-gui.jar
- if: matrix.os == 'macos-latest'
name: Package macOS binaries
run: |
cd jpackage
cp ../vripper-gui/target/vripper-gui.jar jar/vripper-gui.jar
jpackage "@jpackage.cfg" "@jpackage-macos.cfg"
ls -la dist
- if: matrix.os == 'ubuntu-latest'
name: Package for Linux
run: |
cd jpackage
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.png
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-linux.cfg" --resource-dir resources --type deb
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-linux.cfg" --resource-dir resources --type rpm
mv dist/vripper-${{ inputs.release }}-1.x86_64.rpm dist/vripper-linux-${{ inputs.release }}.x86_64.rpm
mv dist/vripper_${{ inputs.release }}-1_amd64.deb dist/vripper-linux-${{ inputs.release }}_amd64.deb
- if: matrix.os == 'windows-latest'
name: Package for Windows
run: |
cd jpackage
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.ico
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-windows.cfg" --resource-dir resources --type msi
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-windows.cfg" --resource-dir resources --type exe
cd dist
ren VRipper-${{ inputs.release }}.msi vripper-windows-installer-${{ inputs.release }}.msi
ren VRipper-${{ inputs.release }}.exe vripper-windows-installer-${{ inputs.release }}.exe
- if: matrix.os == 'macos-latest'
name: Package for macOS(arm64)
run: |
cd jpackage
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.icns
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type pkg
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type dmg
mv dist/VRipper-${{ inputs.release }}.pkg dist/vripper-macos-${{ inputs.release }}.arm64.pkg
mv dist/VRipper-${{ inputs.release }}.dmg dist/vripper-macos-${{ inputs.release }}.arm64.dmg
- if: matrix.os == 'macos-13'
name: Package for macOS(x86_64)
run: |
cd jpackage
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.icns
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type pkg
jpackage --app-version ${{ inputs.release }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type dmg
mv dist/VRipper-${{ inputs.release }}.pkg dist/vripper-macos-${{ inputs.release }}.x86_64.pkg
mv dist/VRipper-${{ inputs.release }}.dmg dist/vripper-macos-${{ inputs.release }}.x86_64.dmg
- if: matrix.os == 'ubuntu-latest'
name: Zip Ubuntu portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
directory: 'jpackage/dist'
path: 'VRipper'
filename: 'vripper-linux-portable-${{ inputs.release }}.zip'
- if: matrix.os == 'windows-latest'
name: Zip Windows portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
directory: 'jpackage/dist'
path: 'VRipper'
filename: 'vripper-windows-portable-${{ inputs.release }}.zip'
- if: matrix.os == 'macos-latest'
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-${{ inputs.release }}.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-${{ inputs.release }}.x86_64.zip'
- if: matrix.os == 'ubuntu-latest'
name: Release packages for Linux
uses: softprops/action-gh-release@v1
with:
files: |
jpackage/dist/vripper-linux-${{ inputs.release }}.x86_64.rpm
jpackage/dist/vripper-linux-${{ inputs.release }}_amd64.deb
jpackage/dist/vripper-linux-portable-${{ inputs.release }}.zip
- if: matrix.os == 'windows-latest'
name: Release packages for Windows
uses: softprops/action-gh-release@v1
with:
files: |
jpackage/dist/vripper-windows-installer-${{ inputs.release }}.msi
jpackage/dist/vripper-windows-installer-${{ inputs.release }}.exe
jpackage/dist/vripper-windows-portable-${{ inputs.release }}.zip
- if: matrix.os == 'macos-latest'
name: Release packages for macOS(arm64)
uses: softprops/action-gh-release@v1
with:
files: |
jpackage/dist/vripper-macos-${{ inputs.release }}.arm64.pkg
jpackage/dist/vripper-macos-${{ inputs.release }}.arm64.dmg
jpackage/dist/vripper-macos-portable-${{ inputs.release }}.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-${{ inputs.release }}.x86_64.pkg
jpackage/dist/vripper-macos-${{ inputs.release }}.x86_64.dmg
jpackage/dist/vripper-macos-portable-${{ inputs.release }}.x86_64.zip
+86 -44
View File
@@ -8,89 +8,113 @@ jobs:
build:
strategy:
matrix:
os: [ ubuntu-latest, windows-latest, macos-latest ]
os: [ ubuntu-latest, windows-latest, macos-15-intel, macos-latest ]
runs-on: ${{ matrix.os }}
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v3
- name: Set up JDK 21
- name: Set up jdk25
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
server-id: github # Value of the distributionManagement/repository/id field of the pom.xml
settings-path: ${{ github.workspace }} # location for the settings.xml file
java-version: '25'
distribution: 'zulu'
cache: maven
- 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 docker_build
docker tag ghcr.io/dev-claw/vripper-web:latest ghcr.io/dev-claw/vripper-web:${{ github.event.release.tag_name }}
docker push ghcr.io/dev-claw/vripper-web:latest
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-15-intel'
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 +135,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-15-intel'
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 +171,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-15-intel'
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 azul/zulu-openjdk:25-latest
WORKDIR /opt/vripper-web
ENTRYPOINT ["java", "-jar", "vripper-web.jar"]
ADD vripper-web.jar /opt/vripper-web/vripper-web.jar
+119 -27
View File
@@ -1,26 +1,137 @@
# VRipper!
This is my spin for a cross-platform gallery ripper for [vipergirls.to](https://vipergirls.to).
This is my spin for a cross-platform gallery ripper for [vipergirls.to](https://vipergirls.to)
![GitHub Image](/image.png)
## Donation
To support this project, you can make a donation to its current maintainer
## Requirements
Direct access to `vipergirls.to` domain.
[!["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
## Supported Image Hosts
The following hosts are supported:
* acidimg.cc
* imagetwist.com
* imagezilla.com
* imgspice.com
* imagebam.com
* imgbox.com
* imx.to
* pimpandhost.com
* pixhost.to
* pixxxels.cc
* turboimagehost.com
* postimg.cc
* imagevenue.com
* pixroute.to
* vipr.im
## 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.0.0/vripper-windows-installer-6.0.0.exe) <br /> [Installer (MSI)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-windows-installer-6.0.0.msi) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-windows-portable-6.0.0.zip) | [Installer (DMG)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-macos-6.0.0.dmg) <br /> [Installer (PKG)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-macos-6.0.0.pkg) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-macos-portable-6.0.0.zip) | [Linux (amd64) (DEB)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-linux-6.0.0_amd64.deb) <br /> [Linux (x86_64) (RPM)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-linux-6.0.0.x86_64.rpm) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-linux-portable-6.0.0.zip) | [Java GUI (noarch)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-noarch-gui-6.0.0.jar) <br /> [Java Web (noarch)](https://github.com/death-claw/vripper-project/releases/download/6.0.0/vripper-noarch-web-6.0.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.8/vripper-windows-installer-6.5.8.exe) <br /> [Installer (MSI)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-windows-installer-6.5.8.msi) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-windows-portable-6.5.8.zip) | [Installer (DMG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-macos-6.5.8.x86_64.dmg) <br /> [Installer (PKG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-macos-6.5.8.x86_64.pkg) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-macos-portable-6.5.8.x86_64.zip) | [Installer (DMG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-macos-6.5.8.arm64.dmg) <br /> [Installer (PKG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-macos-6.5.8.arm64.pkg) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-macos-portable-6.5.8.arm64.zip) | [Linux (amd64) (DEB)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-linux-6.5.3_amd64.deb) <br /> [Linux (x86_64) (RPM)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-linux-6.5.8.x86_64.rpm) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-linux-portable-6.5.8.zip) | [Java GUI (noarch)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-noarch-gui-6.5.8.jar) <br /> [Java Web (noarch)](https://github.com/dev-claw/vripper-project/releases/download/6.5.8/vripper-noarch-web-6.5.8.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`
* Linux and macOS --> `HOME_FOLDER/.config/vripper`
## Docker Support
The project is supplied as a container image and can be run locally or in production using Docker. By default:
- **gRPC server/client** uses port **30000**
- **Web UI** runs on port **8080**
### Run with `docker run`
```bash
docker run -d \
-p 30000:30000 \
-p 8080:8080 \
-e GRPC_ENABLED=true \
-e GRPC_PASSPHRASE=my-secure-secret \
ghcr.io/dev-claw/vripper-web:latest
```
### Run with Docker Compose
```yaml
services:
app:
image: ghcr.io/dev-claw/vripper-web:latest
environment:
- GRPC_ENABLED=true
- GRPC_PASSPHRASE=super-secret-passphrase
ports:
- "30000:30000"
- "8080:8080"
```
> **Tip:** For production, prefer storing the passphrase as a secret (see `Security Notes` below) rather than writing it
> directly in the Compose file.
### Environment Variables
The container supports these environment variables for enabling and securing the gRPC server.
### `GRPC_ENABLED`
- **Type:** String (interpreted as Boolean)
- **Accepted values:** `true` (case-insensitive) to enable; any other value or unset → disabled
- **Default:** `false` (gRPC disabled)
- **Behavior:**
- When `GRPC_ENABLED=true`, the application will attempt to start the gRPC server (default port: `30000`).
- When absent or set to anything other than `true`, the gRPC server will not be started.
- **Example (shell):**
```bash
export GRPC_ENABLED=true
```
- **Example (Docker Compose):**
```yaml
environment:
- GRPC_ENABLED=true
```
- **Notes / Best practices:**
- Treat the value as a simple on/off toggle. To avoid ambiguity, set explicitly to `true` or `false`.
- If you enable gRPC, make sure any required authentication (see `GRPC_PASSPHRASE`) is also configured.
### `GRPC_PASSPHRASE`
- **Type:** String
- **Default:** *no default* (empty / unset)
- **Required if:** `GRPC_ENABLED=true`
- **Purpose:** Shared secret used to authenticate gRPC clients. The application should validate that the passphrase is
present when gRPC is enabled and use it to verify client connections.
- **Example (shell):**
```bash
export GRPC_PASSPHRASE="my-very-strong-passphrase"
```
## Default Ports
- **gRPC server/client:** `30000`
- **Web UI:** `8080`
You can map these ports when running the container. The application supports overriding these defaults with environment
variables (`GRPC_PORT`, `SERVER_PORT`), configure them as needed.
## Important Note About Proxies
The use of proxies within VRipper is worthless, please stop using them **for now**. You will get **403 error** codes.
@@ -29,24 +140,6 @@ The use of proxies within VRipper is worthless, please stop using them **for now
If your ISP is blocking access to `vipergirls.to` domain, consider using a VPN or [Cloudflare WARP](https://one.one.one.one/) to bypass the block.
## Supported Image Hosts
The following hosts are supported:
* acidimg.cc
* imagetwist.com
* imagezilla.com
* imgspice.com
* imagebam.com
* imgbox.com
* imx.to
* pimpandhost.com
* pixhost.to
* pixxxels.cc
* turboimagehost.com
* postimg.cc
* imagevenue.com
* pixroute.to
* vipr.im
## Instructions to run from Jar file
You need Java 21+, you can download from https://adoptium.net/
@@ -62,7 +155,6 @@ For the WEB app
Application data (application logs, settings and persisted data) is stored in the location where you launched the jar for both GUI and WEB
## How to build
You need JDK 21 and a recent version of maven 3.8.x+
-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

+14 -13
View File
@@ -8,36 +8,37 @@
<packaging>pom</packaging>
<version>${revision}</version>
<properties>
<java.version>21</java.version>
<java.version>25</java.version>
<maven.deploy.skip>false</maven.deploy.skip>
<timestamp>${maven.build.timestamp}</timestamp>
<maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<revision>6.0.0</revision>
<kotlin.version>2.0.21</kotlin.version>
<revision>6.8.1</revision>
<app-version>6.8.1</app-version>
<kotlin.version>2.2.20</kotlin.version>
<slf4j.version>2.0.16</slf4j.version>
<logback.version>1.5.12</logback.version>
<kotlinx-coroutines.version>1.9.0</kotlinx-coroutines.version>
<kotlinx-coroutines.version>1.10.2</kotlinx-coroutines.version>
<exposed.version>0.55.0</exposed.version>
<snakeyaml.version>2.0</snakeyaml.version>
<koin.version>4.0.0</koin.version>
<h2.version>2.2.224</h2.version>
<htmlcleaner.version>2.29</htmlcleaner.version>
<failsafe.version>2.4.4</failsafe.version>
<failsafe.version>3.3.2</failsafe.version>
<caffeine.version>3.1.8</caffeine.version>
<jna-platform.version>5.14.0</jna-platform.version>
<kotlinx-serialization-json.version>1.7.3</kotlinx-serialization-json.version>
<kotlinx-serialization-json.version>1.9.0</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>
@@ -142,8 +143,8 @@
<version>${htmlcleaner.version}</version>
</dependency>
<dependency>
<groupId>dev.failsafe</groupId>
<artifactId>failsafe</artifactId>
<groupId>net.jodah</groupId>
<version>${failsafe.version}</version>
</dependency>
<dependency>
@@ -186,11 +187,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 +217,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>
+7 -7
View File
@@ -69,16 +69,12 @@
</dependency>
<dependency>
<artifactId>failsafe</artifactId>
<groupId>net.jodah</groupId>
<groupId>dev.failsafe</groupId>
</dependency>
<dependency>
<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>
@@ -2,16 +2,17 @@ package me.vripper
import me.vripper.data.repositories.ImageRepository
import me.vripper.data.repositories.MetadataRepository
import me.vripper.data.repositories.PostDownloadStateRepository
import me.vripper.data.repositories.PostRepository
import me.vripper.data.repositories.ThreadRepository
import me.vripper.data.repositories.impl.ImageRepositoryImpl
import me.vripper.data.repositories.impl.MetadataRepositoryImpl
import me.vripper.data.repositories.impl.PostDownloadStateRepositoryImpl
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.*
import me.vripper.services.download.DownloadService
import me.vripper.services.download.QueueManager
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
@@ -29,8 +30,8 @@ val coreModule = module {
single<ImageRepository> {
ImageRepositoryImpl()
}
single<PostDownloadStateRepository> {
PostDownloadStateRepositoryImpl()
single<PostRepository> {
PostRepositoryImpl()
}
single<MetadataRepository> {
MetadataRepositoryImpl()
@@ -38,85 +39,88 @@ val coreModule = module {
single<ThreadRepository> {
ThreadRepositoryImpl()
}
single<DataTransaction> {
DataTransaction(get(), get(), get(), get(), get(), get())
single<DataAccessService> {
DataAccessService(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<QueueManager> {
QueueManager(get(), getAll(), get(), get(), get())
}
single<DownloadService> {
DownloadService(get(), get(), get(), get(), get())
DownloadService(get(), get(), get(), get())
}
single<DownloadSpeedService> {
DownloadSpeedService(get())
}
single<AppEndpointService> {
AppEndpointService(get(), get(), get(), get(), get(), get())
AppEndpointService(get(), get(), get(), get(), get(), get(), get())
}
single((named("localAppEndpointService"))) {
AppEndpointService(get(), get(), get(), get(), get(), get())
AppEndpointService(get(), get(), get(), get(), get(), get(), get())
} bind IAppEndpointService::class
single<MetadataService> {
MetadataService(get(), get(), get(), get())
MetadataService(get(), get())
}
single {
AcidimgHost(get(), get(), get())
AcidimgHost()
} 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
}
@@ -6,15 +6,15 @@ import java.util.*
internal interface ImageRepository {
fun save(imageEntity: ImageEntity): ImageEntity
fun save(imageEntityList: List<ImageEntity>)
fun deleteAllByPostId(postId: Long)
fun findByPostId(postId: Long): List<ImageEntity>
fun deleteAllByPostEntityId(postEntityId: Long)
fun findByPostEntityId(postEntityId: Long): List<ImageEntity>
fun countError(): Int
fun findByPostIdAndIsNotCompleted(postId: Long): List<ImageEntity>
fun stopByPostIdAndIsNotCompleted(postId: Long): Int
fun stopByPostIdAndIsNotCompleted(): Int
fun findByPostIdAndIsError(postId: Long): List<ImageEntity>
fun findByPostEntityIdAndIsNotCompleted(postEntityId: Long): List<ImageEntity>
fun stopByPostEntityIdAndIsNotCompleted(postEntityId: Long): Int
fun stopByPostEntityIdAndIsNotCompleted(): Int
fun findByPostEntityIdAndIsError(postEntityId: Long): List<ImageEntity>
fun findById(id: Long): Optional<ImageEntity>
fun update(imageEntity: ImageEntity)
fun update(imageEntities: List<ImageEntity>)
fun deleteAllByPostId(postIds: List<Long>)
fun deleteAllByPostEntityId(postEntityIds: List<Long>)
}
@@ -5,7 +5,7 @@ import java.util.*
internal interface MetadataRepository {
fun save(metadataEntity: MetadataEntity): MetadataEntity
fun findByPostId(postId: Long): Optional<MetadataEntity>
fun deleteByPostId(postId: Long): Int
fun deleteAllByPostId(postIds: List<Long>)
fun findByPostEntityId(postEntityId: Long): Optional<MetadataEntity>
fun deleteByPostEntityId(postEntityId: Long): Int
fun deleteAllByPostEntityId(postEntityIds: List<Long>)
}
@@ -1,21 +1,19 @@
package me.vripper.data.repositories
import me.vripper.entities.PostEntity
import java.util.*
internal interface PostDownloadStateRepository {
internal interface PostRepository {
fun save(postEntities: List<PostEntity>): List<PostEntity>
fun findByPostId(postId: Long): Optional<PostEntity>
fun findById(id: Long): Optional<PostEntity>
fun findById(id: Long): PostEntity?
fun findCompleted(): List<Long>
fun findAll(): List<PostEntity>
fun existByPostEntityId(postEntityId: Long): Boolean
fun existByPostId(postId: Long): Boolean
fun setDownloadingToStopped(): Int
fun deleteByPostId(postId: Long): Int
fun deleteByPostEntityId(postEntityId: Long): Int
fun update(postEntity: PostEntity)
fun update(postEntities: List<PostEntity>)
fun findMaxRank(): Int?
fun deleteAll(postIds: List<Long>)
fun deleteAll(postEntityIds: List<Long>)
fun stopAll()
fun findAllNonCompletedPostIds(): List<Long>
fun findAllNonCompletedPostEntityIds(): List<Long>
}
@@ -16,12 +16,11 @@ internal class ImageRepositoryImpl : ImageRepository {
it[downloaded] = imageEntity.downloaded
it[host] = imageEntity.host
it[index] = imageEntity.index
it[postId] = imageEntity.postId
it[status] = imageEntity.status.name
it[size] = imageEntity.size
it[url] = imageEntity.url
it[thumbUrl] = imageEntity.thumbUrl
it[postIdRef] = imageEntity.postIdRef
it[postIdRef] = imageEntity.postEntityId
it[filename] = imageEntity.filename
}.value
return imageEntity.copy(id = id)
@@ -32,23 +31,22 @@ internal class ImageRepositoryImpl : ImageRepository {
this[ImageTable.downloaded] = it.downloaded
this[ImageTable.host] = it.host
this[ImageTable.index] = it.index
this[ImageTable.postId] = it.postId
this[ImageTable.status] = it.status.name
this[ImageTable.size] = it.size
this[ImageTable.url] = it.url
this[ImageTable.thumbUrl] = it.thumbUrl
this[ImageTable.postIdRef] = it.postIdRef
this[ImageTable.postIdRef] = it.postEntityId
this[ImageTable.filename] = it.filename
}
}
override fun deleteAllByPostId(postId: Long) {
ImageTable.deleteWhere { ImageTable.postId eq postId }
override fun deleteAllByPostEntityId(postEntityId: Long) {
ImageTable.deleteWhere { ImageTable.postIdRef eq postEntityId }
}
override fun findByPostId(postId: Long): List<ImageEntity> {
override fun findByPostEntityId(postEntityId: Long): List<ImageEntity> {
return ImageTable.selectAll().where {
ImageTable.postId eq postId
ImageTable.postIdRef eq postEntityId
}.map(this::transform)
}
@@ -59,28 +57,28 @@ internal class ImageRepositoryImpl : ImageRepository {
.count().toInt()
}
override fun findByPostIdAndIsNotCompleted(postId: Long): List<ImageEntity> {
override fun findByPostEntityIdAndIsNotCompleted(postEntityId: Long): List<ImageEntity> {
return ImageTable
.selectAll().where {
(ImageTable.postId eq postId) and (ImageTable.status neq Status.FINISHED.name)
(ImageTable.postIdRef eq postEntityId) and (ImageTable.status neq Status.FINISHED.name)
}.map(this::transform)
}
override fun stopByPostIdAndIsNotCompleted(postId: Long): Int {
return ImageTable.update({ (ImageTable.postId eq postId) and (ImageTable.status neq Status.FINISHED.name) }) {
override fun stopByPostEntityIdAndIsNotCompleted(postEntityId: Long): Int {
return ImageTable.update({ (ImageTable.postIdRef eq postEntityId) and (ImageTable.status neq Status.FINISHED.name) }) {
it[status] = Status.STOPPED.name
}
}
override fun stopByPostIdAndIsNotCompleted(): Int {
override fun stopByPostEntityIdAndIsNotCompleted(): Int {
return ImageTable.update({ (ImageTable.status neq Status.FINISHED.name) }) {
it[status] = Status.STOPPED.name
}
}
override fun findByPostIdAndIsError(postId: Long): List<ImageEntity> {
override fun findByPostEntityIdAndIsError(postEntityId: Long): List<ImageEntity> {
return ImageTable.selectAll().where {
(ImageTable.postId eq postId) and (ImageTable.status eq Status.ERROR.name)
(ImageTable.postIdRef eq postEntityId) and (ImageTable.status eq Status.ERROR.name)
}.map(this::transform)
}
@@ -110,32 +108,31 @@ internal class ImageRepositoryImpl : ImageRepository {
this[ImageTable.downloaded] = it.downloaded
this[ImageTable.host] = it.host
this[ImageTable.index] = it.index
this[ImageTable.postId] = it.postId
this[ImageTable.status] = it.status.name
this[ImageTable.size] = it.size
this[ImageTable.url] = it.url
this[ImageTable.thumbUrl] = it.thumbUrl
this[ImageTable.postIdRef] = it.postIdRef
this[ImageTable.postIdRef] = it.postEntityId
this[ImageTable.filename] = it.filename
}
}
override fun deleteAllByPostId(postIds: List<Long>) {
override fun deleteAllByPostEntityId(postEntityIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE TEMPORARY TABLE IMAGES_DELETE(POST_ID BIGINT PRIMARY KEY)")
conn.prepareStatement("CREATE TEMPORARY TABLE IMAGES_DELETE(POST_ID_REF BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO IMAGES_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
postEntityIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM IMAGE WHERE POST_ID IN (SELECT POST_ID FROM IMAGES_DELETE)")
conn.prepareStatement("DELETE FROM IMAGE WHERE POST_ID_REF IN (SELECT POST_ID_REF FROM IMAGES_DELETE)")
.use {
it.execute()
}
@@ -147,7 +144,6 @@ internal class ImageRepositoryImpl : ImageRepository {
private fun transform(resultRow: ResultRow): ImageEntity {
val id = resultRow[ImageTable.id].value
val postId = resultRow[ImageTable.postId]
val url = resultRow[ImageTable.url]
val thumbUrl = resultRow[ImageTable.thumbUrl]
val host = resultRow[ImageTable.host]
@@ -159,7 +155,6 @@ internal class ImageRepositoryImpl : ImageRepository {
val filename = resultRow[ImageTable.filename]
return ImageEntity(
id,
postId,
url,
thumbUrl,
host,
@@ -18,17 +18,17 @@ internal class MetadataRepositoryImpl : MetadataRepository {
override fun save(metadataEntity: MetadataEntity): MetadataEntity {
MetadataTable.insert {
it[postId] = metadataEntity.postId
it[postIdRef] = metadataEntity.postIdRef
it[data] = Json.encodeToString(metadataEntity.data)
}
return metadataEntity
}
override fun findByPostId(postId: Long): Optional<MetadataEntity> {
override fun findByPostEntityId(postEntityId: Long): Optional<MetadataEntity> {
val result = MetadataTable.selectAll().where {
MetadataTable.postId eq postId
MetadataTable.postIdRef eq postEntityId
}.map(::transform)
return if (result.isEmpty()) {
@@ -39,31 +39,31 @@ internal class MetadataRepositoryImpl : MetadataRepository {
}
private fun transform(row: ResultRow): MetadataEntity {
val id = row[MetadataTable.postId]
val postEntityId = row[MetadataTable.postIdRef]
val data = Json.decodeFromString(row[MetadataTable.data]) as MetadataEntity.Data
return MetadataEntity(id, data)
return MetadataEntity(postEntityId, data)
}
override fun deleteByPostId(postId: Long): Int {
return MetadataTable.deleteWhere { MetadataTable.postId eq postId }
override fun deleteByPostEntityId(postEntityId: Long): Int {
return MetadataTable.deleteWhere { MetadataTable.postIdRef eq postEntityId }
}
override fun deleteAllByPostId(postIds: List<Long>) {
override fun deleteAllByPostEntityId(postEntityIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE TEMPORARY TABLE METADATA_DELETE(POST_ID BIGINT PRIMARY KEY)")
conn.prepareStatement("CREATE TEMPORARY TABLE METADATA_DELETE(POST_ID_REF BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO METADATA_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
postEntityIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM METADATA WHERE POST_ID IN (SELECT POST_ID FROM METADATA_DELETE)")
conn.prepareStatement("DELETE FROM METADATA WHERE POST_ID_REF IN (SELECT POST_ID_REF FROM METADATA_DELETE)")
.use {
it.execute()
}
@@ -1,6 +1,6 @@
package me.vripper.data.repositories.impl
import me.vripper.data.repositories.PostDownloadStateRepository
import me.vripper.data.repositories.PostRepository
import me.vripper.data.tables.PostTable
import me.vripper.entities.PostEntity
import me.vripper.entities.Status
@@ -8,10 +8,9 @@ import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.TransactionManager
import java.sql.Connection
import java.util.*
internal class PostDownloadStateRepositoryImpl :
PostDownloadStateRepository {
internal class PostRepositoryImpl :
PostRepository {
private val delimiter = ";"
@@ -20,12 +19,11 @@ internal class PostDownloadStateRepositoryImpl :
this[PostTable.status] = post.status.name
this[PostTable.done] = post.done
this[PostTable.total] = post.total
this[PostTable.rank] = post.rank
this[PostTable.hosts] = post.hosts.joinToString(delimiter)
this[PostTable.outputPath] = post.downloadDirectory
this[PostTable.folderName] = post.folderName
this[PostTable.postId] = post.postId
this[PostTable.threadId] = post.threadId
this[PostTable.postId] = post.vgPostId
this[PostTable.threadId] = post.vgThreadId
this[PostTable.postTitle] = post.postTitle
this[PostTable.threadTitle] = post.threadTitle
this[PostTable.forum] = post.forum
@@ -37,41 +35,29 @@ internal class PostDownloadStateRepositoryImpl :
}.map(::transform)
}
override fun findByPostId(postId: Long): Optional<PostEntity> {
val result = PostTable.selectAll().where {
PostTable.postId eq postId
}.map(::transform)
return if (result.isEmpty()) {
Optional.empty()
} else {
Optional.of(result.first())
}
}
override fun findCompleted(): List<Long> {
return PostTable.select(PostTable.postId).where {
return PostTable.select(PostTable.id).where {
(PostTable.status eq Status.FINISHED.name) and (PostTable.done greaterEq PostTable.total)
}.map { it[PostTable.postId] }
}.map { it[PostTable.id].value }
}
override fun findById(id: Long): Optional<PostEntity> {
override fun findById(id: Long): PostEntity? {
val result = PostTable.selectAll().where {
PostTable.id eq id
}.map { transform(it) }
return if (result.isEmpty()) {
Optional.empty()
} else {
Optional.of(result.first())
}
return result.firstOrNull()
}
override fun findAll(): List<PostEntity> {
return PostTable.selectAll().map { transform(it) }
}
override fun existByPostEntityId(postEntityId: Long): Boolean {
return PostTable.select(PostTable.id).where { PostTable.id eq postEntityId }.count() > 0
}
override fun existByPostId(postId: Long): Boolean {
return PostTable.select(PostTable.id).where { PostTable.postId eq postId }.count() > 0
return PostTable.select(PostTable.postId).where { PostTable.postId eq postId }.count() > 0
}
override fun setDownloadingToStopped(): Int {
@@ -80,15 +66,14 @@ internal class PostDownloadStateRepositoryImpl :
}
}
override fun deleteByPostId(postId: Long): Int {
return PostTable.deleteWhere { PostTable.postId eq postId }
override fun deleteByPostEntityId(postEntityId: Long): Int {
return PostTable.deleteWhere { PostTable.id eq postEntityId }
}
override fun update(postEntity: PostEntity) {
PostTable.update({ PostTable.id eq postEntity.id }) {
it[status] = postEntity.status.name
it[done] = postEntity.done
it[rank] = postEntity.rank
it[size] = postEntity.size
it[downloaded] = postEntity.downloaded
it[folderName] = postEntity.folderName
@@ -101,12 +86,11 @@ internal class PostDownloadStateRepositoryImpl :
this[PostTable.status] = post.status.name
this[PostTable.done] = post.done
this[PostTable.total] = post.total
this[PostTable.rank] = post.rank
this[PostTable.hosts] = post.hosts.joinToString(delimiter)
this[PostTable.outputPath] = post.downloadDirectory
this[PostTable.folderName] = post.folderName
this[PostTable.postId] = post.postId
this[PostTable.threadId] = post.threadId
this[PostTable.postId] = post.vgPostId
this[PostTable.threadId] = post.vgThreadId
this[PostTable.postTitle] = post.postTitle
this[PostTable.threadTitle] = post.threadTitle
this[PostTable.forum] = post.forum
@@ -114,31 +98,26 @@ internal class PostDownloadStateRepositoryImpl :
this[PostTable.token] = post.token
this[PostTable.size] = post.size
this[PostTable.downloaded] = post.downloaded
this[PostTable.addedAt] = post.addedOn
}
}
override fun findMaxRank(): Int? {
return PostTable.select(PostTable.rank.max())
.firstOrNull()
?.get(PostTable.rank.max())
}
override fun deleteAll(postIds: List<Long>) {
override fun deleteAll(postEntityIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE TEMPORARY TABLE POSTS_DELETE(POST_ID BIGINT PRIMARY KEY)")
conn.prepareStatement("CREATE TEMPORARY TABLE POSTS_DELETE(ID BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO POSTS_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
postEntityIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM POST WHERE POST_ID IN (SELECT POST_ID FROM POSTS_DELETE)")
conn.prepareStatement("DELETE FROM POST WHERE ID IN (SELECT ID FROM POSTS_DELETE)")
.use {
it.execute()
}
@@ -154,10 +133,10 @@ internal class PostDownloadStateRepositoryImpl :
}
}
override fun findAllNonCompletedPostIds(): List<Long> {
return PostTable.select(PostTable.postId).where {
override fun findAllNonCompletedPostEntityIds(): List<Long> {
return PostTable.select(PostTable.id).where {
PostTable.status neq Status.FINISHED.name
}.map { it[PostTable.postId] }
}.map { it[PostTable.id].value }
}
private fun transform(resultRow: ResultRow): PostEntity {
@@ -177,7 +156,6 @@ internal class PostDownloadStateRepositoryImpl :
val downloadDirectory = resultRow[PostTable.outputPath]
val folderName = resultRow[PostTable.folderName]
val addedOn = resultRow[PostTable.addedAt]
val rank = resultRow[PostTable.rank]
val size = resultRow[PostTable.size]
val downloaded = resultRow[PostTable.downloaded]
return PostEntity(
@@ -196,7 +174,6 @@ internal class PostDownloadStateRepositoryImpl :
folderName,
status,
done,
rank,
size,
downloaded
)
@@ -6,7 +6,6 @@ internal object ImageTable : LongIdTable(name = "IMAGE", columnName = "ID") {
val downloaded = long("DOWNLOADED")
val host = byte("HOST")
val index = integer("INDEX")
val postId = long("POST_ID")
val status = varchar("STATUS", 15)
val filename = varchar("FILENAME", 260)
val size = long("SIZE")
@@ -3,6 +3,6 @@ package me.vripper.data.tables
import org.jetbrains.exposed.sql.Table
internal object MetadataTable : Table(name = "METADATA") {
val postId = long("POST_ID")
val postIdRef = long("POST_ID_REF").references(PostTable.id, fkName = "METADATA_POST_ID_REF_POST_ID_FK")
val data = varchar("DATA", 1_000_000)
}
@@ -1,299 +0,0 @@
package me.vripper.download
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.vripper.entities.ImageEntity
import me.vripper.entities.PostEntity
import me.vripper.entities.Status
import me.vripper.event.ErrorCountEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.event.StoppedEvent
import me.vripper.host.Host
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.services.DataTransaction
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
import me.vripper.utilities.GlobalScopeCoroutine
import me.vripper.utilities.LoggerDelegate
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.RetryPolicy
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
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()
private val pending: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private val coroutineScope = CoroutineScope(SupervisorJob())
init {
Thread.ofVirtual().name("Download Loop").unstarted(Runnable {
val accepted: MutableList<ImageDownloadRunnable> = mutableListOf()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
while (!Thread.currentThread().isInterrupted) {
lock.withLock {
candidates.addAll(getCandidates(candidateCount()))
candidates.forEach {
if (canRun(it.context.imageEntity.host)) {
accepted.add(it)
running[it.context.imageEntity.host]!!.add(it)
log.debug("${it.context.imageEntity.url} accepted to run")
}
}
accepted.forEach {
pending[it.context.imageEntity.host]?.remove(it)
scheduleForDownload(it)
}
accepted.clear()
candidates.clear()
try {
condition.await()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
}).start()
}
fun stop(postIds: List<Long> = emptyList()) {
if (postIds.isNotEmpty()) {
stopInternal(postIds)
coroutineScope.launch {
eventBus.publishEvent(StoppedEvent(postIds))
}
} else {
stopAll()
coroutineScope.launch {
eventBus.publishEvent(StoppedEvent(listOf(-1)))
}
}
}
fun restartAll(postEntityIds: List<PostEntity> = emptyList()) {
if (postEntityIds.isNotEmpty()) {
restart(postEntityIds.associateWith { dataTransaction.findByPostIdAndIsNotCompleted(it.postId) })
} else {
restart(dataTransaction.findAllPosts()
.associateWith { dataTransaction.findByPostIdAndIsNotCompleted(it.postId) })
}
}
private fun restart(posts: Map<PostEntity, List<ImageEntity>>) {
lock.withLock {
val toProcess = mutableMapOf<PostEntity, List<ImageEntity>>()
for ((post, images) in posts) {
if (images.isEmpty()) {
continue
}
if (isPending(post.postId)) {
continue
}
toProcess[post] = images
}
toProcess.forEach { (post, images) ->
post.status = Status.PENDING
images.forEach { image ->
with(image) {
this.status = Status.PENDING
this.downloaded = 0
}
}
}
transaction {
dataTransaction.updatePosts(
toProcess.keys.toList()
)
dataTransaction.updateImages(
toProcess.values.flatten()
)
}
toProcess.keys.forEach {
vgAuthService.leaveThanks(it)
}
toProcess.entries.forEach { (post, images) ->
images.forEach { image ->
log.debug("Enqueuing a job for ${image.url}")
val imageDownloadRunnable = ImageDownloadRunnable(
image, post.rank, settingsService.settings
)
pending.computeIfAbsent(
image.host
) { mutableListOf() }
pending[image.host]!!.add(imageDownloadRunnable)
}
}
condition.signal()
}
}
private fun isPending(postId: Long): Boolean {
return pending.values.flatten().any { it.context.imageEntity.postId == postId }
}
private fun isRunning(postId: Long): Boolean {
return running.values.flatten().any { it.context.imageEntity.postId == postId }
}
private fun stopAll() {
lock.withLock {
pending.values.clear()
running.values.flatten().forEach { obj: ImageDownloadRunnable -> obj.stop() }
while (running.values.flatten().count { !it.context.completed } > 0) {
Thread.sleep(100)
}
dataTransaction.findAllNonCompletedPostIds().forEach {
dataTransaction.stopImagesByPostIdAndIsNotCompleted(it)
dataTransaction.finishPost(it)
}
}
}
private fun stopInternal(postIds: List<Long>) {
lock.withLock {
for (postId in postIds) {
pending.values.forEach { pending ->
pending.removeIf { it.context.imageEntity.postId == postId }
}
running.values.flatten()
.filter { p: ImageDownloadRunnable -> p.context.imageEntity.postId == postId }
.forEach { obj: ImageDownloadRunnable -> obj.stop() }
while (running.values.flatten()
.count { !it.context.completed && it.context.imageEntity.postId == postId } > 0
) {
Thread.sleep(100)
}
}
postIds.forEach {
dataTransaction.stopImagesByPostIdAndIsNotCompleted(it)
dataTransaction.finishPost(it)
}
}
}
private fun canRun(host: Byte): Boolean {
val totalRunning = running.values.sumOf { it.size }
return (running[host]!!.size < settingsService.settings.connectionSettings.maxConcurrentPerHost && if (settingsService.settings.connectionSettings.maxGlobalConcurrent == 0) totalRunning < maxPoolSize else totalRunning < settingsService.settings.connectionSettings.maxGlobalConcurrent)
}
private fun candidateCount(): Map<Byte, Int> {
val map: MutableMap<Byte, Int> = mutableMapOf()
Host.getHosts().values.forEach { host: Byte ->
val imageDownloadRunnableList: List<ImageDownloadRunnable> = running.computeIfAbsent(
host
) { mutableListOf() }
val count: Int =
settingsService.settings.connectionSettings.maxConcurrentPerHost - imageDownloadRunnableList.size
log.debug("Download slots for $host: $count")
map[host] = count
}
return map
}
private fun getCandidates(candidateCount: Map<Byte, Int>): List<ImageDownloadRunnable> {
val hostIntegerMap: MutableMap<Byte, Int> = candidateCount.toMutableMap()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
hosts@ for (host in pending.keys) {
val list: List<ImageDownloadRunnable> =
pending[host]!!.sortedWith(Comparator.comparingInt<ImageDownloadRunnable> { it.postRank }
.thenComparingInt { it.context.imageEntity.index })
for (imageDownloadRunnable in list) {
val count = hostIntegerMap[host] ?: 0
if (count > 0) {
candidates.add(imageDownloadRunnable)
hostIntegerMap[host] = count - 1
} else {
continue@hosts
}
}
}
return candidates
}
private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${imageDownloadRunnable.context.imageEntity.url}")
GlobalScopeCoroutine.launch {
coroutineScope.launch {
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
}
try {
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload("Failed to download ${imageDownloadRunnable.context.imageEntity.url}: "))
.onFailure {
log.error(
"Failed to download ${imageDownloadRunnable.context.imageEntity.url} after ${it.attemptCount} tries",
it.failure
)
val image = imageDownloadRunnable.context.imageEntity
image.status = Status.ERROR
dataTransaction.updateImage(image)
}
.onComplete {
afterJobFinish(imageDownloadRunnable)
coroutineScope.launch {
eventBus.publishEvent(
QueueStateEvent(
QueueState(
runningCount(), pendingCount()
)
)
)
}
coroutineScope.launch {
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
}
log.debug(
"Finished downloading ${imageDownloadRunnable.context.imageEntity.url}"
)
}.run(imageDownloadRunnable::run)
} catch (e: Exception) {
log.error("Download Failure", e)
}
}
}
private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
lock.withLock {
val image = imageDownloadRunnable.context.imageEntity
running[image.host]!!.remove(imageDownloadRunnable)
if (!isPending(image.postId) && !isRunning(
image.postId
) && !imageDownloadRunnable.context.stopped
) {
dataTransaction.finishPost(image.postId, true)
}
condition.signal()
}
}
private fun pendingCount(): Int {
return pending.values.sumOf { it.size }
}
private fun runningCount(): Int {
return running.values.sumOf { it.size }
}
}
@@ -1,17 +0,0 @@
package me.vripper.download
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 {
val httpContext: HttpClientContext =
HttpClientContext.create().apply { cookieStore = BasicCookieStore() }
val requests = mutableListOf<HttpUriRequestBase>()
val postId = imageEntity.postIdRef
var stopped = false
var completed = false
}
@@ -1,148 +0,0 @@
package me.vripper.download
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.*
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils.getExtension
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
import me.vripper.utilities.PathUtils.sanitize
import net.jodah.failsafe.function.CheckedRunnable
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.*
import kotlin.io.path.Path
import kotlin.io.path.pathString
internal class ImageDownloadRunnable(
private 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()
val context: ImageDownloadContext = ImageDownloadContext(imageEntity, settings)
@Throws(DownloadException::class)
fun download() {
try {
imageEntity.status = Status.DOWNLOADING
imageEntity.downloaded = 0
dataTransaction.updateImage(imageEntity)
synchronized(imageEntity.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataTransaction.updatePost(post)
}
}
log.debug("Getting image url and name from ${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).orElseThrow()
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 (context.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) {
}
}
}
@Throws(Exception::class)
override fun run() {
try {
if (context.stopped) {
return
}
download()
} finally {
context.completed = true
}
}
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 stop() {
context.requests.forEach { it.abort() }
context.stopped = true
}
}
@@ -5,29 +5,13 @@ import kotlinx.serialization.Serializable
@Serializable
data class ImageEntity(
val id: Long = -1,
val postId: Long,
val url: String,
val thumbUrl: String,
val host: Byte,
val index: Int,
val postIdRef: Long = -1,
val postEntityId: Long = -1,
var size: Long = -1,
var downloaded: Long = 0,
var status: Status = Status.STOPPED,
var filename: String = "",
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ImageEntity
if (url != other.url) return false
return true
}
override fun hashCode(): Int {
return url.hashCode()
}
}
)
@@ -3,7 +3,7 @@ package me.vripper.entities
import kotlinx.serialization.Serializable
@Serializable
data class MetadataEntity(val postId: Long, val data: Data) {
data class MetadataEntity(val postIdRef: Long, val data: Data) {
@Serializable
data class Data(
val postedBy: String,
@@ -12,8 +12,8 @@ data class PostEntity(
val forum: String,
val url: String,
val token: String,
val postId: Long,
val threadId: Long,
val vgPostId: Long,
val vgThreadId: Long,
val total: Int,
val hosts: Set<String>,
val downloadDirectory: String,
@@ -21,21 +21,6 @@ data class PostEntity(
var folderName: String,
var status: Status = Status.STOPPED,
var done: Int = 0,
var rank: Int = Int.MAX_VALUE,
var size: Long = -1,
var downloaded: Long = 0,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostEntity
return postId == other.postId
}
override fun hashCode(): Int {
return postId.hashCode()
}
}
)
@@ -9,17 +9,4 @@ data class ThreadEntity(
val link: String,
val threadId: Long,
var total: Int = 0,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ThreadEntity
return threadId == other.threadId
}
override fun hashCode(): Int {
return threadId.hashCode()
}
}
)
@@ -8,7 +8,7 @@ import me.vripper.model.Settings
data class PostCreateEvent(val postEntities: List<PostEntity>)
data class PostUpdateEvent(val postEntities: List<PostEntity>)
data class PostDeleteEvent(val postIds: List<Long>)
data class PostDeleteEvent(val postEntityIds: List<Long>)
data class ImageEvent(val imageEntities: List<ImageEntity>)
data class ThreadCreateEvent(val threadEntity: ThreadEntity)
data class ThreadUpdateEvent(val threadEntity: ThreadEntity)
@@ -1,14 +1,15 @@
package me.vripper.event
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object EventBus {
private val _events = MutableSharedFlow<Any>()
private val _events = MutableSharedFlow<Any>(0, Int.MAX_VALUE, BufferOverflow.DROP_OLDEST)
val events = _events.asSharedFlow()
suspend fun publishEvent(event: Any) {
_events.emit(event)
fun publishEvent(event: Any) {
_events.tryEmit(event)
}
}
@@ -1,42 +1,37 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable.Context
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) {
internal class AcidimgHost : Host("acidimg.cc", 0) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String, document: Document, context: ImageDownloadContext
context: Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.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", context.imageEntity.url))
val httpPost = HttpPost(context.imageEntity.url).also {
it.addHeader("Referer", context.imageEntity.url)
it.entity = UrlEncodedFormEntity(
listOf(
BasicNameValuePair(
@@ -45,8 +40,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 +55,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, context.imageEntity.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, context.imageEntity.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", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim()
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim()
Pair(
@@ -1,30 +1,23 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +25,11 @@ internal class DPicMeHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { e: Node -> e.textContent.trim() }
@@ -1,11 +1,15 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.runBlocking
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.services.DataTransaction
import me.vripper.services.DataAccessService
import me.vripper.services.DownloadSpeedService
import me.vripper.services.HTTPService
import me.vripper.services.download.ImageDownloadRunnable.Context
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
@@ -14,22 +18,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 java.time.Duration
import java.time.LocalDateTime
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()
protected val httpService: HTTPService by inject()
private val dataAccessService: DataAccessService by inject()
private val downloadSpeedService: DownloadSpeedService by inject()
companion object {
private const val READ_BUFFER_SIZE = 8192
private val hosts: MutableMap<String, Byte> = mutableMapOf()
@@ -43,54 +48,51 @@ internal abstract class Host(
hosts[hostName] = hostId
}
@Throws(HostException::class)
abstract fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
context: Context
): Pair<String, String>
@Throws(HostException::class)
fun downloadInternal(url: String, context: ImageDownloadContext): DownloadedImage {
val headers = head(url, context)
fun downloadInternal(context: Context): DownloadedImage {
if (hostId == 8.toByte()) {
return downloadByHost(context)
}
val headers = head(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(context.imageEntity.url, context) {
handleImageDownload(it, context)
}
DownloadedImage(getDefaultImageName(url), downloadedImage.first, downloadedImage.second)
DownloadedImage(getDefaultImageName(context.imageEntity.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(context)
} else {
throw HostException("Unable to download $url, can't process content type $value")
throw HostException("Unable to download ${context.imageEntity.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 ${context.imageEntity.url}, response have no content type")
}
}
return downloadedImage
}
private fun downloadByHost(context: Context): DownloadedImage {
val resolvedImage = resolve(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
context: Context
): Pair<Path, ImageMimeType> {
val mimeType = getImageMimeType(response.headers)
?: throw HostException("Unsupported image type ${response.getFirstHeader("content-type")}")
@@ -100,45 +102,46 @@ internal abstract class Host(
"vripper_",
".tmp"
)
return Files.newOutputStream(tempImage).use { fos ->
val image = context.imageEntity
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
val size = if (image.size < 0) {
return BufferedOutputStream(Files.newOutputStream(tempImage)).use { bos ->
synchronized(context.imageEntity.postEntityId.toString().intern()) {
val post = dataAccessService.findPostByEntityId(context.imageEntity.postEntityId)
val size = if (context.imageEntity.size < 0) {
response.entity.contentLength
} else {
0
}
image.size = response.entity.contentLength
context.imageEntity.size = response.entity.contentLength
post.size += size
transaction {
dataTransaction.updateImage(image)
dataTransaction.updatePost(post)
dataAccessService.updateImage(context.imageEntity)
dataAccessService.updatePost(post)
}
}
log.debug(
"Length is ${image.size}"
"Length is ${context.imageEntity.size}"
)
log.debug(
"Starting data transfer"
)
val buffer = ByteArray(READ_BUFFER_SIZE)
var read: Int
var lastImageUpdateDate = LocalDateTime.now()
while (response.entity.content.read(buffer, 0, READ_BUFFER_SIZE)
.also { read = it } != -1 && !context.stopped
) {
fos.write(buffer, 0, read)
image.downloaded += read
if (Duration.between(lastImageUpdateDate, LocalDateTime.now()).toMillis() > 1750) {
dataTransaction.updateImage(image)
lastImageUpdateDate = LocalDateTime.now()
} else {
dataTransaction.updateImage(image, false)
val reporterJob = context.launchCoroutine {
while (isActive) {
dataAccessService.updateImage(context.imageEntity, false)
delay(100)
}
}
while (response.entity.content.read(buffer)
.also { read = it } != -1
) {
bos.write(buffer, 0, read)
context.imageEntity.downloaded += read
downloadSpeedService.reportDownloadedBytes(read.toLong())
}
dataTransaction.updateImage(image)
runBlocking {
reporterJob.cancelAndJoin()
}
dataAccessService.updateImage(context.imageEntity)
Pair(tempImage, mimeType)
}
}
@@ -147,10 +150,13 @@ internal abstract class Host(
return url.contains(hostName)
}
@Throws(HostException::class)
fun head(url: String, context: ImageDownloadContext): Array<Header> {
val httpHead = HttpHead(url).also { context.requests.add(it) }
log.debug(String.format("Requesting %s", url))
fun head(context: Context): Array<Header> {
val httpHead = HttpHead(context.imageEntity.url).also {
it.addHeader("Referer", "https://vipergirls.to/")
it.setAbsoluteRequestUri(true)
context.requests.add(it)
}
log.info("{}", httpHead)
return httpService.client.execute(
httpHead,
context.httpContext
@@ -162,15 +168,18 @@ internal abstract class Host(
}
}
@Throws(HostException::class)
fun <T> fetch(
url: String,
context: ImageDownloadContext,
context: Context,
referer: String = "https://vipergirls.to/",
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", referer)
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}")
@@ -179,6 +188,19 @@ internal abstract class Host(
}
}
fun fetchDocument(
url: String,
context: Context
): Document {
return fetch(url, context) {
HtmlUtils.clean(it.entity.content)
}.also {
if (log.isDebugEnabled) {
log.debug("Cleaning {} response", url)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun getImageMimeType(headers: Array<Header>): ImageMimeType? {
@@ -1,45 +1,35 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
if (XpathUtils.getAsNode(document, CONTINUE_XPATH) != null) {
val clientCookie = BasicClientCookie("nsfw_inter", "1")
clientCookie.domain = "www.imagebam.com"
clientCookie.path = "/"
clientCookie.expiryDate =
Date.from(
LocalDateTime.now().plusDays(3).atZone(ZoneId.systemDefault()).toInstant()
)
context.httpContext.cookieStore.addCookie(clientCookie)
fetch(url, context) {
val sfwCookie = BasicClientCookie("sfw_inter", "1")
sfwCookie.domain = "www.imagebam.com"
sfwCookie.path = "/"
sfwCookie.setExpiryDate(LocalDateTime.now().plusDays(3).atZone(ZoneId.systemDefault()).toInstant())
context.httpContext.cookieStore.addCookie(sfwCookie)
fetch(context.imageEntity.url, context) {
HtmlUtils.clean(it.entity.content)
}
} else {
@@ -49,7 +39,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, context.imageEntity.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -57,11 +47,11 @@ internal class ImageBamHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
val imgTitle = Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { e: Node -> e.textContent.trim { it <= ' ' } }
.orElse("")
@@ -1,30 +1,23 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +25,11 @@ internal class ImageTwistHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { obj: Node -> obj.textContent }
@@ -1,39 +1,32 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val doc = try {
val document = fetchDocument(context.imageEntity.url, context)
log.debug(
String.format(
"Looking for xpath expression %s in %s",
CONTINUE_BUTTON_XPATH,
url
context.imageEntity.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(context.imageEntity.url, context) {
HtmlUtils.clean(it.entity.content)
}
} else {
@@ -43,7 +36,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, context.imageEntity.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -51,11 +44,11 @@ internal class ImageVenueHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
@@ -1,28 +1,21 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -30,17 +23,17 @@ internal class ImageZillaHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.url
)
)
log.debug(String.format("Resolving name for %s", url))
log.debug(String.format("Resolving name for %s", context.imageEntity.url))
var title = titleNode.attributes.getNamedItem("title").textContent.trim()
titleNode.textContent.trim()
if (title.isEmpty()) {
title = getDefaultImageName(url)
title = getDefaultImageName(context.imageEntity.url)
}
return try {
Pair(title, url.replace("show", "images"))
Pair(title, context.imageEntity.url.replace("show", "images"))
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
@@ -1,29 +1,22 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +24,11 @@ internal class ImgSpiceHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
@@ -1,29 +1,22 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +24,11 @@ internal class ImgboxHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("title").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(imgTitle, imgUrl)
@@ -1,36 +1,39 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
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.services.download.ImageDownloadRunnable
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
import java.io.IOException
internal class ImxHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imx.to", 8, httpService, dataTransaction, downloadSpeedService) {
internal class ImxHost : Host("imx.to", 8) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val httpsUrl = url.replace("http:", "https:")
var value: String? = null
try {
log.debug("Resolving name and image url for ${context.imageEntity.url}")
val imgTitle = getTitle(context).ifEmpty {
getDefaultImageName(context.imageEntity.thumbUrl)
}
val imgUrl = findPattern(context.imageEntity)
return Pair(
imgTitle, imgUrl
)
}
private fun getTitle(context: ImageDownloadRunnable.Context): String {
return try {
val httpsUrl = context.imageEntity.url.replace("http:", "https:")
val document = fetchDocument(httpsUrl, context)
var value: String? = null
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")
@@ -38,46 +41,44 @@ internal class ImxHost(
if (node != null) {
value = node.textContent
}
} catch (e: XpathException) {
throw HostException(e)
}
if (value == null) {
throw HostException("Failed to obtain value attribute from continue input")
}
log.debug("Click button found for $httpsUrl")
val httpPost: HttpPost = HttpPost(httpsUrl).also {
it.entity = UrlEncodedFormEntity(listOf(BasicNameValuePair("imgContinue", value)))
}.also { context.requests.add(it) }
log.debug("Requesting {}", httpPost)
val doc = try {
httpService.client.execute(
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 = 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)
val imgNode = XpathUtils.getAsNode(doc, IMG_XPATH)
log.debug("Resolving name for $httpsUrl")
val imgTitle = imgNode?.attributes?.getNamedItem("alt")?.textContent?.trim() ?: ""
return imgTitle
} catch (_: Exception) {
""
}
}
private fun findPattern(image: ImageEntity): String {
val url = image.thumbUrl
.replace("http:", "https:")
return if (url.startsWith("https://image.imx.to/u/t/")) {
"https://image.imx.to/u/i/" + url.replace("https://image.imx.to/u/t/", "")
} else if (url.startsWith("https://imx.to/u/t")) {
"https://image.imx.to/u/i/" + url.replace("https://imx.to/u/t", "")
} else if (url.startsWith("https://t.imx.to/t/")) {
"https://image.imx.to/u/i/" + url.replace("https://t.imx.to/t/", "")
} else if (url.startsWith("https://imx.to/upload/small/")) {
"https://image.imx.to/u/i/" + url.replace("https://imx.to/upload/small/", "")
} else if (url.startsWith("https://image.imx.to/u/i/")) {
url
} else {
throw HostException("Cannot find pattern for url ${image.thumbUrl}")
}
}
@@ -1,33 +1,28 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val newUrl: String
try {
newUrl = appendUri(url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""), "size=original")
newUrl = appendUri(
context.imageEntity.url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""),
"size=original"
)
} catch (e: Exception) {
throw HostException(e)
}
@@ -43,7 +38,7 @@ internal class PimpandhostHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.url
)
)
return try {
@@ -1,29 +1,22 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +24,11 @@ internal class PixRouteHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
Pair(imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' },
imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' })
} catch (e: Exception) {
@@ -1,29 +1,22 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +24,11 @@ internal class PixhostHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.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,21 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -30,11 +23,11 @@ internal class PixxxelsHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -42,11 +35,11 @@ internal class PixxxelsHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
TITLE_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
val imgTitle = titleNode.textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("href").textContent.trim { it <= ' ' }
Pair(
@@ -1,65 +1,33 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
TITLE_XPATH,
url
)
)
val urlNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
val document = fetchDocument(context.imageEntity.url.replace("http:", "https:"), context)
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
val node = XpathUtils.getAsNode(document, IMG_XPATH) ?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
val imgTitle = Optional.ofNullable(titleNode)
.map { node: Node -> node.textContent.trim { it <= ' ' } }
.orElseGet { getDefaultImageName(url) }
Pair(imgTitle, urlNode.attributes.getNamedItem("href").textContent.trim { it <= ' ' })
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
return Pair(
node.attributes.getNamedItem("alt").textContent.trim(),
node.attributes.getNamedItem("src").textContent.trim()
)
}
companion object {
private const val TITLE_XPATH = "//span[contains(@class,'imagename')]"
private const val IMG_XPATH = "//a[@id='download']"
private const val IMG_XPATH = "//img[contains(@class,'img-fluid')]"
}
}
@@ -1,45 +1,38 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.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", context.imageEntity.url))
titleNode?.textContent?.trim { it <= ' ' }
} catch (e: XpathException) {
throw HostException(e)
}
if (title.isNullOrEmpty()) {
title = getDefaultImageName(url)
title = getDefaultImageName(context.imageEntity.url)
}
val urlNode: Node = XpathUtils.getAsNode(document, IMG_XPATH)
?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.url
)
)
return Pair(title, urlNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' })
@@ -1,30 +1,23 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.download.ImageDownloadRunnable
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,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(context.imageEntity.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, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +25,11 @@ internal class ViprImHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
context.imageEntity.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", context.imageEntity.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { obj: Node -> obj.textContent }
@@ -0,0 +1,55 @@
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 me.vripper.services.download.DownloadService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
object AppManager : KoinComponent {
private val eventBus: EventBus by inject()
private val dataAccessService: DataAccessService 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()
}
}
dataAccessService.setDownloadingToStopped()
dataAccessService.stopImagesByPostEntityIdAndIsNotCompleted()
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,11 +20,10 @@ data class Post(
val total: Int,
val hosts: Set<String>,
val downloadDirectory: String,
@Contextual val addedOn: LocalDateTime = LocalDateTime.now(),
@Contextual val addedOn: LocalDateTime,
var folderName: String,
var status: Status = Status.STOPPED,
var done: Int = 0,
var rank: Int = Int.MAX_VALUE,
var size: Long = -1,
var downloaded: Long = 0,
val previews: List<String>,
@@ -0,0 +1,3 @@
package me.vripper.model
data class PostIdentifier(val siteProxy: String, val threadId: Long, val postId: Long)
@@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class QueueState(
val running: Int,
val remaining: Int
val remaining: Int,
val rank: List<Rank> = emptyList()
)
@@ -0,0 +1,6 @@
package me.vripper.model
import kotlinx.serialization.Serializable
@Serializable
data class Rank(val postEntityId: Long, val rank: Long)
@@ -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
@@ -1,21 +1,21 @@
package me.vripper.services
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
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
import me.vripper.model.*
import me.vripper.services.download.DownloadService
import me.vripper.services.download.MovePosition
import me.vripper.services.download.QueueManager
import me.vripper.tasks.AddPostTask
import me.vripper.tasks.ThreadLookupTask
import me.vripper.utilities.ApplicationProperties
import me.vripper.utilities.*
import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR
import me.vripper.utilities.GlobalScopeCoroutine
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils
import org.h2.jdbc.JdbcSQLNonTransientConnectionException
import java.sql.DriverManager
import java.time.Duration
@@ -26,9 +26,11 @@ import kotlin.io.path.Path
import kotlin.io.path.exists
import kotlin.jvm.optionals.getOrNull
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
internal class AppEndpointService(
private val downloadService: DownloadService,
private val dataTransaction: DataTransaction,
private val queueManager: QueueManager,
private val dataAccessService: DataAccessService,
private val threadCacheService: ThreadCacheService,
private val settingsService: SettingsService,
private val vgAuthService: VGAuthService,
@@ -43,33 +45,35 @@ 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() }
val proxies = settingsService.getProxies()
for (link in urlList) {
log.debug("Starting to process thread: $link")
if (!link.startsWith(settingsService.settings.viperSettings.host)) {
log.debug("Scanning: $link")
val matchingProxy = proxies.find { link.startsWith(it) }
if (matchingProxy == null) {
continue
}
var threadId: Long
var postId: Long?
val m = Pattern.compile(
Pattern.quote(settingsService.settings.viperSettings.host) + "/threads/(\\d+)((.*p=)(\\d+))?"
Pattern.quote(matchingProxy) + "/threads/(\\d+)((.*p=)(\\d+))?"
).matcher(link)
if (m.find()) {
threadId = m.group(1).toLong()
postId = m.group(4)?.toLong()
if (postId == null) {
GlobalScopeCoroutine.launch {
taskRunner.submit(
ThreadLookupTask(
threadId, settingsService.settings
).run()
}
matchingProxy, threadId, settingsService.settings
)
)
} else {
GlobalScopeCoroutine.launch {
taskRunner.submit(
AddPostTask(
listOf(ThreadPostId(threadId, postId))
).run()
}
listOf(PostIdentifier(matchingProxy, threadId, postId))
)
)
}
} else {
log.error("Invalid link $link, link is missing the threadId")
@@ -79,42 +83,46 @@ internal class AppEndpointService(
}
}
override suspend fun restartAll(posIds: List<Long>) {
override suspend fun restartAll(postEntityIds: List<Long>) {
lock.withLock {
downloadService.restartAll(posIds.map { dataTransaction.findPostByPostId(it) }.filter { it.isPresent }
.map { it.get() })
downloadService.restartAll(postEntityIds.filter { dataAccessService.exists(it) }
.map { dataAccessService.findPostByEntityId(it) })
}
}
override suspend fun download(posts: List<ThreadPostId>) {
GlobalScopeCoroutine.launch {
AddPostTask(posts).run()
posts.map {
val thread = dataAccessService.findThreadByThreadId(it.threadId).getOrNull()
?: throw PostParseException("Could not find thread with id ${it.threadId}")
PostIdentifier(thread.link.extractBaseUrl(), it.threadId, it.postId)
}.also {
taskRunner.submit(AddPostTask(it))
}
}
override suspend fun stopAll(postIdList: List<Long>) {
override suspend fun stopAll(postEntityIds: List<Long>) {
lock.withLock {
downloadService.stop(postIdList)
downloadService.stop(postEntityIds)
}
}
override suspend fun remove(postIdList: List<Long>) {
override suspend fun remove(postEntityIds: List<Long>) {
lock.withLock {
downloadService.stop(postIdList)
dataTransaction.removeAll(postIdList)
downloadService.stop(postEntityIds)
dataAccessService.removeAll(postEntityIds)
}
}
override suspend fun clearCompleted(): List<Long> {
lock.withLock {
return dataTransaction.clearCompleted()
return dataAccessService.clearCompleted()
}
}
override suspend fun grab(threadId: Long): List<PostSelection> {
lock.withLock {
return try {
val thread = dataTransaction.findThreadByThreadId(threadId).orElseThrow {
val thread = dataAccessService.findThreadByThreadId(threadId).orElseThrow {
PostParseException(
String.format(
"Unable to find links for threadId = %s", threadId
@@ -122,6 +130,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,
@@ -135,23 +146,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
}
}
}
@@ -159,42 +160,43 @@ internal class AppEndpointService(
override suspend fun threadRemove(threadIdList: List<Long>) {
lock.withLock {
threadIdList.forEach {
dataTransaction.removeThread(it)
dataAccessService.removeThread(it)
}
}
}
override suspend fun threadClear() {
lock.withLock {
dataTransaction.clearQueueLinks()
dataAccessService.clearQueueLinks()
}
}
override suspend fun renameToFirst(postIds: List<Long>) {
postIds.forEach { postId ->
dataTransaction
.findMetadataByPostId(postId)
override suspend fun renameToFirst(postEntityIds: List<Long>) {
postEntityIds.forEach { postEntityId ->
dataAccessService
.findMetadataByPostEntityId(postEntityId)
.map { it.data.resolvedNames }
.filter { it.isNotEmpty() }
.getOrNull()?.let { rename(postId, it.first()) }
.getOrNull()?.let { rename(postEntityId, it.first()) }
}
}
override fun ready(): Boolean {
return true
}
override suspend fun rename(postId: Long, newName: String) {
GlobalScopeCoroutine.launch {
synchronized(postId.toString().intern()) {
dataTransaction.findPostByPostId(postId).ifPresent { post ->
if (Path(post.downloadDirectory, post.folderName).exists()) {
PathUtils.rename(
dataTransaction.findImagesByPostId(postId), post.downloadDirectory, post.folderName, newName
)
override suspend fun rename(postEntityId: Long, newName: String) {
taskRunner.submit {
synchronized(postEntityId.toString().intern()) {
if (dataAccessService.exists(postEntityId)) {
dataAccessService.findPostByEntityId(postEntityId).let { post ->
if (Path(post.downloadDirectory, post.folderName).exists()) {
PathUtils.rename(
dataAccessService.findImagesByPostEntityId(postEntityId),
post.downloadDirectory,
post.folderName,
newName
)
}
post.folderName = PathUtils.sanitize(newName)
dataAccessService.updatePost(post)
}
post.folderName = PathUtils.sanitize(newName)
dataTransaction.updatePost(post)
}
}
}
@@ -211,7 +213,7 @@ internal class AppEndpointService(
override fun onDeletePosts() =
EventBus.events.filterIsInstance(PostDeleteEvent::class).flatMapConcat { it.postIds.asFlow() }
EventBus.events.filterIsInstance(PostDeleteEvent::class).flatMapConcat { it.postEntityIds.asFlow() }
override fun onUpdateMetadata() =
@@ -219,12 +221,12 @@ internal class AppEndpointService(
override suspend fun findAllPosts(): List<Post> {
return dataTransaction.findAllPosts().map(::mapper)
return dataAccessService.findAllPosts().map(::mapper)
}
private fun mapper(postEntity: PostEntity): Post {
val metadata: Metadata? = dataTransaction.findMetadataByPostId(postEntity.postId).orElse(null)
val images = dataTransaction.findImagesByPostId(postEntity.postId)
val metadata: Metadata? = dataAccessService.findMetadataByPostEntityId(postEntity.id).orElse(null)
val images = dataAccessService.findImagesByPostEntityId(postEntity.id)
return Post(
postEntity.id,
postEntity.postTitle,
@@ -232,8 +234,8 @@ internal class AppEndpointService(
postEntity.forum,
postEntity.url,
postEntity.token,
postEntity.postId,
postEntity.threadId,
postEntity.vgPostId,
postEntity.vgThreadId,
postEntity.total,
postEntity.hosts,
postEntity.downloadDirectory,
@@ -241,7 +243,6 @@ internal class AppEndpointService(
postEntity.folderName,
postEntity.status,
postEntity.done,
postEntity.rank,
postEntity.size,
postEntity.downloaded,
images.take(4).map { it.thumbUrl },
@@ -250,17 +251,17 @@ internal class AppEndpointService(
)
}
override suspend fun findPost(postId: Long): Post {
return mapper(dataTransaction.findPostByPostId(postId).orElseThrow())
override suspend fun findPost(postEntityId: Long): Post {
return mapper(dataAccessService.findPostByEntityId(postEntityId))
}
override suspend fun findImagesByPostId(postId: Long): List<Image> {
return dataTransaction.findImagesByPostId(postId)
override suspend fun findImagesByPostEntityId(postEntityId: Long): List<Image> {
return dataAccessService.findImagesByPostEntityId(postEntityId)
}
override fun onUpdateImagesByPostId(postId: Long): Flow<Image> =
override fun onUpdateImagesByPostEntityId(postEntityId: Long): Flow<Image> =
EventBus.events.filterIsInstance(ImageEvent::class).map {
it.imageEntities.filter { imageEntity: Image -> imageEntity.postId == postId }
it.imageEntities.filter { imageEntity: Image -> imageEntity.postEntityId == postEntityId }
}.filter { it.isNotEmpty() }.flatMapConcat { it.asFlow() }
override fun onUpdateImages(): Flow<Image> =
@@ -289,7 +290,7 @@ internal class AppEndpointService(
override suspend fun findAllThreads(): List<Thread> {
return dataTransaction.findAllThreads()
return dataAccessService.findAllThreads()
}
override fun onDownloadSpeed(): Flow<DownloadSpeed> =
@@ -301,6 +302,10 @@ internal class AppEndpointService(
override fun onQueueStateUpdate(): Flow<QueueState> =
EventBus.events.filterIsInstance(QueueStateEvent::class).map { it.queueState }
override suspend fun getQueueState(): QueueState {
return queueManager.getQueueState()
}
override fun onErrorCountUpdate(): Flow<ErrorCount> =
EventBus.events.filterIsInstance(ErrorCountEvent::class).map { it.errorCount }
@@ -320,6 +325,10 @@ internal class AppEndpointService(
override suspend fun getVersion(): String = ApplicationProperties.VERSION
override suspend fun move(postEntityId: Long, position: MovePosition) {
downloadService.move(postEntityId, position)
}
override suspend fun dbMigration(): String {
val conn = try {
@@ -329,31 +338,31 @@ internal class AppEndpointService(
return "Old database not found, nothing to do"
}
var postsCount = 0;
var threadCount = 0;
var postsCount = 0
var threadCount = 0
conn.use { conn ->
conn.prepareStatement("select * from post").use {
it.executeQuery().use {
while (it.next()) {
val done = it.getInt("DONE")
val hosts = it.getString("HOSTS")
val outputPath = it.getString("OUTPUT_PATH")
val postId = it.getLong("POST_ID")
val status = it.getString("STATUS")
val threadId = it.getLong("THREAD_ID")
val postTitle = it.getString("POST_TITLE")
val threadTitle = it.getString("THREAD_TITLE")
val forum = it.getString("FORUM")
val total = it.getInt("TOTAL")
val size = it.getLong("SIZE")
val downloaded = it.getLong("DOWNLOADED")
val url = it.getString("URL")
val token = it.getString("TOKEN")
val addedAt = it.getTimestamp("ADDED_AT")
val folderName = it.getString("FOLDER_NAME") ?: ""
conn.prepareStatement("select * from post").use { statement ->
statement.executeQuery().use { resultSet ->
while (resultSet.next()) {
val done = resultSet.getInt("DONE")
val hosts = resultSet.getString("HOSTS")
val outputPath = resultSet.getString("OUTPUT_PATH")
val postId = resultSet.getLong("POST_ID")
val status = resultSet.getString("STATUS")
val threadId = resultSet.getLong("THREAD_ID")
val postTitle = resultSet.getString("POST_TITLE")
val threadTitle = resultSet.getString("THREAD_TITLE")
val forum = resultSet.getString("FORUM")
val total = resultSet.getInt("TOTAL")
val size = resultSet.getLong("SIZE")
val downloaded = resultSet.getLong("DOWNLOADED")
val url = resultSet.getString("URL")
val token = resultSet.getString("TOKEN")
val addedAt = resultSet.getTimestamp("ADDED_AT")
val folderName = resultSet.getString("FOLDER_NAME") ?: ""
val exists = dataTransaction.findPostByPostId(postId).isPresent
val exists = dataAccessService.existsPostId(postId)
if (exists) {
continue
}
@@ -364,8 +373,8 @@ internal class AppEndpointService(
forum = forum,
url = url,
token = token,
postId = postId,
threadId = threadId,
vgPostId = postId,
vgThreadId = threadId,
total = total,
hosts = hosts.split(";").dropLastWhile { it.isEmpty() }.toSet(),
downloadDirectory = outputPath,
@@ -394,7 +403,6 @@ internal class AppEndpointService(
val fileName = set.getString("FILENAME") ?: ""
ImageEntity(
postId = postId,
url = url,
thumbUrl = thumbUrl,
host = host,
@@ -408,7 +416,7 @@ internal class AppEndpointService(
}
}
dataTransaction.saveAndNotify(post, images)
val savedPost = dataAccessService.saveAndNotify(post, images)
//load meta
conn.prepareStatement("select * from metadata where post_id = ?").use { statement ->
@@ -417,10 +425,10 @@ internal class AppEndpointService(
if (set.next()) {
val data = Json.decodeFromString(set.getString("DATA")) as MetadataEntity.Data
val metadata = MetadataEntity(
postId = postId,
postIdRef = savedPost.id,
data = data
)
dataTransaction.saveMetadata(metadata)
dataAccessService.saveMetadata(metadata)
}
}
}
@@ -437,7 +445,7 @@ internal class AppEndpointService(
val threadId = set.getLong("THREAD_ID")
val title = set.getString("TITLE")
if (dataTransaction.findThreadByThreadId(threadId).isPresent) {
if (dataAccessService.findThreadByThreadId(threadId).isPresent) {
continue
}
@@ -448,7 +456,7 @@ internal class AppEndpointService(
total = total,
)
dataTransaction.save(threadEntity)
dataAccessService.save(threadEntity)
threadCount++
}
}
@@ -461,4 +469,8 @@ internal class AppEndpointService(
"Successfully imported $postsCount posts and $threadCount threads"
}
}
override fun connectionState(): String {
return ""
}
}
@@ -0,0 +1,297 @@
package me.vripper.services
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import me.vripper.data.repositories.ImageRepository
import me.vripper.data.repositories.MetadataRepository
import me.vripper.data.repositories.PostRepository
import me.vripper.data.repositories.ThreadRepository
import me.vripper.entities.*
import me.vripper.event.*
import me.vripper.model.ErrorCount
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils
import me.vripper.utilities.PathUtils.sanitize
import me.vripper.vgapi.PostItem
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.io.path.pathString
internal class DataAccessService(
private val settingsService: SettingsService,
private val postRepository: PostRepository,
private val imageRepository: ImageRepository,
private val threadRepository: ThreadRepository,
private val metadataRepository: MetadataRepository,
private val eventBus: EventBus,
) {
private val log by LoggerDelegate()
private val postEntityIdCache: LoadingCache<Long, PostEntity> =
Caffeine.newBuilder().expireAfterAccess(5, TimeUnit.MINUTES).build { id ->
transaction { postRepository.findById(id) }
}
private fun save(postEntities: List<PostEntity>): List<PostEntity> {
return transaction { postRepository.save(postEntities) }
}
fun saveAndNotify(postEntity: PostEntity, images: List<ImageEntity>): PostEntity {
val savedPost = transaction {
val savedPost =
postRepository.save(listOf(postEntity)).first()
save(images.map { it.copy(postEntityId = savedPost.id) })
// Publish event inside transaction for consistency
eventBus.publishEvent(PostCreateEvent(listOf(savedPost)))
savedPost
}
return savedPost
}
fun updatePosts(postEntities: List<PostEntity>) {
transaction { postRepository.update(postEntities) }
postEntities.forEach { postEntity ->
postEntityIdCache.put(postEntity.id, postEntity)
}
// Log and publish event after transaction commit but before returning
log.debug("[{}] Publishing event: PostUpdateEvent for {} posts", System.currentTimeMillis(), postEntities.size)
eventBus.publishEvent(PostUpdateEvent(postEntities))
}
fun updatePost(postEntity: PostEntity) {
transaction { postRepository.update(postEntity) }
postEntityIdCache.put(postEntity.id, postEntity)
// Log and publish event after transaction commit but before returning
log.debug("[{}] Publishing event: PostUpdateEvent for post {}", System.currentTimeMillis(), postEntity.id)
eventBus.publishEvent(PostUpdateEvent(listOf(postEntity)))
}
fun save(threadEntity: ThreadEntity) {
val savedThread = transaction { threadRepository.save(threadEntity) }
log.debug("[{}] Publishing event: ThreadCreateEvent for thread {}", System.currentTimeMillis(), savedThread.id)
eventBus.publishEvent(ThreadCreateEvent(savedThread))
}
fun update(threadEntity: ThreadEntity) {
transaction { threadRepository.update(threadEntity) }
log.debug("[{}] Publishing event: ThreadUpdateEvent for thread {}", System.currentTimeMillis(), threadEntity.id)
eventBus.publishEvent(ThreadUpdateEvent(threadEntity))
}
fun updateImages(imageEntities: List<ImageEntity>) {
transaction { imageRepository.update(imageEntities) }
log.debug("[{}] Publishing event: ImageEvent for {} images", System.currentTimeMillis(), imageEntities.size)
eventBus.publishEvent(ImageEvent(imageEntities))
}
fun updateImage(imageEntity: ImageEntity, persist: Boolean = true) {
if (persist) {
transaction {
imageRepository.update(imageEntity)
}
}
log.debug("[{}] Publishing event: ImageEvent for image {}", System.currentTimeMillis(), imageEntity.id)
eventBus.publishEvent(ImageEvent(listOf(imageEntity)))
}
fun exists(postEntityId: Long): Boolean {
if (postEntityIdCache.getIfPresent(postEntityId) != null) {
return true
}
return transaction { postRepository.existByPostEntityId(postEntityId) }
}
fun existsPostId(postId: Long): Boolean {
return transaction { postRepository.existByPostId(postId) }
}
@Synchronized
fun newPosts(postItems: List<PostItem>): List<PostEntity> {
val posts = postItems.associate { postItem ->
val postEntity = PostEntity(
postTitle = postItem.title,
url = postItem.url,
token = postItem.securityToken,
vgPostId = postItem.postId,
vgThreadId = postItem.threadId,
total = postItem.imageCount,
hosts = postItem.hosts.map { "${it.first} (${it.second})" }.toSet(),
threadTitle = postItem.threadTitle,
forum = postItem.forum,
downloadDirectory = PathUtils.calculateDownloadPath(
postItem.forum,
postItem.threadTitle,
settingsService.settings
).pathString,
folderName = if (settingsService.settings.downloadSettings.appendPostId) "${sanitize(postItem.title)}_${postItem.postId}" else sanitize(
postItem.title
)
)
val imageEntities = postItem.imageItemList.mapIndexed { index, imageItem ->
ImageEntity(
url = imageItem.mainLink,
thumbUrl = imageItem.thumbLink,
host = imageItem.host.hostId,
index = index,
)
}
Pair(postEntity, imageEntities)
}
val savedPosts = transaction {
val savedPosts = save(posts.keys.toList())
savedPosts.associateWith { postEntity ->
posts.entries.find { postItem -> postItem.key.vgPostId == postEntity.vgPostId }?.value ?: emptyList()
}.forEach { (key, value) ->
save(value.map { it.copy(postEntityId = key.id) })
}
savedPosts
}
eventBus.publishEvent(PostCreateEvent(savedPosts))
return savedPosts
}
private fun save(imageEntities: List<ImageEntity>) {
transaction { imageRepository.save(imageEntities) }
}
fun finishPost(id: Long, automatic: Boolean = false) {
val post = findPostByEntityId(id)
val imagesInErrorStatus = findByIdAndIsError(id)
if (imagesInErrorStatus.isNotEmpty()) {
post.status = Status.ERROR
updatePost(post)
} else {
if (post.done < post.total) {
post.status = Status.STOPPED
updatePost(post)
} else {
post.status = Status.FINISHED
transaction {
updatePost(post)
if (settingsService.settings.downloadSettings.clearCompleted && automatic) {
remove(listOf(post.id))
}
}
}
}
}
private fun findByIdAndIsError(postEntityId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostEntityIdAndIsError(postEntityId) }
}
private fun remove(postEntityIds: List<Long>) {
transaction {
metadataRepository.deleteAllByPostEntityId(postEntityIds)
imageRepository.deleteAllByPostEntityId(postEntityIds)
postRepository.deleteAll(postEntityIds)
}
postEntityIds.forEach { postEntityId ->
postEntityIdCache.get(postEntityId)?.let { postEntityIdCache.invalidate(it.id) }
postEntityIdCache.invalidate(postEntityId)
}
log.debug("[{}] Publishing event: PostDeleteEvent for {} posts", System.currentTimeMillis(), postEntityIds.size)
eventBus.publishEvent(PostDeleteEvent(postEntityIds = postEntityIds))
log.debug("[{}] Publishing event: ErrorCountEvent after post deletion", System.currentTimeMillis())
eventBus.publishEvent(ErrorCountEvent(ErrorCount(countImagesInError())))
}
fun removeThread(threadId: Long) {
transaction { threadRepository.deleteByThreadId(threadId) }
log.debug("[{}] Publishing event: ThreadDeleteEvent for thread {}", System.currentTimeMillis(), threadId)
eventBus.publishEvent(ThreadDeleteEvent(threadId))
}
fun clearCompleted(): List<Long> {
val completed = transaction { postRepository.findCompleted() }
remove(completed)
return completed
}
fun removeAll(postEntityIds: List<Long> = emptyList()) {
if (postEntityIds.isNotEmpty()) {
remove(postEntityIds)
} else {
remove(findAllPosts().map(PostEntity::id))
}
}
fun stopImagesByPostEntityIdAndIsNotCompleted(postEntityId: Long) {
transaction { imageRepository.stopByPostEntityIdAndIsNotCompleted(postEntityId) }
}
fun stopImagesByPostEntityIdAndIsNotCompleted() {
transaction { imageRepository.stopByPostEntityIdAndIsNotCompleted() }
}
fun saveMetadata(metadataEntity: MetadataEntity) {
transaction { metadataRepository.save(metadataEntity) }
log.debug(
"[{}] Publishing event: MetadataUpdateEvent for post {}",
System.currentTimeMillis(),
metadataEntity.postIdRef
)
eventBus.publishEvent(MetadataUpdateEvent(metadataEntity))
}
fun clearQueueLinks() {
transaction { threadRepository.deleteAll() }
log.debug("[{}] Publishing event: ThreadClearEvent", System.currentTimeMillis())
eventBus.publishEvent(ThreadClearEvent())
}
fun setDownloadingToStopped() {
transaction { postRepository.setDownloadingToStopped() }
}
fun findAllPosts(): List<PostEntity> {
return transaction { postRepository.findAll() }
}
fun findPostByEntityId(id: Long): PostEntity {
return postEntityIdCache.get(id) ?: throw NoSuchElementException("Post with id = $id does not exist")
}
fun findImagesByPostEntityId(postEntityId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostEntityId(postEntityId) }
}
fun findImageById(id: Long): Optional<ImageEntity> {
return transaction { imageRepository.findById(id) }
}
fun findAllThreads(): List<ThreadEntity> {
return transaction { threadRepository.findAll() }
}
fun findThreadById(id: Long): Optional<ThreadEntity> {
return transaction {
threadRepository.findById(id)
}
}
fun findByPostEntityIdAndIsNotCompleted(postEntityId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostEntityIdAndIsNotCompleted(postEntityId) }
}
fun countImagesInError(): Int {
return transaction { imageRepository.countError() }
}
fun findThreadByThreadId(threadId: Long): Optional<ThreadEntity> {
return transaction { threadRepository.findByThreadId(threadId) }
}
fun findAllNonCompletedPostEntityIds(): List<Long> {
return transaction { postRepository.findAllNonCompletedPostEntityIds() }
}
fun findMetadataByPostEntityId(postEntityId: Long): Optional<MetadataEntity> {
return transaction { metadataRepository.findByPostEntityId(postEntityId) }
}
}
@@ -1,312 +0,0 @@
package me.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.vripper.data.repositories.ImageRepository
import me.vripper.data.repositories.MetadataRepository
import me.vripper.data.repositories.PostDownloadStateRepository
import me.vripper.data.repositories.ThreadRepository
import me.vripper.entities.*
import me.vripper.event.*
import me.vripper.model.ErrorCount
import me.vripper.utilities.PathUtils
import me.vripper.utilities.PathUtils.sanitize
import me.vripper.vgapi.PostItem
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import kotlin.io.path.pathString
internal class DataTransaction(
private val settingsService: SettingsService,
private val postDownloadStateRepository: PostDownloadStateRepository,
private val imageRepository: ImageRepository,
private val threadRepository: ThreadRepository,
private val metadataRepository: MetadataRepository,
private val eventBus: EventBus,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val nextRank = AtomicInteger(transaction { getQueuePosition() }?.plus(1) ?: 0)
private fun save(postEntities: List<PostEntity>): List<PostEntity> {
return transaction { postDownloadStateRepository.save(postEntities) }
}
fun saveAndNotify(postEntity: PostEntity, images: List<ImageEntity>) {
val savedPost = transaction {
val savedPost =
postDownloadStateRepository.save(listOf(postEntity.copy(rank = nextRank.andIncrement))).first()
save(images.map { it.copy(postIdRef = savedPost.id) })
savedPost
}
coroutineScope.launch {
eventBus.publishEvent(PostCreateEvent(listOf(savedPost)))
}
}
fun updatePosts(postEntities: List<PostEntity>) {
transaction { postDownloadStateRepository.update(postEntities) }
coroutineScope.launch {
eventBus.publishEvent(PostUpdateEvent(postEntities))
}
}
fun updatePost(postEntity: PostEntity) {
transaction { postDownloadStateRepository.update(postEntity) }
coroutineScope.launch {
eventBus.publishEvent(PostUpdateEvent(listOf(postEntity)))
}
}
fun save(threadEntity: ThreadEntity) {
val savedThread = transaction { threadRepository.save(threadEntity) }
coroutineScope.launch {
eventBus.publishEvent(ThreadCreateEvent(savedThread))
}
}
fun update(threadEntity: ThreadEntity) {
transaction { threadRepository.update(threadEntity) }
coroutineScope.launch {
eventBus.publishEvent(ThreadUpdateEvent(threadEntity))
}
}
fun updateImages(imageEntities: List<ImageEntity>) {
transaction { imageRepository.update(imageEntities) }
coroutineScope.launch {
eventBus.publishEvent(ImageEvent(imageEntities))
}
}
fun updateImage(imageEntity: ImageEntity, persist: Boolean = true) {
if (persist) {
transaction {
imageRepository.update(imageEntity)
}
}
coroutineScope.launch {
eventBus.publishEvent(ImageEvent(listOf(imageEntity)))
}
}
fun exists(postId: Long): Boolean {
return transaction { postDownloadStateRepository.existByPostId(postId) }
}
@Synchronized
fun newPosts(postItems: List<PostItem>): List<PostEntity> {
val posts = postItems.associate { postItem ->
val postEntity = PostEntity(
postTitle = postItem.title,
url = postItem.url,
token = postItem.securityToken,
postId = postItem.postId,
threadId = postItem.threadId,
total = postItem.imageCount,
hosts = postItem.hosts.map { "${it.first} (${it.second})" }.toSet(),
threadTitle = postItem.threadTitle,
forum = postItem.forum,
rank = nextRank.andIncrement,
downloadDirectory = PathUtils.calculateDownloadPath(
postItem.forum,
postItem.threadTitle,
settingsService.settings
).pathString,
folderName = if (settingsService.settings.downloadSettings.appendPostId) "${sanitize(postItem.title)}_${postItem.postId}" else sanitize(
postItem.title
)
)
val imageEntities = postItem.imageItemList.mapIndexed { index, imageItem ->
ImageEntity(
postId = postItem.postId,
url = imageItem.mainLink,
thumbUrl = imageItem.thumbLink,
host = imageItem.host.hostId,
index = index,
)
}
Pair(postEntity, imageEntities)
}
val savedPosts = transaction {
val savedPosts = save(posts.keys.toList())
savedPosts.associateWith {
posts[it]!!
}.forEach { (key, value) ->
save(value.map { it.copy(postIdRef = key.id) })
}
savedPosts
}
coroutineScope.launch {
eventBus.publishEvent(PostCreateEvent(savedPosts))
}
return savedPosts
}
private fun getQueuePosition(): Int? {
return postDownloadStateRepository.findMaxRank()
}
private fun save(imageEntities: List<ImageEntity>) {
transaction { imageRepository.save(imageEntities) }
}
fun finishPost(postId: Long, automatic: Boolean = false) {
val post = findPostByPostId(postId).orElseThrow()
val imagesInErrorStatus = findByPostIdAndIsError(post.postId)
if (imagesInErrorStatus.isNotEmpty()) {
post.status = Status.ERROR
updatePost(post)
} else {
if (post.done < post.total) {
post.status = Status.STOPPED
updatePost(post)
} else {
post.status = Status.FINISHED
transaction {
updatePost(post)
if (settingsService.settings.downloadSettings.clearCompleted && automatic) {
remove(listOf(post.postId))
}
}
}
}
}
private fun findByPostIdAndIsError(postId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostIdAndIsError(postId) }
}
private fun remove(postIds: List<Long>) {
transaction {
metadataRepository.deleteAllByPostId(postIds)
imageRepository.deleteAllByPostId(postIds)
postDownloadStateRepository.deleteAll(postIds)
sortPostsByRank()
}
coroutineScope.launch {
eventBus.publishEvent(PostDeleteEvent(postIds = postIds))
}
coroutineScope.launch {
eventBus.publishEvent(ErrorCountEvent(ErrorCount(countImagesInError())))
}
}
fun removeThread(threadId: Long) {
transaction { threadRepository.deleteByThreadId(threadId) }
coroutineScope.launch {
eventBus.publishEvent(ThreadDeleteEvent(threadId))
}
}
fun clearCompleted(): List<Long> {
val completed = transaction { postDownloadStateRepository.findCompleted() }
remove(completed)
return completed
}
fun removeAll(postIds: List<Long> = emptyList()) {
if (postIds.isNotEmpty()) {
remove(postIds)
} else {
remove(findAllPosts().map(PostEntity::postId))
}
}
fun stopImagesByPostIdAndIsNotCompleted(postId: Long) {
transaction { imageRepository.stopByPostIdAndIsNotCompleted(postId) }
}
fun stopImagesByPostIdAndIsNotCompleted() {
transaction { imageRepository.stopByPostIdAndIsNotCompleted() }
}
fun saveMetadata(metadataEntity: MetadataEntity) {
transaction { metadataRepository.save(metadataEntity) }
coroutineScope.launch {
eventBus.publishEvent(MetadataUpdateEvent(metadataEntity))
}
}
fun clearQueueLinks() {
transaction { threadRepository.deleteAll() }
coroutineScope.launch {
eventBus.publishEvent(ThreadClearEvent())
}
}
@Synchronized
fun sortPostsByRank() {
val postsToUpdate = mutableListOf<PostEntity>()
val postEntities = findAllPosts().sortedWith(Comparator.comparing(PostEntity::rank))
for (i in postEntities.indices) {
if (postEntities[i].rank != i) {
postsToUpdate.add(postEntities[i].copy(rank = i))
}
}
updatePosts(postsToUpdate)
nextRank.set(transaction { getQueuePosition() }?.plus(1) ?: 0)
}
fun setDownloadingToStopped() {
transaction { postDownloadStateRepository.setDownloadingToStopped() }
}
fun findAllPosts(): List<PostEntity> {
return transaction { postDownloadStateRepository.findAll() }
}
fun findPostById(id: Long): Optional<PostEntity> {
return transaction { postDownloadStateRepository.findById(id) }
}
fun findImagesByPostId(postId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostId(postId) }
}
fun findImageById(id: Long): Optional<ImageEntity> {
return transaction { imageRepository.findById(id) }
}
fun findAllThreads(): List<ThreadEntity> {
return transaction { threadRepository.findAll() }
}
fun findThreadById(id: Long): Optional<ThreadEntity> {
return transaction {
threadRepository.findById(id)
}
}
fun findByPostIdAndIsNotCompleted(postId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostIdAndIsNotCompleted(postId) }
}
fun countImagesInError(): Int {
return transaction { imageRepository.countError() }
}
fun findPostByPostId(postId: Long): Optional<PostEntity> {
return transaction { postDownloadStateRepository.findByPostId(postId) }
}
fun findThreadByThreadId(threadId: Long): Optional<ThreadEntity> {
return transaction { threadRepository.findByThreadId(threadId) }
}
fun findAllNonCompletedPostIds(): List<Long> {
return transaction { postDownloadStateRepository.findAllNonCompletedPostIds() }
}
fun findMetadataByPostId(postId: Long): Optional<MetadataEntity> {
return transaction { metadataRepository.findByPostId(postId) }
}
}
@@ -6,6 +6,7 @@ import me.vripper.event.DownloadSpeedEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.model.DownloadSpeed
import me.vripper.utilities.LoggerDelegate
import java.util.concurrent.atomic.AtomicLong
internal class DownloadSpeedService(
@@ -16,12 +17,15 @@ internal class DownloadSpeedService(
const val DOWNLOAD_POLL_RATE = 2500
}
private val log by LoggerDelegate()
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) {
@@ -30,8 +34,13 @@ internal class DownloadSpeedService(
while (isActive) {
delay(DOWNLOAD_POLL_RATE.toLong())
val newValue = bytesCount.getAndSet(0)
eventBus.publishEvent(DownloadSpeedEvent(DownloadSpeed(((newValue * 1000) / DOWNLOAD_POLL_RATE))))
val speed = DownloadSpeed(((newValue * 1000) / DOWNLOAD_POLL_RATE))
log.debug(
"[{}] Publishing event: DownloadSpeedEvent({})",
System.currentTimeMillis(),
speed
)
eventBus.publishEvent(DownloadSpeedEvent(speed))
}
}
}
@@ -39,6 +48,7 @@ internal class DownloadSpeedService(
job?.cancel()
coroutineScope.launch {
delay(DOWNLOAD_POLL_RATE + 500L)
log.debug("[{}] Publishing event: DownloadSpeedEvent(0)", System.currentTimeMillis())
eventBus.publishEvent(DownloadSpeedEvent(DownloadSpeed(0L)))
}
}
@@ -46,6 +56,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.TimeValue
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 {
pcm.closeIdle(TimeValue.ofSeconds(60))
delay(15000)
}
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.STRICT)
.setConnPoolPolicy(PoolReusePolicy.LIFO)
.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()))
.setTimeToLive(TimeValue.ofMinutes(10))
.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()
}
}
}
@@ -2,22 +2,23 @@ package me.vripper.services
import kotlinx.coroutines.flow.Flow
import me.vripper.model.*
import me.vripper.services.download.MovePosition
interface IAppEndpointService {
suspend fun scanLinks(postLinks: String)
suspend fun restartAll(posIds: List<Long> = listOf())
suspend fun remove(postIdList: List<Long>)
suspend fun stopAll(postIdList: List<Long> = emptyList())
suspend fun restartAll(postEntityIds: List<Long> = listOf())
suspend fun remove(postEntityIds: List<Long>)
suspend fun stopAll(postEntityIds: List<Long> = emptyList())
suspend fun clearCompleted(): List<Long>
suspend fun findPost(postId: Long): Post
suspend fun findPost(postEntityId: Long): Post
suspend fun findAllPosts(): List<Post>
suspend fun rename(postId: Long, newName: String)
suspend fun rename(postEntityId: Long, newName: String)
fun onNewPosts(): Flow<Post>
fun onUpdatePosts(): Flow<Post>
fun onDeletePosts(): Flow<Long>
fun onUpdateMetadata(): Flow<Metadata>
suspend fun findImagesByPostId(postId: Long): List<Image>
fun onUpdateImagesByPostId(postId: Long): Flow<Image>
suspend fun findImagesByPostEntityId(postEntityId: Long): List<Image>
fun onUpdateImagesByPostEntityId(postEntityId: Long): Flow<Image>
fun onUpdateImages(): Flow<Image>
fun onStopped(): Flow<Long>
fun onNewLog(): Flow<LogEntry>
@@ -32,6 +33,7 @@ interface IAppEndpointService {
suspend fun download(posts: List<ThreadPostId>)
fun onDownloadSpeed(): Flow<DownloadSpeed>
fun onVGUserUpdate(): Flow<String>
suspend fun getQueueState(): QueueState
fun onQueueStateUpdate(): Flow<QueueState>
fun onErrorCountUpdate(): Flow<ErrorCount>
fun onTasksRunning(): Flow<Boolean>
@@ -41,8 +43,9 @@ interface IAppEndpointService {
fun onUpdateSettings(): Flow<Settings>
suspend fun loggedInUser(): String
suspend fun getVersion(): String
suspend fun renameToFirst(postIds: List<Long>)
fun ready(): Boolean
suspend fun renameToFirst(postEntityIds: List<Long>)
suspend fun dbMigration(): String
suspend fun initLogger()
suspend fun move(postEntityId: Long, position: MovePosition)
fun connectionState(): String
}
@@ -1,111 +1,30 @@
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.entities.PostEntity
import me.vripper.tasks.FetchMetadataTask
import me.vripper.utilities.taskRunner
internal class MetadataService(
private val httpService: HTTPService,
private val dataAccessService: DataAccessService,
private val settingsService: SettingsService,
private val dataTransaction: DataTransaction,
private val vgAuthService: VGAuthService,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val dictionary: List<String> = mutableListOf("download", "link", "rapidgator", "filefactory", "filefox")
fun init() {
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()) {
fun fetchExisting() {
if (!settingsService.settings.viperSettings.fetchMetadata) {
return
}
if (node.nodeName == "a" || node.nodeName == "img") {
keepGoing.set(false)
dataAccessService.findAllPosts().filter { dataAccessService.findMetadataByPostEntityId(it.id).isEmpty }
.map { it }.forEach(::fetchMetadata)
}
fun fetchMetadata(post: PostEntity) {
if (!settingsService.settings.viperSettings.fetchMetadata) {
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(
post
)
)
}
}
@@ -1,44 +1,17 @@
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.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import dev.failsafe.RetryPolicy
import me.vripper.utilities.LoggerDelegate
import net.jodah.failsafe.RetryPolicy
import net.jodah.failsafe.event.ExecutionAttemptedEvent
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)
var maxAttempts: Int = 3
init {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
if (maxAttempts != it.settings.connectionSettings.maxAttempts) {
maxAttempts = it.settings.connectionSettings.maxAttempts
}
}
}
}
fun <T> buildRetryPolicyForDownload(message: String): RetryPolicy<T> {
return RetryPolicy<T>().withDelay(2, 5, ChronoUnit.SECONDS).withMaxAttempts(maxAttempts).onFailedAttempt {
log.warn(message + "#${it.attemptCount} tries failed", it.lastFailure)
}
}
fun <T> buildGenericRetryPolicy(message: String): RetryPolicy<T> {
return RetryPolicy<T>().withDelay(2, 5, ChronoUnit.SECONDS).withMaxAttempts(maxAttempts)
.onFailedAttempt { e: ExecutionAttemptedEvent<T> ->
log.warn(message + "#${e.attemptCount} tries failed", e.lastFailure)
}
fun <T> buildRetryPolicy(message: String): RetryPolicy<T> {
return RetryPolicy.builder<T>().withDelay(2, 5, ChronoUnit.SECONDS).withMaxAttempts(maxAttempts)
.onFailedAttempt {
log.warn(message + "#${it.attemptCount} tries failed", it.lastException)
}.build()
}
}
@@ -1,9 +1,6 @@
package me.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@@ -13,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
@@ -24,7 +21,6 @@ class SettingsService(private val eventBus: EventBus) {
private val configPath = VRIPPER_DIR.resolve("config.json")
private val customProxiesPath = VRIPPER_DIR.resolve("proxies.json")
private val proxies: MutableSet<String> = HashSet()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val json = Json {
encodeDefaults = true
@@ -33,18 +29,13 @@ class SettingsService(private val eventBus: EventBus) {
}
var settings = Settings()
init {
init()
}
private fun init() {
fun init() {
loadViperProxies()
restore()
coroutineScope.launch {
eventBus.publishEvent(SettingsUpdateEvent(settings))
}
eventBus.publishEvent(SettingsUpdateEvent(settings))
}
@OptIn(ExperimentalSerializationApi::class)
private fun loadViperProxies() {
try {
SettingsService::class.java.getResourceAsStream("/proxies.json")?.use {
@@ -91,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
}
@@ -100,9 +91,7 @@ class SettingsService(private val eventBus: EventBus) {
}
this.settings = settings.copy(viperSettings = viperSettings)
save()
coroutineScope.launch {
eventBus.publishEvent(SettingsUpdateEvent(this@SettingsService.settings))
}
eventBus.publishEvent(SettingsUpdateEvent(this@SettingsService.settings))
}
private fun restore() {
@@ -146,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
@@ -197,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,21 @@ package me.vripper.services
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.utilities.extractBaseUrl
import me.vripper.vgapi.ThreadItem
import me.vripper.vgapi.ThreadLookupAPIParser
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import kotlin.jvm.optionals.getOrNull
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 dataAccessService: DataAccessService) {
private val cache: LoadingCache<Long, ThreadItem> =
Caffeine.newBuilder().expireAfterWrite(20, TimeUnit.MINUTES).build { threadId ->
runBlocking {
ThreadLookupAPIParser(threadId).parse()
}
}
Caffeine.newBuilder().expireAfterWrite(20, TimeUnit.MINUTES).build(::cacheLoader)
fun invalidate() {
cache.invalidateAll()
}
@Throws(ExecutionException::class)
operator fun get(threadId: Long): ThreadItem {
return cache[threadId]
}
@@ -38,4 +24,31 @@ internal class ThreadCacheService(val eventBus: EventBus) {
fun getIfPresent(threadId: Long): ThreadItem? {
return cache.getIfPresent(threadId)
}
fun loadThenCache(threadId: Long, siteProxy: String): ThreadItem {
return cache.get(threadId) {
cacheLoader(threadId, siteProxy)
}
}
private fun cacheLoader(threadId: Long, siteProxy: String? = null): ThreadItem? {
val result = if (siteProxy != null) {
ThreadLookupAPIParser(siteProxy, threadId).parse()
} else {
dataAccessService.findThreadByThreadId(threadId).map { threadEntity ->
ThreadLookupAPIParser(threadEntity.link.extractBaseUrl(), threadId).parse()
}.getOrNull()
}
if (result == null) {
throw Exception("Failed to load thread $threadId")
}
dataAccessService.findThreadByThreadId(result.threadId).ifPresent {
if (result.postItemList.isNotEmpty()) {
dataAccessService.update(it.copy(total = result.postItemList.size))
}
}
return result
}
}
@@ -1,72 +1,58 @@
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.GlobalScopeCoroutine
import me.vripper.utilities.LoggerDelegate
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()
loggedUser = ""
coroutineScope.launch {
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
synchronized(this) {
authenticated = false
loggedUser = ""
}
synchronized(vgCookies) {
vgCookies.clear()
}
log.debug("[{}] Publishing event: VGUserLoginEvent", System.currentTimeMillis())
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
val username = settings.viperSettings.username
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()
loggedUser = ""
coroutineScope.launch {
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
synchronized(this) {
authenticated = false
loggedUser = ""
}
synchronized(vgCookies) {
vgCookies.clear()
}
log.debug("[{}] Publishing event: VGUserLoginEvent (empty credentials)", System.currentTimeMillis())
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
val postAuth = HttpPost(settings.viperSettings.host + "/login.php?do=login").also {
@@ -78,13 +64,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}")
@@ -92,38 +79,79 @@ 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
)
log.debug("[${System.currentTimeMillis()}] Publishing event: VGUserLoginEvent (missing cookies)")
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
} catch (e: Exception) {
context.cookieStore.clear()
loggedUser = ""
coroutineScope.launch {
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
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) {
log.error(
"Failed to authenticate user with " + settings.viperSettings.host, e
)
synchronized(this) {
authenticated = false
loggedUser = ""
}
log.debug("[{}] Publishing event: VGUserLoginEvent (exception)", System.currentTimeMillis())
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
authenticated = true
loggedUser = username
coroutineScope.launch {
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
synchronized(this) {
authenticated = true
loggedUser = username
}
log.debug("[{}] Publishing event: VGUserLoginEvent for user {}", System.currentTimeMillis(), username)
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
log.info("Successfully logged in as: {}", loggedUser)
}
fun leaveThanks(postEntity: PostEntity) {
GlobalScopeCoroutine.launch {
LeaveThanksTask(postEntity, authenticated, context).run()
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
}
}
@@ -0,0 +1,171 @@
package me.vripper.services.download
import me.vripper.entities.ImageEntity
import me.vripper.entities.PostEntity
import me.vripper.entities.Status
import me.vripper.event.EventBus
import me.vripper.event.StoppedEvent
import me.vripper.services.DataAccessService
import me.vripper.services.SettingsService
import me.vripper.services.download.SharedLock.downloadManagerCondition
import me.vripper.services.download.SharedLock.downloadManagerLock
import me.vripper.utilities.LoggerDelegate
import org.jetbrains.exposed.sql.transactions.transaction
import kotlin.concurrent.withLock
import kotlin.math.min
internal class DownloadService(
private val settingsService: SettingsService,
private val dataAccessService: DataAccessService,
private val queueManager: QueueManager,
private val eventBus: EventBus
) {
private val maxPoolSize: Int = 24
private val log by LoggerDelegate()
private var downloadMonitorThread: Thread? = null
fun init() {
downloadMonitorThread?.interrupt()
downloadMonitorThread = Thread.ofVirtual().name("Download Monitor").unstarted(Runnable {
log.info("DownloadManager have been initialized")
while (!Thread.currentThread().isInterrupted) {
downloadManagerLock.withLock {
val candidates = getCandidates()
val canRunPerHost = canRun(candidates.keys)
val accepted = candidates.entries.flatMap {
it.value.take(canRunPerHost[it.key] ?: 0)
}
queueManager.accept(accepted)
try {
downloadManagerCondition.await()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
log.info("Scheduler have been shutdown")
})
downloadMonitorThread?.start()
}
fun halt() {
downloadMonitorThread?.interrupt()
}
fun stop(postEntityIds: List<Long> = emptyList()) {
downloadManagerLock.withLock {
if (postEntityIds.isNotEmpty()) {
stopInternal(postEntityIds)
log.debug(
"[{}] Publishing event: StoppedEvent for {} posts",
System.currentTimeMillis(),
postEntityIds.size
)
eventBus.publishEvent(StoppedEvent(postEntityIds))
} else {
stopAll()
log.debug("[{}] Publishing event: StoppedEvent for all posts", System.currentTimeMillis())
eventBus.publishEvent(StoppedEvent(listOf(-1)))
}
}
}
fun restartAll(postEntities: List<PostEntity> = emptyList()) {
if (postEntities.isNotEmpty()) {
restart(postEntities.associateWith { dataAccessService.findByPostEntityIdAndIsNotCompleted(it.id) })
} else {
restart(
dataAccessService.findAllPosts()
.associateWith { dataAccessService.findByPostEntityIdAndIsNotCompleted(it.id) })
}
}
fun move(postEntityId: Long, position: MovePosition) {
queueManager.move(postEntityId, position)
}
private fun restart(posts: Map<PostEntity, List<ImageEntity>>) {
downloadManagerLock.withLock {
val toProcess = mutableMapOf<PostEntity, List<ImageEntity>>()
for ((post, images) in posts) {
if (images.isEmpty()) {
continue
}
if (queueManager.isPending(post.id)) {
continue
}
toProcess[post] = images
}
toProcess.forEach { (post, images) ->
post.status = Status.PENDING
images.forEach { image ->
with(image) {
this.status = Status.PENDING
this.downloaded = 0
}
}
}
transaction {
dataAccessService.updatePosts(
toProcess.keys.toList()
)
dataAccessService.updateImages(
toProcess.values.flatten()
)
}
toProcess.entries.sortedBy { it.key.addedOn }.forEach { (post, images) ->
images.map { image ->
log.debug("Enqueuing a job for ${post.url}")
ImageQueueElement(
image.id, image.postEntityId, image.host
)
}.also { queueManager.addPending(it) }
}
downloadManagerCondition.signal()
}
}
private fun stopAll() {
queueManager.clearPending()
queueManager.clearRunning()
dataAccessService.findAllNonCompletedPostEntityIds().forEach {
dataAccessService.stopImagesByPostEntityIdAndIsNotCompleted(it)
dataAccessService.finishPost(it)
}
}
private fun stopInternal(postEntityIds: List<Long>) {
postEntityIds.forEach {
queueManager.clearPending(it)
queueManager.clearRunning(it)
dataAccessService.stopImagesByPostEntityIdAndIsNotCompleted(it)
dataAccessService.finishPost(it)
}
}
private fun canRun(hosts: Set<Byte>): Map<Byte, Int> {
val runningSnapshot = queueManager.runningTotalCount().toMutableMap()
return hosts.associateWith {
val totalRunning = runningSnapshot.values.sum()
val canRunGlobally =
if (settingsService.settings.connectionSettings.maxGlobalConcurrent == 0) maxPoolSize - totalRunning else settingsService.settings.connectionSettings.maxGlobalConcurrent - totalRunning
val canRunPerHost =
settingsService.settings.connectionSettings.maxConcurrentPerHost - (runningSnapshot[it] ?: 0)
val min = min(canRunGlobally, canRunPerHost)
val actual = if (min < 0) 0 else min
runningSnapshot[it] = (runningSnapshot[it] ?: 0) + actual
actual
}
}
private fun getCandidates(): Map<Byte, List<ImageQueueElement>> {
return queueManager.pending().groupBy { it.host }
}
}
@@ -0,0 +1,217 @@
package me.vripper.services.download
import dev.failsafe.function.CheckedRunnable
import kotlinx.coroutines.*
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.DataAccessService
import me.vripper.services.HTTPService
import me.vripper.services.VGAuthService
import me.vripper.utilities.ApplicationProperties
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.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.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 kotlin.io.path.Path
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.pathString
import kotlin.io.path.readLines
internal class ImageDownloadRunnable(
imageEntity: ImageEntity, settings: Settings
) : KoinComponent, CheckedRunnable {
class Context(val imageEntity: ImageEntity, val settings: Settings) {
private val log by LoggerDelegate()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val jobs = mutableListOf<Job>()
val httpContext: HttpClientContext = HttpClientContext.create().apply {
cookieStore = HTTPService.cookieStore
}
val requests = mutableListOf<HttpUriRequestBase>()
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 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")
}
}
fun clear() {
runBlocking {
requests.forEach { it.abort() }
coroutineScope.cancel()
jobs.forEach { job -> job.cancelAndJoin() }
jobs.clear()
}
}
fun launchCoroutine(block: suspend CoroutineScope.() -> Unit): Job {
return coroutineScope.launch(block = block).also { job -> jobs.add(job) }
}
}
private val log by LoggerDelegate()
private val dataAccessService: DataAccessService by inject()
private val vgAuthService: VGAuthService by inject()
private val hosts: List<Host> = getKoin().getAll()
val context = Context(imageEntity, settings)
var completed = false
var stopped = false
fun download() {
try {
context.imageEntity.status = Status.DOWNLOADING
context.imageEntity.downloaded = 0
dataAccessService.updateImage(context.imageEntity)
synchronized(context.imageEntity.postEntityId.toString().intern()) {
val post = dataAccessService.findPostByEntityId(context.imageEntity.postEntityId)
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataAccessService.updatePost(post)
vgAuthService.leaveThanks(post)
}
}
log.debug("Getting image url and name from ${context.imageEntity.url} using ${context.imageEntity.host}")
val host = hosts.first { it.isSupported(context.imageEntity.url) }
val downloadedImage = host.downloadInternal(context)
log.debug("Resolved name for ${context.imageEntity.url}: ${downloadedImage.name}")
log.debug("Downloaded image {} to {}", context.imageEntity.url, downloadedImage.path)
synchronized(context.imageEntity.postEntityId.toString().intern()) {
val post = dataAccessService.findPostByEntityId(context.imageEntity.postEntityId)
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
checkImageTypeAndRename(
downloadDirectory, downloadedImage, context.imageEntity.index
)
if (context.imageEntity.downloaded == context.imageEntity.size && context.imageEntity.size > 0) {
context.imageEntity.status = Status.FINISHED
post.done += 1
post.downloaded += context.imageEntity.size
dataAccessService.updatePost(post)
} else {
context.imageEntity.status = Status.ERROR
}
dataAccessService.updateImage(context.imageEntity)
}
} catch (e: Exception) {
if (stopped) {
return
}
context.imageEntity.status = Status.ERROR
dataAccessService.updateImage(context.imageEntity)
throw DownloadException(e)
}
}
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 (context.settings.downloadSettings.forceOrder) String.format(
"%03d_", index + 1
) else ""
}$filename"
context.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() {
try {
if (stopped) {
return
}
download()
} finally {
completed = true
context.clear()
}
}
fun stop() {
stopped = true
context.clear()
dataAccessService.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 context.imageEntity.id == that.context.imageEntity.id
}
override fun hashCode(): Int {
return Objects.hash(context.imageEntity.id)
}
}
@@ -0,0 +1,3 @@
package me.vripper.services.download
internal data class ImageQueueElement(val imageEntityId: Long, val postEntityId: Long, val host: Byte)
@@ -0,0 +1,5 @@
package me.vripper.services.download
enum class MovePosition {
UP, DOWN, TOP, BOTTOM
}
@@ -0,0 +1,181 @@
package me.vripper.services.download
import dev.failsafe.Failsafe
import dev.failsafe.RetryPolicy
import me.vripper.entities.Status
import me.vripper.event.ErrorCountEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.host.Host
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.model.Rank
import me.vripper.services.DataAccessService
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.download.SharedLock.downloadManagerCondition
import me.vripper.services.download.SharedLock.downloadManagerLock
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.downloadRunner
import java.util.*
import kotlin.concurrent.withLock
internal class QueueManager(
private val dataAccessService: DataAccessService,
private val hosts: List<Host>,
private val settingsService: SettingsService,
private val retryPolicyService: RetryPolicyService,
private val eventBus: EventBus
) {
private val log by LoggerDelegate()
private val running: MutableList<ImageDownloadRunnable> = mutableListOf()
private val pending: MutableList<MutableList<ImageQueueElement>> = mutableListOf()
fun addPending(imageQueueElementList: List<ImageQueueElement>) {
pending.add(imageQueueElementList.toMutableList())
reportQueueState()
}
fun clearPending(postEntityId: Long? = null) {
val toProcess = if (postEntityId != null) pending.flatten()
.filter { it.postEntityId == postEntityId } else pending.flatten()
pending.forEach { it.removeAll(toProcess) }
pending.removeIf { it.isEmpty() }
reportQueueState()
}
fun runningTotalCount(): Map<Byte, Int> {
return hosts.associate { host ->
Pair(
host.hostId, running.count { it.context.imageEntity.host == host.hostId })
}
}
fun clearRunning(postEntityId: Long? = null) {
val toProcess =
if (postEntityId != null) running.filter { it.context.imageEntity.postEntityId == postEntityId } else running
toProcess.forEach { it.stop() }
while (toProcess.count { !it.completed } > 0) {
Thread.sleep(100)
}
running.removeAll(toProcess)
}
fun clearRunningRunnable(imageId: Long) {
running.removeIf { it.context.imageEntity.id == imageId }
}
fun accept(accepted: List<ImageQueueElement>) {
downloadManagerLock.withLock {
pending.forEach {
it.removeAll(accepted)
}
pending.removeIf { it.isEmpty() }
accepted.map {
ImageDownloadRunnable(
dataAccessService.findImageById(it.imageEntityId).orElseThrow(), settingsService.settings.copy()
)
}.forEach {
launch(it)
running.add(it)
}
reportQueueState()
}
}
fun pending(): List<ImageQueueElement> {
return pending.flatten()
}
fun isPending(postEntityId: Long): Boolean {
return pending.flatten().any { it.postEntityId == postEntityId }
}
fun isRunning(postEntityId: Long): Boolean {
return running.any { it.context.imageEntity.postEntityId == postEntityId }
}
fun move(postEntityId: Long, position: MovePosition) {
downloadManagerLock.withLock {
val index = pending.indexOfFirst { it.first().postEntityId == postEntityId }
when (position) {
MovePosition.UP -> {
val newIndex = if (index > 0) index - 1 else index
Collections.swap(pending, index, newIndex)
}
MovePosition.DOWN -> {
val newIndex = if (index < pending.size - 1) index + 1 else index
Collections.swap(pending, index, newIndex)
}
MovePosition.TOP -> {
if (index > 0) {
val element = pending.removeAt(index)
pending.addFirst(element)
}
}
MovePosition.BOTTOM -> {
if (index < pending.size - 1) {
val element = pending.removeAt(pending.size - 1)
pending.addLast(element)
}
}
}
reportQueueState()
}
}
fun getQueueState(): QueueState {
val ranks = downloadManagerLock.withLock {
pending.mapIndexed { index, elements -> Rank(elements.first().postEntityId, index + 1L) }
}
return QueueState(running.size, pending().size, ranks)
}
private fun launch(runnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${runnable.context.imageEntity.url}")
reportQueueState()
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicy("Failed to download ${runnable.context.imageEntity.url}: "))
.with(downloadRunner).onFailure {
log.error(
"Failed to download ${runnable.context.imageEntity.url} after ${it.attemptCount} tries",
it.exception
)
val image = runnable.context.imageEntity
image.status = Status.ERROR
dataAccessService.updateImage(image)
}.onComplete {
afterJobFinish(runnable)
log.debug(
"Finished downloading ${runnable.context.imageEntity.url}"
)
}.runAsync(runnable)
}
private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
downloadManagerLock.withLock {
val image = imageDownloadRunnable.context.imageEntity
clearRunningRunnable(imageDownloadRunnable.context.imageEntity.id)
if (!isPending(image.postEntityId) && !isRunning(image.postEntityId) && !imageDownloadRunnable.stopped) {
dataAccessService.finishPost(image.postEntityId, true)
}
reportQueueState()
log.debug("[{}] Event published: ErrorCountEvent after job finish", System.currentTimeMillis())
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataAccessService.countImagesInError())))
downloadManagerCondition.signal()
}
}
private fun reportQueueState() {
val queueState = getQueueState()
log.debug(
"[{}] Publishing event: QueueStateEvent(running={}, remaining={})",
System.currentTimeMillis(),
queueState.running,
queueState.remaining
)
eventBus.publishEvent(QueueStateEvent(queueState))
}
}
@@ -0,0 +1,9 @@
package me.vripper.services.download
import java.util.concurrent.locks.Condition
import java.util.concurrent.locks.ReentrantLock
object SharedLock {
val downloadManagerLock = ReentrantLock()
val downloadManagerCondition: Condition = downloadManagerLock.newCondition()
}
@@ -1,21 +1,20 @@
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.model.PostIdentifier
import me.vripper.services.DataAccessService
import me.vripper.services.MetadataService
import me.vripper.services.SettingsService
import me.vripper.services.ThreadCacheService
import me.vripper.services.download.DownloadService
import me.vripper.utilities.LoggerDelegate
import me.vripper.vgapi.PostItem
import me.vripper.vgapi.PostLookupAPIParser
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponent, Runnable {
internal class AddPostTask(private val items: List<PostIdentifier>) : KoinComponent, Runnable {
private val log by LoggerDelegate()
private val dataTransaction: DataTransaction by inject()
private val dataAccessService: DataAccessService by inject()
private val settingsService: SettingsService by inject()
private val downloadService: DownloadService by inject()
private val threadCacheService: ThreadCacheService by inject()
@@ -25,20 +24,21 @@ internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponen
try {
Tasks.increment()
val toProcess = mutableListOf<PostItem>()
for ((threadId, postId) in items) {
if (dataTransaction.exists(postId)) {
for ((siteProxy, threadId, postId) in items) {
if (dataAccessService.existsPostId(postId)) {
log.info("Post $postId already loaded")
continue
}
val link =
"https://${settingsService.settings.viperSettings.host}/threads/$threadId?p=$postId&viewfull=1#post$postId"
"$siteProxy/threads/$threadId?p=$postId&viewfull=1#post$postId"
val cachedThread = threadCacheService.getIfPresent(threadId)
val threadItem = cachedThread ?: runBlocking {
val threadItem = cachedThread ?:
PostLookupAPIParser(
threadId, postId
siteProxy, threadId, postId
).parse()
}
if (threadItem == null) {
log.error("Failed to load $link")
@@ -61,14 +61,18 @@ internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponen
toProcess.add(postItem)
}
if (toProcess.isEmpty()) {
return
}
val posts = try {
dataTransaction.newPosts(toProcess.toList())
dataAccessService.newPosts(toProcess.toList())
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
posts.forEach {
metadataService.fetchMetadata(it.postId)
metadataService.fetchMetadata(it)
}
if (settingsService.settings.downloadSettings.autoStart) {
downloadService.restartAll(posts)
@@ -0,0 +1,111 @@
package me.vripper.tasks
import me.vripper.entities.MetadataEntity
import me.vripper.entities.PostEntity
import me.vripper.exception.DownloadException
import me.vripper.exception.VripperException
import me.vripper.services.DataAccessService
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 postEntity: PostEntity,
) : 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 dataAccessService: DataAccessService 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", postEntity.vgPostId.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_${postEntity.vgPostId}']/div[contains(@class, 'postdetails')]",
) ?: throw VripperException("Unable to find post #'${postEntity.vgPostId}'")
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 #'${postEntity.vgPostId}'")
val node: Node = XpathUtils.getAsNode(
document, java.lang.String.format("//div[@id='post_message_%s']", postEntity.vgPostId)
) ?: throw VripperException("Unable to locate post content")
val titles = findTitleInContent(node)
val metadataEntity = MetadataEntity(postEntity.id, MetadataEntity.Data(postedBy, titles))
dataAccessService.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.vgPostId.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 {
@@ -1,9 +1,5 @@
package me.vripper.tasks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.vripper.event.EventBus
import me.vripper.event.LoadingTasks
import org.koin.core.component.KoinComponent
@@ -13,14 +9,11 @@ internal object Tasks : KoinComponent {
private val eventBus: EventBus by inject()
private var current = 0
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Synchronized
fun increment() {
if (current == 0) {
coroutineScope.launch {
eventBus.publishEvent(LoadingTasks(true))
}
eventBus.publishEvent(LoadingTasks(true))
}
current += 1
}
@@ -29,9 +22,7 @@ internal object Tasks : KoinComponent {
fun decrement() {
current -= 1
if (current == 0) {
coroutineScope.launch {
eventBus.publishEvent(LoadingTasks(false))
}
eventBus.publishEvent(LoadingTasks(false))
}
}
}
@@ -1,29 +1,30 @@
package me.vripper.tasks
import kotlinx.coroutines.launch
import me.vripper.entities.ThreadEntity
import me.vripper.model.PostIdentifier
import me.vripper.model.Settings
import me.vripper.model.ThreadPostId
import me.vripper.services.DataTransaction
import me.vripper.services.SettingsService
import me.vripper.services.DataAccessService
import me.vripper.services.ThreadCacheService
import me.vripper.utilities.GlobalScopeCoroutine
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.taskRunner
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
internal class ThreadLookupTask(private val threadId: Long, private val settings: Settings) : KoinComponent, Runnable {
internal class ThreadLookupTask(
private val siteProxy: String,
private val threadId: Long,
private val settings: Settings
) : KoinComponent, Runnable {
private val log by LoggerDelegate()
private val dataTransaction by inject<DataTransaction>()
private val settingsService by inject<SettingsService>()
private val dataAccessService by inject<DataAccessService>()
private val threadCacheService by inject<ThreadCacheService>()
private val link: String = "${settingsService.settings.viperSettings.host}/threads/$threadId"
private val link: String = "$siteProxy/threads/$threadId"
override fun run() {
try {
Tasks.increment()
if (dataTransaction.findThreadByThreadId(threadId).isEmpty) {
val threadLookupResult = threadCacheService[threadId]
if (dataAccessService.findThreadByThreadId(threadId).isEmpty) {
val threadLookupResult = threadCacheService.loadThenCache(threadId, siteProxy)
if (threadLookupResult.error.isNotBlank()) {
log.error("Error loading $link: ${threadLookupResult.error}")
return
@@ -34,16 +35,15 @@ internal class ThreadLookupTask(private val threadId: Long, private val settings
}
if (threadLookupResult.postItemList.size <= settings.downloadSettings.autoQueueThreshold) {
GlobalScopeCoroutine.launch {
taskRunner.submit(
AddPostTask(threadLookupResult.postItemList.map {
ThreadPostId(
it.threadId, it.postId
PostIdentifier(
siteProxy, it.threadId, it.postId
)
}).run()
}
})
)
} else {
try {
dataTransaction.save(
dataAccessService.save(
ThreadEntity(
title = threadLookupResult.title,
link = link,
@@ -51,9 +51,6 @@ internal class ThreadLookupTask(private val threadId: Long, private val settings
total = threadLookupResult.postItemList.size
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
} catch (e: Exception) {
@@ -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,11 +38,24 @@ 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 {
val request = HttpRequest.newBuilder()
.uri(URI.create("https://api.github.com/repos/death-claw/vripper-project/releases/latest")).build()
.uri(URI.create("https://api.github.com/repos/dev-claw/vripper-project/releases/latest")).build()
return HttpClient.newHttpClient().use {
val response = it.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() / 100 != 2) {
@@ -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()
@@ -36,3 +36,7 @@ fun String.hash256(): String {
val digest = md.digest(bytes)
return digest.fold("") { str, it -> str + "%02x".format(it) }
}
fun String.extractBaseUrl(): String {
return this.replaceFirst(Regex("(https?://[^/]+).*"), "$1")
}
@@ -1,14 +0,0 @@
package me.vripper.utilities
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
private val log by LoggerDelegate()
val errorHandler = CoroutineExceptionHandler { _, exception ->
log.error("Unexpected error", exception)
}
val GlobalScopeCoroutine = CoroutineScope(SupervisorJob() + Dispatchers.IO + errorHandler)
@@ -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,18 +1,15 @@
package me.vripper.vgapi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withPermit
import dev.failsafe.Failsafe
import dev.failsafe.function.CheckedSupplier
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
import me.vripper.utilities.RequestLimit
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.function.CheckedSupplier
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.net.URIBuilder
@@ -20,11 +17,13 @@ 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 {
internal class PostLookupAPIParser(
private val siteProxy: String,
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,36 +32,35 @@ 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())
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting {}", httpGet)
}.build()).also { it.setAbsoluteRequestUri(true) }
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler(siteProxy)
Tasks.increment()
return try {
Failsafe.with(retryPolicyService.buildGenericRetryPolicy<Any>("Failed to parse $httpGet: ")).onFailure {
Failsafe.with(retryPolicyService.buildRetryPolicy<Any>("Failed to parse $httpGet: ")).onFailure {
log.error(
"Failed to process thread $threadId, post $postId", it.failure
"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)
@@ -1,18 +1,15 @@
package me.vripper.vgapi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withPermit
import dev.failsafe.Failsafe
import dev.failsafe.function.CheckedSupplier
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
import me.vripper.utilities.RequestLimit
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.function.CheckedSupplier
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.net.URIBuilder
@@ -20,46 +17,41 @@ 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 {
internal class ThreadLookupAPIParser(private val siteProxy: String, 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())
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting {}", httpGet)
}.build()).also { it.setAbsoluteRequestUri(true) }
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler(siteProxy)
Tasks.increment()
return try {
Failsafe.with(retryPolicyService.buildGenericRetryPolicy<Any>("Failed to parse $httpGet: ")).onFailure {
Failsafe.with(retryPolicyService.buildRetryPolicy<Any>("Failed to parse $httpGet: ")).onFailure {
log.error(
"Failed to process thread $threadId",
it.failure
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
@@ -1,15 +1,12 @@
package me.vripper.vgapi
import me.vripper.host.Host
import me.vripper.services.SettingsService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.xml.sax.Attributes
import org.xml.sax.helpers.DefaultHandler
internal class ThreadLookupAPIResponseHandler : KoinComponent, DefaultHandler() {
internal class ThreadLookupAPIResponseHandler(private val siteProxy: String) : KoinComponent, DefaultHandler() {
private val supportedHosts: List<Host> = getKoin().getAll()
private val settingsService: SettingsService by inject()
private var error: String = ""
private val hostMap: MutableMap<Host, Int> = mutableMapOf()
private val postItemList: MutableList<PostItem> = mutableListOf()
@@ -74,7 +71,7 @@ internal class ThreadLookupAPIResponseHandler : KoinComponent, DefaultHandler()
postCounter,
postTitle,
imageItemList.size,
"${settingsService.settings.viperSettings.host}/threads/$threadId?p=$postId&viewfull=1#post$postId",
"$siteProxy/threads/$threadId?p=$postId&viewfull=1#post$postId",
hostMap.toMap().map { Pair(it.key.hostName, it.value) },
securityToken,
forum,
@@ -27,9 +27,14 @@ message DownloadSpeed {
message VGUser {
string user = 1;
}
message Rank {
int64 postEntityId = 1;
int64 rank = 2;
}
message QueueState {
int32 running = 1;
int32 remaining = 2;
repeated Rank rank = 3;
}
message ErrorCount {
int32 count = 1;
@@ -78,6 +83,16 @@ message Version {
message DBMigrationResponse {
string message = 1;
}
enum MovePositionEnum {
UP = 0;
DOWN = 1;
TOP = 2;
BOTTOM = 3;
}
message MovePositionMessage {
int64 postEntityId = 1;
MovePositionEnum position = 2;
}
service EndpointService {
rpc scanLinks (Links) returns (EmptyResponse) {}
@@ -94,6 +109,7 @@ service EndpointService {
rpc onDownloadSpeed (EmptyRequest) returns (stream DownloadSpeed) {}
rpc onVGUserUpdate (EmptyRequest) returns (stream VGUser) {}
rpc onQueueStateUpdate (EmptyRequest) returns (stream QueueState) {}
rpc getQueueState (EmptyRequest) returns (QueueState) {}
rpc onErrorCountUpdate (EmptyRequest) returns (stream ErrorCount) {}
rpc onTasksRunning (EmptyRequest) returns (stream TasksRunning) {}
rpc onUpdateMetadata (EmptyRequest) returns (stream Metadata) {}
@@ -120,4 +136,5 @@ service EndpointService {
rpc getVersion (EmptyRequest) returns (Version) {}
rpc dbMigration (EmptyRequest) returns (DBMigrationResponse) {}
rpc initLogger (EmptyRequest) returns (EmptyResponse) {}
rpc move (MovePositionMessage) returns (EmptyResponse) {}
}
+9 -10
View File
@@ -3,14 +3,13 @@ option java_package = "me.vripper.proto";
message Image {
int64 id = 1;
int64 postId = 2;
string url = 3;
string thumbUrl = 4;
int32 host = 5;
int32 index = 6;
int64 postIdRef = 7;
int64 size = 8;
int64 downloaded = 9;
string status = 10;
string filename = 11;
string url = 2;
string thumbUrl = 3;
int32 host = 4;
int32 index = 5;
int64 postIdRef = 6;
int64 size = 7;
int64 downloaded = 8;
string status = 9;
string filename = 10;
}
+5 -6
View File
@@ -17,10 +17,9 @@ message Post {
string folderName = 13;
string status = 14;
int32 done = 15;
int32 rank = 16;
int64 size = 17;
int64 downloaded = 18;
repeated string previews = 19;
string postedBy = 20;
repeated string resolvedNames = 21;
int64 size = 16;
int64 downloaded = 17;
repeated string previews = 18;
string postedBy = 19;
repeated string resolvedNames = 20;
}
@@ -7,6 +7,8 @@ message ViperSettings {
string password = 3;
bool thanks = 4;
string host = 5;
int64 requestLimit = 6;
bool fetchMetadata = 7;
}
message DownloadSettings {
@@ -142,4 +142,15 @@
</column>
</createTable>
</changeSet>
<changeSet author="vripper" id="0007">
<renameColumn oldColumnName="POST_ID" newColumnName="POST_ID_REF" tableName="METADATA"/>
</changeSet>
<changeSet author="vripper" id="0009">
<dropIndex tableName="IMAGE" indexName="IMAGE_POST_ID_IDX"/>
</changeSet>
<changeSet author="vripper" id="0010">
<sql>
ALTER TABLE IMAGE DROP COLUMN POST_ID
</sql>
</changeSet>
</databaseChangeLog>
+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"/>
+1 -1
View File
@@ -1 +1 @@
${project.version}
${app-version}
+9 -4
View File
@@ -16,7 +16,7 @@
<description>vripper-gui</description>
<properties>
<javafx.version>23.0.1</javafx.version>
<javafx.version>25</javafx.version>
</properties>
<dependencies>
@@ -114,7 +114,7 @@
<dependency>
<groupId>io.github.mkpaz</groupId>
<artifactId>atlantafx-base</artifactId>
<version>2.0.1</version>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>org.kordamp.ikonli</groupId>
@@ -147,6 +147,11 @@
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
</dependency>
<dependency>
<groupId>com.google.jimfs</groupId>
<artifactId>jimfs</artifactId>
<version>1.3.1</version>
</dependency>
</dependencies>
<build>
@@ -160,14 +165,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>
@@ -1,7 +1,6 @@
package me.vripper.gui
import atlantafx.base.theme.CupertinoDark
import atlantafx.base.theme.CupertinoLight
import atlantafx.base.theme.*
import javafx.application.Application
import javafx.scene.image.Image
import javafx.stage.Stage
@@ -31,26 +30,32 @@ 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
}
override fun start(stage: Stage) {
Thread.setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler { t, e ->
log.error("Thread $t threw an exception", e)
})
if (widgetsController.currentSettings.darkMode) {
setUserAgentStylesheet(CupertinoDark().userAgentStylesheet)
} else {
setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
Thread.setDefaultUncaughtExceptionHandler { t, e ->
log.error("Thread $t threw an exception: ${e.message}", e)
}
when (widgetsController.currentSettings.theme) {
"CupertinoLight" -> setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
"CupertinoDark" -> setUserAgentStylesheet(CupertinoDark().userAgentStylesheet)
"NordLight" -> setUserAgentStylesheet(NordLight().userAgentStylesheet)
"NordDark" -> setUserAgentStylesheet(NordDark().userAgentStylesheet)
"PrimerLight" -> setUserAgentStylesheet(PrimerLight().userAgentStylesheet)
"PrimerDark" -> setUserAgentStylesheet(PrimerDark().userAgentStylesheet)
"Dracula" -> setUserAgentStylesheet(Dracula().userAgentStylesheet)
else -> setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
}
with(stage) {
width = widgetsController.currentSettings.width
height = widgetsController.currentSettings.height
minWidth = 800.0
minHeight = 600.0
minWidth = 100.0
minHeight = 100.0
icons.addAll(
listOf(
Image("icons/16x16.png"),
@@ -1,17 +1,16 @@
package me.vripper.gui.components.cells
import javafx.collections.ObservableList
import javafx.geometry.Pos
import javafx.scene.control.TableCell
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
class PreviewTableCell<T> : TableCell<T, ObservableList<String>>() {
class PreviewTableCell<T, V> : TableCell<T, V>() {
init {
alignment = Pos.CENTER
}
override fun updateItem(item: ObservableList<String>?, empty: Boolean) {
override fun updateItem(item: V, empty: Boolean) {
super.updateItem(item, empty)
graphic = if (!empty) {
FontIcon.of(Feather.IMAGE)
@@ -23,24 +23,61 @@ class AboutFragment : Fragment("About") {
padding = Insets(15.0, 15.0, 15.0, 15.0)
spacing = 15.0
imageview("icons/64x64.png")
vbox {
spacing = 5.0
text("VRipper") {
vbox(spacing = 5.0) {
text("VRipper ${ApplicationProperties.VERSION}") {
style {
fontWeight = FontWeight.BOLD
fontSize = Dimension(18.0, Dimension.LinearUnits.px)
}
}
text("Version ${ApplicationProperties.VERSION}")
text("Developed by death-claw and VRipper working group")
hyperlink("Home Page") {
action {
openLink("https://github.com/death-claw/vripper-project")
text("Developed by dev-claw and VRipper working group")
hbox(spacing = 5.0) {
hyperlink {
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")
}
}
}
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")
}
}
}
field("ETH:") {
textfield("0xDdac82B16dC5E3D742fc915ffF583D8548A301cA") {
isEditable = false
}
}
field("BTC:") {
textfield("bc1qcqudnkrndwyadsjwrxww42svkf8trnzx3c8vlr") {
isEditable = false
}
}
}
}
}
}
}
tab("System") {
form {
@@ -51,19 +88,6 @@ class AboutFragment : Fragment("About") {
isEditable = false
}
}
field("Previews Path") {
textfield(widgetsController.currentSettings.cachePathProperty) {
isEditable = false
}
button("Browse") {
action {
val directory = chooseDirectory(title = "Select previews folder")
if (directory != null) {
widgetsController.currentSettings.cachePathProperty.set(directory.path)
}
}
}
}
}
}
}

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