Compare commits

..
Author SHA1 Message Date
death-claw c77e3fd5c7 fixes #143 2023-12-08 14:33:13 +01:00
death-claw e798218a21 version bump 2023-11-25 18:37:35 +01:00
death-claw 04df3bcc81 version bump 2023-11-25 18:30:02 +01:00
death-clawandGitHub 7af0aeafa7 fixes #137 (#138) 2023-11-25 18:25:57 +01:00
death-claw 90625736e9 fix release.yml 2023-11-14 01:03:46 +01:00
death-claw eff254747f fix release.yml 2023-11-14 01:00:40 +01:00
death-claw b8b22f7faa fix release.yml 2023-11-14 00:50:54 +01:00
death-clawandGitHub 6376576e9e fixes #130 (#131) 2023-11-14 00:38:43 +01:00
252 changed files with 9389 additions and 8279 deletions
+14 -11
View File
@@ -13,14 +13,17 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
server-id: github # Value of the distributionManagement/repository/id field of the pom.xml
settings-path: ${{ github.workspace }} # location for the settings.xml file
- name: Build parent project
run: mvn -N -q install --file pom.xml
- name: Build Core Jar
run: mvn -B -q install --file vripper-core/pom.xml
@@ -28,10 +31,10 @@ jobs:
run: mvn -B -q install --file vripper-gui/pom.xml
- name: Rename GUI Jar
run: mv vripper-gui/target/vripper-gui-${{ github.event.release.tag_name }}.jar vripper-gui/target/vripper-noarch-gui-${{ github.event.release.tag_name }}.jar
run: mv vripper-gui/target/vripper-gui-${{ github.event.release.tag_name }}-jar-with-dependencies.jar vripper-gui/target/vripper-noarch-gui-${{ github.event.release.tag_name }}.jar
- if: matrix.os == 'ubuntu-latest'
name: Upload GUI Jar
name: Release GUI Jar
uses: softprops/action-gh-release@v1
with:
files: vripper-gui/target/vripper-noarch-gui-${{ github.event.release.tag_name }}.jar
@@ -48,7 +51,7 @@ jobs:
run: mv vripper-web/target/vripper-web-${{ github.event.release.tag_name }}.jar vripper-web/target/vripper-noarch-web-${{ github.event.release.tag_name }}.jar
- if: matrix.os == 'ubuntu-latest'
name: Upload WEB Jar
name: Release WEB Jar
uses: softprops/action-gh-release@v1
with:
files: vripper-web/target/vripper-noarch-web-${{ github.event.release.tag_name }}.jar
@@ -90,7 +93,7 @@ jobs:
mv dist/VRipper-${{ github.event.release.tag_name }}.dmg dist/vripper-macos-${{ github.event.release.tag_name }}.dmg
- if: matrix.os == 'ubuntu-latest'
name: Zip portable
name: Zip Ubuntu portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
@@ -99,7 +102,7 @@ jobs:
filename: 'vripper-linux-portable-${{ github.event.release.tag_name }}.zip'
- if: matrix.os == 'windows-latest'
name: Zip portable
name: Zip Windows portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
@@ -108,7 +111,7 @@ jobs:
filename: 'vripper-windows-portable-${{ github.event.release.tag_name }}.zip'
- if: matrix.os == 'macos-latest'
name: Zip portable
name: Zip MacOS portable
uses: thedoctor0/zip-release@0.7.1
with:
type: 'zip'
@@ -117,7 +120,7 @@ jobs:
filename: 'vripper-macos-portable-${{ github.event.release.tag_name }}.zip'
- if: matrix.os == 'ubuntu-latest'
name: Upload DEB and RPM package for Linux
name: Release packages for Linux
uses: softprops/action-gh-release@v1
with:
files: |
@@ -126,7 +129,7 @@ jobs:
jpackage/dist/vripper-linux-portable-${{ github.event.release.tag_name }}.zip
- if: matrix.os == 'windows-latest'
name: Upload packages for Windows
name: Release packages for Windows
uses: softprops/action-gh-release@v1
with:
files: |
@@ -135,7 +138,7 @@ jobs:
jpackage/dist/vripper-windows-portable-${{ github.event.release.tag_name }}.zip
- if: matrix.os == 'macos-latest'
name: Upload package for macOS
name: Release packages for macOS
uses: softprops/action-gh-release@v1
with:
files: |
+1 -1
View File
@@ -1,2 +1,2 @@
--type app-image
--java-options "-Dbase.dir=${user.dir}"
--java-options "-Dvripper.portable=true"
+1 -1
View File
@@ -1,5 +1,5 @@
--icon icon.png
--java-options "-Dbase.dir=${user.home}/.config"
--java-options "-Dvripper.portable=false"
--linux-package-name vripper
--linux-app-release 1
--linux-menu-group Utility
+1 -1
View File
@@ -1,4 +1,4 @@
--icon icon.icns
--java-options "-Dbase.dir=${user.home}/.config"
--java-options "-Dvripper.portable=false"
--mac-package-identifier me.mnlr.vripper.vripper-gui
--mac-package-name VRipper
+1 -1
View File
@@ -1,5 +1,5 @@
--icon icon.ico
--java-options "-Dbase.dir=${user.home}"
--java-options "-Dvripper.portable=false"
--win-dir-chooser
--win-menu
--win-per-user-install
+1 -1
View File
@@ -1,7 +1,7 @@
--input jar
--main-jar vripper-gui.jar
--add-modules java.base,java.desktop,java.sql,jdk.unsupported,jdk.crypto.ec,java.security.jgss
--jlink-options "--strip-native-commands --strip-debug --no-man-pages --no-header-files --compress=2"
--jlink-options "--strip-native-commands --strip-debug --no-man-pages --no-header-files --compress=zip-6"
--name VRipper
--description "Image ripper tool for vipergirls"
--dest dist
+20 -13
View File
@@ -3,24 +3,26 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>me.mnlr</groupId>
<groupId>me.vripper</groupId>
<artifactId>vripper-project</artifactId>
<modules>
<module>vripper-core</module>
<!-- <module>vripper-web-ui</module>-->
<!-- <module>vripper-web</module>-->
<module>vripper-gui</module>
</modules>
<packaging>pom</packaging>
<version>4.4.0</version>
<version>5.1.0</version>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
<kotlin.version>1.9.20</kotlin.version>
<maven.deploy.skip>false</maven.deploy.skip>
<timestamp>${maven.build.timestamp}</timestamp>
<maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<modules>
<module>vripper-core</module>
<module>vripper-web-ui</module>
<module>vripper-web</module>
<module>vripper-gui</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
@@ -31,7 +33,7 @@
<dependency>
<artifactId>kotlin-stdlib</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>1.9.10</version>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
@@ -116,7 +118,7 @@
<dependency>
<artifactId>kotlin-reflect</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>1.9.10</version>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<artifactId>jcl-over-slf4j</artifactId>
@@ -138,6 +140,11 @@
<groupId>org.liquibase</groupId>
<version>4.24.0</version>
</dependency>
<dependency>
<artifactId>liquibase-slf4j</artifactId>
<groupId>com.mattbertolini</groupId>
<version>5.0.0</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
+142 -119
View File
@@ -1,124 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.mnlr</groupId>
<artifactId>vripper-project</artifactId>
<version>4.4.0</version>
</parent>
<groupId>me.mnlr.vripper</groupId>
<artifactId>vripper-core</artifactId>
<name>vripper-core</name>
<description>vripper-core</description>
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<dependencies>
<dependency>
<artifactId>logback-classic</artifactId>
<groupId>ch.qos.logback</groupId>
</dependency>
<dependency>
<artifactId>kotlin-stdlib</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core-jvm</artifactId>
</dependency>
<dependency>
<artifactId>kotlin-reflect</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-java-time</artifactId>
</dependency>
<dependency>
<groupId>io.insert-koin</groupId>
<artifactId>koin-core-jvm</artifactId>
</dependency>
<dependency>
<artifactId>jackson-module-kotlin</artifactId>
<groupId>com.fasterxml.jackson.module</groupId>
</dependency>
<dependency>
<artifactId>h2</artifactId>
<groupId>com.h2database</groupId>
</dependency>
<dependency>
<artifactId>httpclient5</artifactId>
<groupId>org.apache.httpcomponents.client5</groupId>
</dependency>
<dependency>
<artifactId>jcl-over-slf4j</artifactId>
<groupId>org.slf4j</groupId>
</dependency>
<dependency>
<artifactId>htmlcleaner</artifactId>
<groupId>net.sourceforge.htmlcleaner</groupId>
</dependency>
<dependency>
<artifactId>failsafe</artifactId>
<groupId>net.jodah</groupId>
</dependency>
<dependency>
<artifactId>caffeine</artifactId>
<groupId>com.github.ben-manes.caffeine</groupId>
</dependency>
<dependency>
<artifactId>jackson-databind</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
</dependency>
<dependency>
<artifactId>liquibase-core</artifactId>
<groupId>org.liquibase</groupId>
</dependency>
</dependencies>
<parent>
<groupId>me.vripper</groupId>
<artifactId>vripper-project</artifactId>
<version>5.1.0</version>
</parent>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<source>src/main/kotlin</source>
<source>target/generated-sources/annotations</source>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<artifactId>vripper-core</artifactId>
<name>vripper-core</name>
<description>vripper-core</description>
<dependencies>
<dependency>
<artifactId>logback-classic</artifactId>
<groupId>ch.qos.logback</groupId>
</dependency>
<dependency>
<artifactId>kotlin-stdlib</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core-jvm</artifactId>
</dependency>
<dependency>
<artifactId>kotlin-reflect</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-java-time</artifactId>
</dependency>
<dependency>
<groupId>io.insert-koin</groupId>
<artifactId>koin-core-jvm</artifactId>
</dependency>
<dependency>
<artifactId>jackson-module-kotlin</artifactId>
<groupId>com.fasterxml.jackson.module</groupId>
</dependency>
<dependency>
<artifactId>h2</artifactId>
<groupId>com.h2database</groupId>
</dependency>
<dependency>
<artifactId>httpclient5</artifactId>
<groupId>org.apache.httpcomponents.client5</groupId>
</dependency>
<dependency>
<artifactId>jcl-over-slf4j</artifactId>
<groupId>org.slf4j</groupId>
</dependency>
<dependency>
<artifactId>htmlcleaner</artifactId>
<groupId>net.sourceforge.htmlcleaner</groupId>
</dependency>
<dependency>
<artifactId>failsafe</artifactId>
<groupId>net.jodah</groupId>
</dependency>
<dependency>
<artifactId>caffeine</artifactId>
<groupId>com.github.ben-manes.caffeine</groupId>
</dependency>
<dependency>
<artifactId>jackson-databind</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
</dependency>
<dependency>
<artifactId>liquibase-core</artifactId>
<groupId>org.liquibase</groupId>
</dependency>
<dependency>
<artifactId>liquibase-slf4j</artifactId>
<groupId>com.mattbertolini</groupId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</dependency>
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<source>src/main/kotlin</source>
<source>target/generated-sources/annotations</source>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifestEntries>
<Build-Time>${maven.build.timestamp}</Build-Time>
<Built-By>${user.name}</Built-By>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -1,13 +0,0 @@
package me.mnlr.vripper
import java.nio.file.Files
import java.nio.file.Path
object ApplicationProperties {
const val BASE_DIR_NAME: String = "vripper"
val baseDir: String = System.getProperty("base.dir", System.getProperty("user.dir"))
init {
Files.createDirectories(Path.of(baseDir, BASE_DIR_NAME))
}
}
@@ -1,273 +0,0 @@
package me.mnlr.vripper.download
import kotlinx.coroutines.*
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.Image
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.entities.Post
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.event.ErrorCountEvent
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.event.QueueStateEvent
import me.mnlr.vripper.formatToString
import me.mnlr.vripper.host.Host
import me.mnlr.vripper.model.ErrorCount
import me.mnlr.vripper.model.QueueState
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.services.DataTransaction
import me.mnlr.vripper.services.RetryPolicyService
import me.mnlr.vripper.services.SettingsService
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.RetryPolicy
import java.util.concurrent.locks.ReentrantLock
import java.util.stream.Collectors
import kotlin.concurrent.withLock
class DownloadService(
private val settingsService: SettingsService,
private val dataTransaction: DataTransaction,
private val retryPolicyService: RetryPolicyService,
private val eventRepository: LogEventRepository,
private val eventBus: EventBus
) {
private val maxPoolSize: Int = 12
private val log by LoggerDelegate()
// Class fields
private val running: MutableMap<String, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val pending: MutableMap<String, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun init() {
coroutineScope.launch(Dispatchers.Default) {
val accepted: MutableList<ImageDownloadRunnable> = mutableListOf()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
while (isActive) {
lock.withLock {
candidates.addAll(getCandidates(candidateCount()))
candidates.forEach {
if (canRun(it.context.image.host)) {
accepted.add(it)
running[it.context.image.host]!!.add(it)
log.debug("${it.context.image.url} accepted to run")
}
}
accepted.forEach {
pending[it.context.image.host]?.remove(it)
scheduleForDownload(it)
}
accepted.clear()
candidates.clear()
try {
condition.await()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
}
}
fun destroy() {
stop(dataTransaction.findAllPosts().map { it.postId })
}
fun stopAll(postIds: List<String>?) {
if (postIds != null) {
stop(postIds)
} else {
stop(dataTransaction.findAllPosts().map(Post::postId))
}
}
fun restartAll(postIds: List<String> = listOf()) {
if (postIds.isNotEmpty()) {
restart(postIds)
} else {
restart(dataTransaction.findAllPosts().map(Post::postId))
}
}
private fun restart(postIds: List<String>) {
lock.withLock {
val data: MutableMap<Post, Collection<Image>> = mutableMapOf()
for (postId in postIds) {
if (isPending(postId)) {
log.warn("Cannot restart, jobs are currently running for post id $postIds")
continue
}
val images: List<Image> =
dataTransaction.findByPostIdAndIsNotCompleted(postId)
if (images.isEmpty()) {
continue
}
val post: Post =
dataTransaction.findPostsByPostId(postId).orElseThrow()
log.debug("Restarting {} jobs for post id {}", images.size, postIds)
post.status = Status.PENDING
dataTransaction.update(post)
data[post] = images
}
for ((_, images) in data) {
for (image in images) {
log.debug("Enqueuing a job for ${image.url}")
with(image) {
this.status = Status.PENDING
this.current = 0
}
dataTransaction.update(image)
val imageDownloadRunnable = ImageDownloadRunnable(
image.id, settingsService.settings
)
pending.computeIfAbsent(
image.host
) { mutableListOf() }
pending[image.host]!!.add(imageDownloadRunnable)
}
}
condition.signal()
}
}
private fun isPending(postId: String): Boolean {
lock.withLock {
return pending.values.flatten().any { it.context.image.postId == postId }
}
}
private fun isRunning(postId: String): Boolean {
lock.withLock {
return running.values.flatten().any { it.context.image.postId == postId }
}
}
private fun stop(postIds: List<String>) {
lock.withLock {
for (postId in postIds) {
val post: Post =
dataTransaction.findPostsByPostId(postId).orElseThrow()
pending.values.forEach { pending ->
pending.removeIf { it.context.image.postId == postId }
}
running.values.flatten()
.filter { p: ImageDownloadRunnable -> p.context.image.postId == postId }
.forEach { obj: ImageDownloadRunnable -> obj.stop() }
dataTransaction.stopImagesByPostIdAndIsNotCompleted(postId)
dataTransaction.finishPost(post)
}
}
}
private fun canRun(host: String): Boolean {
val totalRunning = running.values.sumOf { it.size }
return (running[host]!!.size < settingsService.settings.connectionSettings.maxThreads && if (settingsService.settings.connectionSettings.maxTotalThreads == 0) totalRunning < maxPoolSize else totalRunning < settingsService.settings.connectionSettings.maxTotalThreads)
}
private fun candidateCount(): Map<String, Int> {
val map: MutableMap<String, Int> = mutableMapOf()
Host.getHosts().forEach { host: String ->
val imageDownloadRunnableList: List<ImageDownloadRunnable> = running.computeIfAbsent(
host
) { mutableListOf() }
val count: Int =
settingsService.settings.connectionSettings.maxThreads - imageDownloadRunnableList.size
log.debug("Download slots for $host: $count")
map[host] = count
}
return map
}
private fun getCandidates(candidateCount: Map<String, Int>): List<ImageDownloadRunnable> {
val hostIntegerMap: MutableMap<String, Int> = candidateCount.toMutableMap()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
hosts@ for (host in pending.keys) {
val list: List<ImageDownloadRunnable> =
pending[host]!!.sortedWith(Comparator.comparingInt<ImageDownloadRunnable> { it.context.post.rank }
.thenComparingInt { it.context.image.index })
for (imageDownloadRunnable in list) {
val count = hostIntegerMap[host] ?: 0
if (count > 0) {
candidates.add(imageDownloadRunnable)
hostIntegerMap[host] = count - 1
} else {
continue@hosts
}
}
}
if (log.isDebugEnabled) {
val collect: Map<String, List<ImageDownloadRunnable>> =
candidates.stream().collect(Collectors.groupingBy { it.context.image.host })
collect.forEach {
log.debug(
"Candidate download for ${it.key} ${it.value.size}/${candidateCount[it.key]}"
)
}
}
return candidates.sortedWith(Comparator.comparing { v: ImageDownloadRunnable -> v.context.post.rank })
}
private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${imageDownloadRunnable.context.image.url}")
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload())
.onFailure {
try {
eventRepository.save(
LogEvent(
type = LogEvent.Type.DOWNLOAD,
status = LogEvent.Status.ERROR,
message = "Failed to download ${imageDownloadRunnable.context.image.url}\n ${it.failure.formatToString()}"
)
)
} catch (exp: Exception) {
log.error("Failed to save event", exp)
}
log.error(
"Failed to download ${imageDownloadRunnable.context.image.url} after ${it.attemptCount} tries",
it.failure
)
val image = imageDownloadRunnable.context.image
image.status = Status.ERROR
dataTransaction.update(image)
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
}.onComplete {
afterJobFinish(imageDownloadRunnable)
eventBus.publishEvent(
QueueStateEvent(
QueueState(
runningCount(),
pendingCount()
)
)
)
log.debug(
"Finished downloading ${imageDownloadRunnable.context.image.url}"
)
}.runAsync(imageDownloadRunnable::run)
}
private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
lock.withLock {
val image = imageDownloadRunnable.context.image
running[image.host]!!.remove(imageDownloadRunnable)
if (!isPending(image.postId) && !isRunning(
image.postId
)
) {
dataTransaction.finishPost(imageDownloadRunnable.context.post)
}
condition.signal()
}
}
private fun pendingCount(): Int {
return pending.values.sumOf { it.size }
}
private fun runningCount(): Int {
return running.values.sumOf { it.size }
}
}
@@ -1,22 +0,0 @@
package me.mnlr.vripper.download
import me.mnlr.vripper.entities.Image
import me.mnlr.vripper.entities.Post
import me.mnlr.vripper.model.Settings
import me.mnlr.vripper.services.DataTransaction
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class ImageDownloadContext(val imageId: Long, val settings: Settings) : KoinComponent {
private val dataTransaction: DataTransaction by inject()
val httpContext: HttpClientContext = HttpClientContext.create()
val postId = image.postIdRef
var stopped = false
val image: Image
get() = dataTransaction.findImageById(imageId).orElseThrow()
val post: Post
get() = dataTransaction.findPostById(postId).orElseThrow()
}
@@ -1,158 +0,0 @@
package me.mnlr.vripper.download
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.entities.LogEvent.Status.*
import me.mnlr.vripper.exception.DownloadException
import me.mnlr.vripper.exception.PostParseException
import me.mnlr.vripper.formatToString
import me.mnlr.vripper.model.PostItem
import me.mnlr.vripper.parser.ThreadLookupAPIResponseHandler
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.services.*
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.function.CheckedSupplier
import org.apache.hc.client5.http.classic.HttpClient
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse
import org.apache.hc.client5.http.protocol.HttpClientContext
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 java.net.URISyntaxException
import java.util.*
import javax.xml.parsers.SAXParserFactory
class PostDownloadRunnable(private val threadId: String, private val postId: String) : KoinComponent, Runnable {
private val log by LoggerDelegate()
private val dataTransaction: DataTransaction by inject()
private val settingsService: SettingsService by inject()
private val vgAuthService: VGAuthService by inject()
private val eventRepository: LogEventRepository by inject()
private val cm: HTTPService by inject()
private val retryPolicyService: RetryPolicyService by inject()
private val downloadService: DownloadService by inject()
private val link: String =
"${settingsService.settings.viperSettings.host}/threads/$threadId?p=$postId"
private val logEvent: LogEvent
init {
logEvent = eventRepository.save(
LogEvent(
type = LogEvent.Type.POST,
status = PENDING,
message = "Processing $link"
)
)
}
override fun run() {
try {
eventRepository.update(logEvent.copy(status = PROCESSING))
if (dataTransaction.exists(postId)) {
log.warn(String.format("skipping %s, already loaded", postId))
eventRepository.update(
logEvent.copy(
status = ERROR,
message = String.format("Gallery %s is already loaded", link)
)
)
return
}
val postItem: PostItem = try {
parse()
} catch (e: PostParseException) {
val error = String.format("parsing failed for gallery %s", link)
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
return
}
if (postItem.imageItemList.isEmpty()) {
val error = String.format("Gallery %s contains no images to download", link)
log.error(error)
eventRepository.update(logEvent.copy(status = ERROR, message = error))
return
}
val post = dataTransaction.newPost(postItem)
vgAuthService.leaveThanks(post)
// metadataService.startFetchingMetadata(post)
if (settingsService.settings.downloadSettings.autoStart) {
log.debug("Auto start downloads option is enabled")
downloadService.restartAll(listOf(postItem.postId))
log.debug(String.format("Done enqueuing jobs for %s", postItem.url))
}
eventRepository.update(
logEvent.copy(
status = DONE,
message = String.format(
"Gallery %s is successfully added to download queue",
link
)
)
)
} catch (e: Exception) {
val error = String.format("Error when adding gallery %s", link)
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
}
}
@Throws(PostParseException::class)
fun parse(): PostItem {
log.debug("Parsing post $postId")
val httpGet: HttpGet = try {
val uriBuilder = URIBuilder("${settingsService.settings.viperSettings.host}/vr.php")
uriBuilder.setParameter("p", postId)
cm.buildHttpGet(uriBuilder.build(), HttpClientContext.create())
} catch (e: URISyntaxException) {
throw PostParseException(e)
}
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting $httpGet")
return try {
Failsafe.with(retryPolicyService.buildGenericRetryPolicy<Any>()).onFailure {
log.error(
"parsing failed for thread $threadId, post $postId", it.failure
)
}.get(CheckedSupplier {
val connection: HttpClient = cm.clientBuilder.build()
(connection.execute(
httpGet, vgAuthService.context
) as CloseableHttpResponse).use { response ->
try {
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
}
factory.newSAXParser()
.parse(response.entity.content, threadLookupAPIResponseHandler)
threadLookupAPIResponseHandler.result.postItemList.first()
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
})
} catch (e: Exception) {
throw PostParseException(e)
}
}
companion object {
private val factory = SAXParserFactory.newInstance()
}
}
@@ -1,23 +0,0 @@
package me.mnlr.vripper.event
import me.mnlr.vripper.entities.Image
import me.mnlr.vripper.entities.Post
import me.mnlr.vripper.entities.Thread
import me.mnlr.vripper.model.DownloadSpeed
import me.mnlr.vripper.model.ErrorCount
import me.mnlr.vripper.model.QueueState
import me.mnlr.vripper.model.Settings
data class PostCreateEvent(val post: Post)
data class PostUpdateEvent(val post: Post)
data class PostDeleteEvent(val postId: String)
data class ImageCreateEvent(val image: Image)
data class ImageUpdateEvent(val image: Image)
data class ThreadCreateEvent(val thread: Thread)
data class ThreadDeleteEvent(val threadId: String)
class ThreadClearEvent
data class VGUserLoginEvent(val username: String)
data class DownloadSpeedEvent(val downloadSpeed: DownloadSpeed)
data class QueueStateEvent(val queueState: QueueState)
data class ErrorCountEvent(val errorCount: ErrorCount)
data class SettingsUpdateEvent(val settings: Settings)
@@ -1,29 +0,0 @@
package me.mnlr.vripper.event
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterIsInstance
import kotlin.coroutines.coroutineContext
object EventBus {
private val _events = MutableSharedFlow<Any>(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
extraBufferCapacity = 100_000
)
val events = _events.asSharedFlow()
fun publishEvent(event: Any) {
_events.tryEmit(event)
}
suspend inline fun <reified T> subscribe(crossinline onEvent: (T) -> Unit) {
events.filterIsInstance<T>()
.collectLatest { event ->
coroutineContext.ensureActive()
onEvent(event)
}
}
}
@@ -1,239 +0,0 @@
package me.mnlr.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.DownloadException
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.getFileNameWithoutExtension
import me.mnlr.vripper.services.*
import org.apache.hc.client5.http.classic.HttpClient
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.apache.hc.core5.http.Header
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.w3c.dom.Document
import java.nio.file.Files
import java.nio.file.Path
abstract class Host(
val hostId: String,
private val httpService: HTTPService,
private val dataTransaction: DataTransaction,
private val downloadSpeedService: DownloadSpeedService
) {
private val log by LoggerDelegate()
companion object {
private const val READ_BUFFER_SIZE = 8192
private val hosts: MutableList<String> = mutableListOf()
fun getHosts(): List<String> {
return hosts.toList()
}
}
init {
hosts.add(hostId)
}
@Throws(HostException::class)
abstract fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
): Pair<String, String>
@Throws(HostException::class)
fun downloadInternal(url: String, context: ImageDownloadContext): DownloadedImage {
val headers = head(url, context.httpContext)
// is the body of type image ?
val imageMimeType = getImageMimeType(headers)
val downloadedImage = if (imageMimeType != null) {
// a direct link, awesome
val downloadedImage = fetch(url, context) {
handleImageDownload(it, context)
}
DownloadedImage(getDefaultImageName(url), downloadedImage.first, downloadedImage.second)
} else {
// linked image ?
val value = headers.find { it.name.contains("content-type", true) }?.value
if (value != null) {
if (value.contains("text/html")) {
val document = fetch(url, context) {
HtmlProcessorService.clean(it.entity.content)
}
if (log.isDebugEnabled) {
log.debug("Cleaning $url response", url)
}
val resolvedImage = resolve(url, document, context)
val downloadImage: Pair<Path, ImageMimeType> =
fetch(resolvedImage.second, context) {
handleImageDownload(it, context)
}
DownloadedImage(resolvedImage.first, downloadImage.first, downloadImage.second)
} else {
throw HostException("Unable to download $url, can't process content type $value")
}
} else {
throw HostException("Unexpected server response for $url, response have no content type")
}
}
return downloadedImage
}
private fun handleImageDownload(
response: CloseableHttpResponse,
context: ImageDownloadContext
): Pair<Path, ImageMimeType> {
val mimeType = getImageMimeType(response.headers)
?: throw HostException("Unsupported image type ${response.getFirstHeader("content-type")}")
val tempImage = Files.createTempFile(Path.of(context.settings.downloadSettings.tempPath), "vripper_", ".tmp")
return response.entity.content.use { inputStream ->
try {
Files.newOutputStream(tempImage).use { fos ->
val image = context.image
image.total = response.entity.contentLength
dataTransaction.update(image)
log.debug(
"Length is ${image.total}"
)
log.debug(
"Starting data transfer"
)
val buffer = ByteArray(READ_BUFFER_SIZE)
var read: Int
while (inputStream.read(buffer, 0, READ_BUFFER_SIZE)
.also { read = it } != -1 && !context.stopped
) {
fos.write(buffer, 0, read)
with(image) {
current += read
}
downloadSpeedService.reportDownloadedBytes(read.toLong())
dataTransaction.update(image)
}
Pair(tempImage, mimeType)
}
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
}
fun isSupported(url: String): Boolean {
return url.contains(hostId)
}
@Throws(HostException::class)
fun head(url: String, context: HttpClientContext): Array<Header> {
val client: CloseableHttpAsyncClient = httpService.clientBuilder.build()
val httpGet = httpService.buildHttpHead(url, context)
log.debug(String.format("Requesting %s", url))
return try {
(client.execute(
httpGet,
context
) as CloseableHttpResponse).use { response ->
try {
if (response.code / 100 != 2) {
throw HostException(
String.format(
"Unexpected response code: %d", response.code
)
)
}
response.headers
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
} catch (e: Exception) {
throw HostException(e)
}
}
@Throws(HostException::class)
fun <T> fetch(
url: String,
context: ImageDownloadContext,
transformer: (CloseableHttpResponse) -> T
): T {
val client: HttpClient = httpService.clientBuilder.build()
val httpGet = httpService.buildHttpGet(url, context.httpContext)
httpGet.addHeader("Referer", context.image.url)
log.debug(String.format("Requesting %s", url))
return try {
(client.execute(
httpGet,
context.httpContext
) as CloseableHttpResponse).use { response ->
if (response.code / 100 != 2) {
EntityUtils.consumeQuietly(response.entity)
throw DownloadException(
"Server returned code ${response.code}"
)
}
try {
transformer(response)
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
} catch (e: Exception) {
throw HostException(e)
}
}
private fun getImageMimeType(headers: Array<Header>): ImageMimeType? {
// first check if content type header exists
val value = headers.find { it.name.contains("content-type", true) }?.value
// header found, check the type
return if (value != null) {
ImageMimeType.values().find {
value.contains(it.strValue, true)
}
} else {
null
}
}
fun getDefaultImageName(imgUrl: String): String {
val imageTitle = imgUrl.substring(imgUrl.lastIndexOf('/') + 1)
log.debug(String.format("Extracting name from url %s: %s", imgUrl, imageTitle))
return getFileNameWithoutExtension(imageTitle)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Host
if (hostId != other.hostId) return false
return true
}
override fun hashCode(): Int {
return hostId.hashCode()
}
override fun toString(): String {
return hostId
}
}
data class DownloadedImage(val name: String, val path: Path, val type: ImageMimeType)
enum class ImageMimeType(val strValue: String) {
IMAGE_BMP("image/bmp"),
IMAGE_GIF("image/gif"),
IMAGE_JPEG("image/jpeg"),
IMAGE_PNG("image/png"),
IMAGE_WEBP("image/webp"),
}
@@ -1,3 +0,0 @@
package me.mnlr.vripper.model
data class DownloadSpeed(val speed: String)
@@ -1,78 +0,0 @@
package me.mnlr.vripper.parser
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.exception.DownloadException
import me.mnlr.vripper.exception.PostParseException
import me.mnlr.vripper.model.ThreadItem
import me.mnlr.vripper.services.HTTPService
import me.mnlr.vripper.services.RetryPolicyService
import me.mnlr.vripper.services.SettingsService
import me.mnlr.vripper.services.VGAuthService
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.function.CheckedSupplier
import org.apache.hc.client5.http.classic.HttpClient
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse
import org.apache.hc.client5.http.protocol.HttpClientContext
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 java.io.BufferedInputStream
import java.net.URISyntaxException
import java.util.*
import javax.xml.parsers.SAXParserFactory
class ThreadLookupAPIParser(private val threadId: String) : 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 = try {
val uriBuilder = URIBuilder(settingsService.settings.viperSettings.host + "/vr.php")
uriBuilder.setParameter("t", threadId)
cm.buildHttpGet(uriBuilder.build(), HttpClientContext.create())
} catch (e: URISyntaxException) {
throw PostParseException(e)
}
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting $httpGet")
return try {
Failsafe.with(retryPolicyService.buildGenericRetryPolicy<Any>()).onFailure {
log.error(
"parsing failed for thread $threadId",
it.failure
)
}.get(CheckedSupplier {
val connection: HttpClient = cm.clientBuilder.build()
(connection.execute(
httpGet, vgAuthService.context
) as CloseableHttpResponse).use { response ->
try {
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
}
factory.newSAXParser().parse(
BufferedInputStream(response.entity.content),
threadLookupAPIResponseHandler
)
threadLookupAPIResponseHandler.result
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
})
} catch (e: Exception) {
throw PostParseException(e)
}
}
companion object {
private val factory = SAXParserFactory.newInstance()
}
}
@@ -1,17 +0,0 @@
package me.mnlr.vripper.repositories
import me.mnlr.vripper.entities.Image
import java.util.*
interface ImageRepository {
fun save(image: Image): Image
fun save(imageList: List<Image>)
fun deleteAllByPostId(postId: String)
fun findByPostId(postId: String): List<Image>
fun countError(): Int
fun findByPostIdAndIsNotCompleted(postId: String): List<Image>
fun stopByPostIdAndIsNotCompleted(postId: String): Int
fun findByPostIdAndIsError(postId: String): List<Image>
fun findById(id: Long): Optional<Image>
fun update(image: Image)
}
@@ -1,13 +0,0 @@
package me.mnlr.vripper.repositories
import me.mnlr.vripper.entities.LogEvent
import java.util.*
interface LogEventRepository {
fun save(logEvent: LogEvent): LogEvent
fun update(logEvent: LogEvent): LogEvent
fun findAll(): List<LogEvent>
fun findById(id: Long): Optional<LogEvent>
fun delete(id: Long)
fun deleteAll()
}
@@ -1,10 +0,0 @@
package me.mnlr.vripper.repositories
import me.mnlr.vripper.entities.Metadata
import java.util.*
interface MetadataRepository {
fun save(metadata: Metadata): Metadata
fun findByPostId(postId: String): Optional<Metadata>
fun deleteByPostId(postId: String): Int
}
@@ -1,17 +0,0 @@
package me.mnlr.vripper.repositories
import me.mnlr.vripper.entities.Post
import java.util.*
interface PostDownloadStateRepository {
fun save(post: Post): Post
fun findByPostId(postId: String): Optional<Post>
fun findById(id: Long): Optional<Post>
fun findCompleted(): List<String>
fun findAll(): List<Post>
fun existByPostId(postId: String): Boolean
fun setDownloadingToStopped(): Int
fun deleteByPostId(postId: String): Int
fun update(post: Post)
fun update(post: List<Post>)
}
@@ -1,33 +0,0 @@
package me.mnlr.vripper.repositories.impl
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.repositories.LogEventRepository
import java.util.*
class LogEventRepositoryImpl : LogEventRepository {
@Synchronized
override fun save(logEvent: LogEvent): LogEvent {
return logEvent
}
override fun update(logEvent: LogEvent): LogEvent {
return logEvent
}
override fun findById(id: Long): Optional<LogEvent> {
return Optional.empty()
}
override fun findAll(): List<LogEvent> {
return emptyList()
}
override fun delete(id: Long) {
}
override fun deleteAll() {
}
}
@@ -1,20 +0,0 @@
package me.mnlr.vripper.repositories.impl
import me.mnlr.vripper.entities.Metadata
import me.mnlr.vripper.repositories.MetadataRepository
import java.util.*
class MetadataRepositoryImpl: MetadataRepository {
override fun save(metadata: Metadata): Metadata {
return metadata
}
override fun findByPostId(postId: String): Optional<Metadata> {
return Optional.empty()
}
override fun deleteByPostId(postId: String): Int {
return 0
}
}
@@ -1,145 +0,0 @@
package me.mnlr.vripper.repositories.impl
import me.mnlr.vripper.entities.Post
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.repositories.PostDownloadStateRepository
import me.mnlr.vripper.tables.PostTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import java.util.*
class PostDownloadStateRepositoryImpl :
PostDownloadStateRepository {
private val delimiter = ";"
override fun save(post: Post): Post {
val id = PostTable.insertAndGetId {
it[done] = post.done
it[hosts] = java.lang.String.join(delimiter, post.hosts)
it[outputPath] = post.downloadDirectory
it[postId] = post.postId
it[status] = post.status.name
it[threadId] = post.threadId
it[postTitle] = post.postTitle
it[threadTitle] = post.threadTitle
it[forum] = post.forum
it[total] = post.total
it[url] = post.url
it[token] = post.token
it[addedAt] = post.addedOn
it[rank] = post.rank
}.value
return post.copy(id = id)
}
override fun findByPostId(postId: String): Optional<Post> {
val result = PostTable.select {
PostTable.postId eq postId
}.map { transform(it) }
return if (result.isEmpty()) {
Optional.empty()
} else {
Optional.of(result.first())
}
}
override fun findCompleted(): List<String> {
return PostTable.slice(PostTable.postId).select {
(PostTable.status eq Status.FINISHED.name) and (PostTable.done greaterEq PostTable.total)
}.map { it[PostTable.postId] }
}
override fun findById(id: Long): Optional<Post> {
val result = PostTable.select {
PostTable.id eq id
}.map { transform(it) }
return if (result.isEmpty()) {
Optional.empty()
} else {
Optional.of(result.first())
}
}
override fun findAll(): List<Post> {
return PostTable.selectAll().map { transform(it) }
}
override fun existByPostId(postId: String): Boolean {
return PostTable.slice(PostTable.id).select { PostTable.postId eq postId }.count() > 0
}
override fun setDownloadingToStopped(): Int {
return PostTable.update({ (PostTable.status eq Status.DOWNLOADING.name) or (PostTable.status eq Status.PENDING.name) }) {
it[status] = Status.STOPPED.name
}
}
override fun deleteByPostId(postId: String): Int {
return PostTable.deleteWhere { PostTable.postId eq postId }
}
override fun update(post: Post) {
PostTable.update({ PostTable.id eq post.id }) {
it[status] = post.status.name
it[done] = post.done
it[rank] = post.rank
}
}
override fun update(post: List<Post>) {
PostTable.batchReplace(post, shouldReturnGeneratedValues = false) {
this[PostTable.id] = it.id
this[PostTable.status] = it.status.name
this[PostTable.done] = it.done
this[PostTable.total] = it.total
this[PostTable.rank] = it.rank
this[PostTable.hosts] = java.lang.String.join(delimiter, it.hosts)
this[PostTable.outputPath] = it.downloadDirectory
this[PostTable.postId] = it.postId
this[PostTable.threadId] = it.threadId
this[PostTable.postTitle] = it.postTitle
this[PostTable.threadTitle] = it.threadTitle
this[PostTable.forum] = it.forum
this[PostTable.url] = it.url
this[PostTable.token] = it.token
}
}
private fun transform(resultRow: ResultRow): Post {
val id = resultRow[PostTable.id].value
val status = Status.valueOf(resultRow[PostTable.status])
val postId = resultRow[PostTable.postId]
val threadId = resultRow[PostTable.threadId]
val postTitle = resultRow[PostTable.postTitle]
val threadTitle = resultRow[PostTable.threadTitle]
val forum = resultRow[PostTable.forum]
val url = resultRow[PostTable.url]
val token = resultRow[PostTable.token]
val done = resultRow[PostTable.done]
val total = resultRow[PostTable.total]
val hosts =
resultRow[PostTable.hosts].split(delimiter).dropLastWhile { it.isEmpty() }.toSet()
val downloadDirectory = resultRow[PostTable.outputPath]
val addedOn = resultRow[PostTable.addedAt]
val rank = resultRow[PostTable.rank]
return Post(
id,
postTitle,
threadTitle,
forum,
url,
token,
postId,
threadId,
total,
hosts,
downloadDirectory,
addedOn,
status,
done,
rank
)
}
}
@@ -1,5 +0,0 @@
package me.mnlr.vripper.services
object AppVersion {
const val VERSION: String = "4.4.0"
}
@@ -1,272 +0,0 @@
package me.mnlr.vripper.services
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import me.mnlr.vripper.entities.Image
import me.mnlr.vripper.entities.Metadata
import me.mnlr.vripper.entities.Post
import me.mnlr.vripper.entities.Thread
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.event.*
import me.mnlr.vripper.model.PostItem
import me.mnlr.vripper.repositories.ImageRepository
import me.mnlr.vripper.repositories.MetadataRepository
import me.mnlr.vripper.repositories.PostDownloadStateRepository
import me.mnlr.vripper.repositories.ThreadRepository
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*
import kotlin.io.path.pathString
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 imageIdCache: LoadingCache<Long, Optional<Image>> = Caffeine.newBuilder().build {
_findImageById(it)
}
private val postIdCache: LoadingCache<Long, Optional<Post>> = Caffeine.newBuilder().build {
_findPostById(it)
}
private fun save(post: Post): Post {
return transaction { postDownloadStateRepository.save(post) }
}
fun update(post: Post) {
transaction { postDownloadStateRepository.update(post) }
postIdCache.invalidate(post.id)
eventBus.publishEvent(PostUpdateEvent(post))
}
fun save(thread: Thread) {
val savedThread = transaction { threadRepository.save(thread) }
eventBus.publishEvent(
ThreadCreateEvent(
savedThread
)
)
}
fun update(image: Image) {
transaction { imageRepository.update(image) }
imageIdCache.invalidate(image.id)
eventBus.publishEvent(ImageUpdateEvent(image))
}
fun exists(postId: String): Boolean {
return transaction { postDownloadStateRepository.existByPostId(postId) }
}
fun newPost(postItem: PostItem): Post {
val (post, images) = transaction {
val post = save(
Post(
postTitle = postItem.title,
url = postItem.url,
token = postItem.securityToken,
postId = postItem.postId,
threadId = postItem.threadId,
total = postItem.imageCount,
hosts = postItem.hosts.map { "${it.first} (${it.second})" }.toSet(),
threadTitle = postItem.threadTitle,
forum = postItem.forum,
downloadDirectory = PathUtils.calculateDownloadPath(
postItem.forum,
postItem.threadTitle,
postItem.title,
postItem.postId,
settingsService.settings
).pathString
)
)
val images = postItem.imageItemList.mapIndexed { index, imageItem ->
Image(
postId = postItem.postId,
url = imageItem.mainLink,
thumbUrl = imageItem.thumbLink,
host = imageItem.host.hostId,
index = index,
postIdRef = post.id!!
)
}
save(images)
sortPostsByRank()
Pair(post, images)
}
eventBus.publishEvent(PostCreateEvent(post))
images.forEach {
eventBus.publishEvent(ImageCreateEvent(it))
}
return post
}
private fun save(images: List<Image>) {
transaction { imageRepository.save(images) }
}
fun finishPost(post: Post) {
val imagesInErrorStatus = findByPostIdAndIsError(post.postId)
if (imagesInErrorStatus.isNotEmpty()) {
post.status = Status.ERROR
update(post)
} else {
if (post.done < post.total) {
post.status = Status.STOPPED
update(post)
} else {
post.status = Status.FINISHED
transaction {
update(post)
if (settingsService.settings.downloadSettings.clearCompleted) {
remove(listOf(post.postId))
}
}
}
}
}
private fun findByPostIdAndIsError(postId: String): List<Image> {
return transaction { imageRepository.findByPostIdAndIsError(postId) }
}
private fun remove(postIds: List<String>) {
transaction {
for (postId in postIds) {
imageRepository.deleteAllByPostId(postId)
metadataRepository.deleteByPostId(postId)
postDownloadStateRepository.deleteByPostId(postId)
}
sortPostsByRank()
}
postIds.forEach {
eventBus.publishEvent(PostDeleteEvent(it))
}
}
fun removeThread(threadId: String) {
transaction { threadRepository.deleteByThreadId(threadId) }
eventBus.publishEvent(ThreadDeleteEvent(threadId))
}
fun clearCompleted(): List<String> {
val completed = transaction { postDownloadStateRepository.findCompleted() }
remove(completed)
return completed
}
fun removeAll(postIds: List<String>?) {
if (postIds != null) {
remove(postIds)
} else {
remove(findAllPosts().map(Post::postId))
}
}
fun stopImagesByPostIdAndIsNotCompleted(postId: String) {
transaction { imageRepository.stopByPostIdAndIsNotCompleted(postId) }
}
@Synchronized
fun setMetadata(post: Post, metadata: Metadata) {
if (metadataRepository.findByPostId(post.postId).isEmpty) {
metadata.postIdRef = post.id
metadataRepository.save(metadata)
}
}
fun clearQueueLinks() {
transaction { threadRepository.deleteAll() }
eventBus.publishEvent(ThreadClearEvent())
}
@Synchronized
fun sortPostsByRank() {
val post =
findAllPosts().sortedWith(Comparator.comparing(Post::addedOn))
for (i in post.indices) {
post[i].rank = i
}
update(post)
}
private fun update(postList: List<Post>) {
transaction { postDownloadStateRepository.update(postList) }
postList.forEach { postIdCache.invalidate(it.id) }
postList.forEach {
eventBus.publishEvent(PostUpdateEvent(it))
}
}
fun setDownloadingToStopped() {
transaction { postDownloadStateRepository.setDownloadingToStopped() }
}
fun findAllPosts(): List<Post> {
return transaction { postDownloadStateRepository.findAll() }
}
fun findPostById(id: Long): Optional<Post> {
return postIdCache[id]
}
private fun _findPostById(id: Long): Optional<Post> {
return transaction { postDownloadStateRepository.findById(id) }
}
fun findImagesByPostId(postId: String): List<Image> {
return transaction { imageRepository.findByPostId(postId) }
}
fun findImageById(id: Long): Optional<Image> {
return imageIdCache[id]
}
private fun _findImageById(id: Long): Optional<Image> {
return transaction { imageRepository.findById(id) }
}
fun findAllThreads(): List<Thread> {
return transaction { threadRepository.findAll() }
}
fun findThreadById(id: Long): Optional<Thread> {
return transaction {
threadRepository.findById(id)
}
}
fun findByPostIdAndIsNotCompleted(postId: String): List<Image> {
return transaction { imageRepository.findByPostIdAndIsNotCompleted(postId) }
}
fun countImagesInError(): Int {
return transaction { imageRepository.countError() }
}
fun findPostsByPostId(postId: String): Optional<Post> {
return transaction { postDownloadStateRepository.findByPostId(postId) }
}
fun findThreadByThreadId(threadId: String): Optional<Thread> {
return transaction { threadRepository.findByThreadId(threadId) }
}
}
@@ -1,27 +0,0 @@
package me.mnlr.vripper.services
import liquibase.Contexts
import liquibase.LabelExpression
import liquibase.Liquibase
import liquibase.database.Database
import liquibase.database.DatabaseFactory
import liquibase.database.jvm.JdbcConnection
import liquibase.resource.ClassLoaderResourceAccessor
import me.mnlr.vripper.ApplicationProperties.BASE_DIR_NAME
import me.mnlr.vripper.ApplicationProperties.baseDir
import java.sql.DriverManager
object DatabaseMigration {
fun update() {
val database: Database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(JdbcConnection(DriverManager.getConnection("jdbc:h2:file:$baseDir/$BASE_DIR_NAME/vripper;DB_CLOSE_DELAY=-1;")))
Liquibase(
"db.changelog-master.xml",
ClassLoaderResourceAccessor(),
database
).use { liquibase ->
liquibase.update(Contexts(), LabelExpression())
}
}
}
@@ -1,150 +0,0 @@
package me.mnlr.vripper.services
import kotlinx.coroutines.*
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.event.SettingsUpdateEvent
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.client5.http.classic.methods.HttpHead
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.classic.methods.HttpUriRequest
import org.apache.hc.client5.http.config.ConnectionConfig
import org.apache.hc.client5.http.config.RequestConfig
import org.apache.hc.client5.http.cookie.StandardCookieSpec
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy
import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder
import org.apache.hc.client5.http.impl.async.HttpAsyncClients
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder
import org.apache.hc.client5.http.protocol.HttpClientContext
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
import java.net.URI
class HTTPService(
val eventBus: EventBus,
settingsService: SettingsService
) {
companion object {
const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0"
}
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private lateinit var pcm: PoolingAsyncClientConnectionManager
private lateinit var rc: RequestConfig
private lateinit var cc: ConnectionConfig
lateinit var clientBuilder: HttpAsyncClientBuilder
private var connectionTimeout: Long = settingsService.settings.connectionSettings.timeout
fun init() {
buildRequestConfig()
buildConnectionConfig()
buildConnectionPool()
buildClientBuilder()
coroutineScope.launch {
pcm.closeIdle(TimeValue.ofSeconds(60))
delay(15000)
}
coroutineScope.launch {
eventBus
.subscribe<SettingsUpdateEvent> {
if (connectionTimeout != it.settings.connectionSettings.timeout) {
connectionTimeout = it.settings.connectionSettings.timeout
buildRequestConfig()
buildConnectionConfig()
pcm.close()
buildConnectionPool()
buildClientBuilder()
}
}
}
}
private fun buildConnectionPool() {
pcm = PoolingAsyncClientConnectionManagerBuilder.create()
.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.STRICT)
.setConnPoolPolicy(PoolReusePolicy.LIFO)
.setDefaultConnectionConfig(cc)
.setMaxConnTotal(Int.MAX_VALUE)
.setMaxConnPerRoute(Int.MAX_VALUE)
.build()
}
private fun buildRequestConfig() {
rc = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofSeconds(connectionTimeout))
.setCookieSpec(StandardCookieSpec.RELAXED)
.build()
}
private fun buildConnectionConfig() {
cc = ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofSeconds(connectionTimeout))
.setSocketTimeout(Timeout.ofSeconds(connectionTimeout))
.setTimeToLive(TimeValue.ofMinutes(10))
.build()
}
private fun buildClientBuilder() {
clientBuilder = HttpAsyncClients.custom()
.setConnectionManager(pcm)
.setRedirectStrategy(DefaultRedirectStrategy.INSTANCE)
.disableAutomaticRetries()
.setDefaultRequestConfig(rc)
}
fun buildHttpGet(url: String, context: HttpClientContext): HttpGet {
val httpGet = HttpGet(url.replace(" ", "+"))
httpGet.addHeader("User-Agent", USER_AGENT)
addToContext(context, httpGet)
return httpGet
}
fun buildHttpHead(url: String, context: HttpClientContext): HttpHead {
SimpleRequestBuilder.head(url)
val httpHead = HttpHead(url.replace(" ", "+"))
httpHead.addHeader("User-Agent", USER_AGENT)
addToContext(context, httpHead)
return httpHead
}
fun buildHttpPost(url: String, context: HttpClientContext): HttpPost {
val httpPost = HttpPost(url.replace(" ", "+"))
httpPost.addHeader("User-Agent", USER_AGENT)
addToContext(context, httpPost)
return httpPost
}
fun buildHttpGet(uri: URI, context: HttpClientContext): HttpGet {
val httpGet = HttpGet(uri)
httpGet.addHeader("User-Agent", USER_AGENT)
addToContext(context, httpGet)
return httpGet
}
fun addToContext(context: HttpClientContext, request: HttpUriRequest) {
val contextAttributes =
context.getAttribute(
ContextAttributes.CONTEXT_ATTRIBUTES,
ContextAttributes::class.java
)
if (contextAttributes != null) {
synchronized(contextAttributes.requests) {
contextAttributes.requests.add(request)
}
}
}
class ContextAttributes {
val requests: MutableList<HttpUriRequest> = mutableListOf()
companion object {
const val CONTEXT_ATTRIBUTES = "CONTEXT_ATTRIBUTES"
}
}
}
@@ -1,34 +0,0 @@
package me.mnlr.vripper.services
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.model.Settings
import java.nio.file.Path
object PathUtils {
private val log by LoggerDelegate()
fun calculateDownloadPath(forum: String, threadTitle: String, postTitle: String, postId: String, settings: Settings): Path {
var downloadDirectory =
if (settings.downloadSettings.forumSubfolder) Path.of(settings.downloadSettings.downloadPath, sanitize(forum)) else Path.of(
settings.downloadSettings.downloadPath
)
downloadDirectory = if (settings.downloadSettings.threadSubLocation) downloadDirectory.resolve(threadTitle) else downloadDirectory
downloadDirectory = downloadDirectory.resolve(if (settings.downloadSettings.appendPostId) "${sanitize(postTitle)}_${postId}" else sanitize(
postTitle
))
return downloadDirectory
}
/**
* Will sanitize the image name and remove extension
*
* @param path
* @return Sanitized local path string
*/
fun sanitize(path: String): String {
val sanitizedPath =
path.replace("\\.|\\\\|/|\\||:|\\?|\\*|\"|<|>|\\p{Cntrl}".toRegex(), "_")
log.debug(String.format("%s sanitized to %s", path, sanitizedPath))
return sanitizedPath
}
}
@@ -1,49 +0,0 @@
package me.mnlr.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.event.SettingsUpdateEvent
import net.jodah.failsafe.RetryPolicy
import net.jodah.failsafe.event.ExecutionAttemptedEvent
import java.time.temporal.ChronoUnit
class RetryPolicyService(
val eventBus: EventBus,
settingsService: SettingsService
) {
private val log by LoggerDelegate()
private var maxAttempts: Int = settingsService.settings.connectionSettings.maxAttempts
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun init() {
coroutineScope.launch {
eventBus.subscribe<SettingsUpdateEvent> {
if (maxAttempts != it.settings.connectionSettings.maxAttempts) {
maxAttempts = it.settings.connectionSettings.maxAttempts
}
}
}
}
fun <T> buildRetryPolicyForDownload(): RetryPolicy<T> {
return RetryPolicy<T>()
.withDelay(2, 5, ChronoUnit.SECONDS)
.withMaxAttempts(maxAttempts)
.onFailedAttempt {
log.warn("#${it.attemptCount} tries failed", it.lastFailure)
}
}
fun <T> buildGenericRetryPolicy(): RetryPolicy<T> {
return RetryPolicy<T>()
.withDelay(2, 5, ChronoUnit.SECONDS)
.withMaxAttempts(maxAttempts)
.onFailedAttempt { e: ExecutionAttemptedEvent<T> ->
log.warn("#${e.attemptCount} tries failed", e.lastFailure)
}
}
}
@@ -1,38 +0,0 @@
package me.mnlr.vripper.services
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.event.SettingsUpdateEvent
import me.mnlr.vripper.model.ThreadItem
import me.mnlr.vripper.parser.ThreadLookupAPIParser
import java.util.*
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
class ThreadCacheService(val eventBus: EventBus) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun init() {
coroutineScope.launch {
eventBus.subscribe<SettingsUpdateEvent> {
cache.invalidateAll()
}
}
}
private val cache: LoadingCache<String, ThreadItem> =
Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build { threadId: String ->
ThreadLookupAPIParser(threadId).parse()
}
@Throws(ExecutionException::class)
operator fun get(threadId: String): ThreadItem {
return cache[threadId] ?: throw NoSuchElementException("$threadId does not exist")
}
}
@@ -1,151 +0,0 @@
package me.mnlr.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.Post
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.event.SettingsUpdateEvent
import me.mnlr.vripper.event.VGUserLoginEvent
import me.mnlr.vripper.exception.VripperException
import me.mnlr.vripper.model.Settings
import me.mnlr.vripper.tasks.LeaveThanksRunnable
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.protocol.HttpClientContext
import org.apache.hc.core5.http.NameValuePair
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.http.message.BasicNameValuePair
import java.util.concurrent.CompletableFuture
class VGAuthService(
private val cm: HTTPService,
private val settingsService: SettingsService,
private val eventBus: EventBus
) {
private val log by LoggerDelegate()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val context: HttpClientContext = HttpClientContext.create()
var loggedUser = ""
private var authenticated = false
fun init() {
context.cookieStore = BasicCookieStore()
coroutineScope.launch {
eventBus.subscribe<SettingsUpdateEvent> {
authenticate(it.settings)
}
}
authenticate(settingsService.settings)
}
private fun authenticate(settings: Settings) {
authenticated = false
if (!settings.viperSettings.login) {
log.debug("Authentication option is disabled")
context.cookieStore.clear()
loggedUser = ""
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 = ""
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
val postAuth =
cm.buildHttpPost(
settings.viperSettings.host + "/login.php?do=login",
context
)
val params: MutableList<NameValuePair> = ArrayList()
params.add(BasicNameValuePair("vb_login_username", username))
params.add(BasicNameValuePair("cookieuser", "1"))
params.add(BasicNameValuePair("do", "login"))
params.add(BasicNameValuePair("vb_login_md5password", password))
try {
postAuth.entity = UrlEncodedFormEntity(params)
} catch (e: Exception) {
context.cookieStore.clear()
loggedUser = ""
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
log.error(
"Failed to authenticate user with " + settings.viperSettings.host,
e
)
return
}
postAuth.addHeader("Referer", settings.viperSettings.host)
postAuth.addHeader(
"Host",
settings.viperSettings.host.replace("https://", "")
.replace("http://", "")
)
val client = cm.clientBuilder.build()
try {
client.execute(postAuth, context).use { response ->
if (response.code / 100 != 2) {
throw VripperException(
String.format(
"Unexpected response code returned %s", response.code
)
)
}
val responseBody = EntityUtils.toString(response.entity)
log.debug(
String.format(
"Authentication with ViperGirls response body:%n%s",
responseBody
)
)
EntityUtils.consumeQuietly(response.entity)
if (context.cookieStore.cookies.stream()
.map { obj: Cookie -> obj.name }
.noneMatch { e: String -> e == "vg_userid" }
) {
log.error(
String.format(
"Failed to authenticate user with %s, missing vg_userid cookie",
settings.viperSettings.host
)
)
return
}
}
} catch (e: Exception) {
context.cookieStore.clear()
loggedUser = ""
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
log.error(
"Failed to authenticate user with " + settings.viperSettings.host,
e
)
return
}
authenticated = true
loggedUser = username
log.info(String.format("Authenticated: %s", username))
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
}
fun leaveThanks(post: Post) {
CompletableFuture.runAsync(LeaveThanksRunnable(post, authenticated, context))
}
}
@@ -1,15 +0,0 @@
package me.mnlr.vripper.tables
import org.jetbrains.exposed.dao.id.LongIdTable
object ImageTable : LongIdTable(name = "IMAGE", columnName = "ID") {
val current = long("CURRENT")
val host = varchar("HOST", 255)
val index = integer("INDEX")
val postId = varchar("POST_ID", 255)
val status = varchar("STATUS", 255)
val total = long("TOTAL")
val url = varchar("URL", 3000)
val thumbUrl = varchar("THUMB_URL", 3000)
val postIdRef = long("POST_ID_REF").references(PostTable.id, fkName = "IMAGE_POST_ID_REF_POST_ID_FK")
}
@@ -1,22 +0,0 @@
package me.mnlr.vripper.tables
import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.sql.javatime.datetime
import java.time.LocalDateTime
object PostTable : LongIdTable(name = "POST", columnName = "ID") {
val done = integer("DONE")
val hosts = varchar("HOSTS", 500)
val outputPath = varchar("OUTPUT_PATH", 500)
val postId = varchar("POST_ID", 255)
val status = varchar("STATUS", 255)
val threadId = varchar("THREAD_ID", 255)
val postTitle = varchar("POST_TITLE", 500)
val threadTitle = varchar("THREAD_TITLE", 500)
val forum = varchar("FORUM", 500)
val total = integer("TOTAL")
val url = varchar("URL", 3000)
val token = varchar("TOKEN", 500)
val addedAt = datetime("ADDED_AT").default(LocalDateTime.now())
val rank = integer("RANK").default(0)
}
@@ -1,121 +0,0 @@
package me.mnlr.vripper.tasks
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.LogEvent
import me.mnlr.vripper.entities.LogEvent.Status.*
import me.mnlr.vripper.entities.Post
import me.mnlr.vripper.formatToString
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.services.HTTPService
import me.mnlr.vripper.services.SettingsService
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.apache.hc.core5.http.NameValuePair
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.http.message.BasicNameValuePair
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.UnsupportedEncodingException
class LeaveThanksRunnable(
private val post: Post,
private val authenticated: Boolean,
private val context: HttpClientContext
) : KoinComponent, Runnable {
private val log by LoggerDelegate()
private val cm: HTTPService by inject()
private val settingsService: SettingsService by inject()
private val eventRepository: LogEventRepository by inject()
private val logEvent: LogEvent
init {
logEvent = eventRepository.save(
LogEvent(
type = LogEvent.Type.THANKS,
status = PENDING,
message = "Leaving thanks for $post.url"
)
)
}
override fun run() {
try {
eventRepository.update(logEvent.copy(status = PROCESSING))
if (!settingsService.settings.viperSettings.login) {
eventRepository.update(
logEvent.copy(
status = DONE,
message = "Will not send a like for ${post.url}\nAuthentication with ViperGirls option is disabled"
)
)
return
}
if (!settingsService.settings.viperSettings.thanks) {
eventRepository.update(
logEvent.copy(
status = DONE,
message = "Will not send a like for ${post.url}\nLeave thanks option is disabled"
)
)
return
}
if (!authenticated) {
eventRepository.update(
logEvent.copy(
status = ERROR,
message = "Will not send a like for ${post.url}\nYou are not authenticated"
)
)
return
}
val postThanks: HttpPost = cm.buildHttpPost(
"${settingsService.settings.viperSettings.host}/post_thanks.php", HttpClientContext.create()
)
val params: MutableList<NameValuePair> = ArrayList()
params.add(BasicNameValuePair("do", "post_thanks_add"))
params.add(BasicNameValuePair("using_ajax", "1"))
params.add(BasicNameValuePair("p", post.postId))
params.add(BasicNameValuePair("securitytoken", post.token))
try {
postThanks.entity = UrlEncodedFormEntity(params)
} catch (e: UnsupportedEncodingException) {
val error = "Request error for ${post.url}"
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
return
}
postThanks.addHeader("Referer", settingsService.settings.viperSettings.host)
postThanks.addHeader(
"Host", settingsService.settings.viperSettings.host.replace("https://", "").replace("http://", "")
)
val client: CloseableHttpClient = cm.clientBuilder.build()
client.execute(postThanks, context).use { response ->
try {
} finally {
EntityUtils.consumeQuietly(response.entity)
}
}
eventRepository.update(logEvent.copy(status = DONE))
} catch (e: Exception) {
val error = "Failed to leave a thanks for $post"
log.error(error, e)
eventRepository.update(
logEvent.copy(
status = ERROR, message = """
$error
${e.formatToString()}
""".trimIndent()
)
)
}
}
}
@@ -1,11 +1,11 @@
package me.mnlr.vripper
package me.vripper
import me.mnlr.vripper.download.DownloadService
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.host.*
import me.mnlr.vripper.repositories.*
import me.mnlr.vripper.repositories.impl.*
import me.mnlr.vripper.services.*
import me.vripper.download.DownloadService
import me.vripper.event.EventBus
import me.vripper.host.*
import me.vripper.repositories.*
import me.vripper.repositories.impl.*
import me.vripper.services.*
import org.koin.dsl.bind
import org.koin.dsl.module
@@ -28,11 +28,11 @@ val coreModule = module {
single<ThreadRepository> {
ThreadRepositoryImpl()
}
single<LogEventRepository> {
LogEventRepositoryImpl()
single<LogRepository> {
LogRepositoryImpl(get())
}
single<DataTransaction> {
DataTransaction(get(), get(), get(), get(), get(), get())
DataTransaction(get(), get(), get(), get(), get(), get(), get())
}
single<RetryPolicyService> {
RetryPolicyService(get(), get())
@@ -53,7 +53,7 @@ val coreModule = module {
DownloadSpeedService(get())
}
single<AppEndpointService> {
AppEndpointService(get(), get(), get(), get(), get())
AppEndpointService(get(), get(), get(), get())
}
single {
AcidimgHost(get(), get(), get())
@@ -1,4 +1,4 @@
package me.mnlr.vripper.delegate
package me.vripper.delegate
import org.slf4j.Logger
import org.slf4j.LoggerFactory
@@ -0,0 +1,279 @@
package me.vripper.download
import kotlinx.coroutines.Runnable
import me.vripper.entities.Image
import me.vripper.entities.LogEntry
import me.vripper.entities.Post
import me.vripper.entities.domain.Status
import me.vripper.event.ErrorCountEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.host.Host
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.services.DataTransaction
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
import me.vripper.utilities.GLOBAL_EXECUTOR
import me.vripper.utilities.formatToString
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.RetryPolicy
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class DownloadService(
private val settingsService: SettingsService,
private val dataTransaction: DataTransaction,
private val retryPolicyService: RetryPolicyService,
private val vgAuthService: VGAuthService,
private val eventBus: EventBus
) {
private val maxPoolSize: Int = 24
private val log by me.vripper.delegate.LoggerDelegate()
private val running: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val pending: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val lock = ReentrantLock()
private val condition = lock.newCondition()
fun init() {
Thread.ofVirtual().name("Download Loop").unstarted(Runnable {
val accepted: MutableList<ImageDownloadRunnable> = mutableListOf()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
while (!Thread.currentThread().isInterrupted) {
lock.withLock {
candidates.addAll(getCandidates(candidateCount()))
candidates.forEach {
if (canRun(it.context.image.host)) {
accepted.add(it)
running[it.context.image.host]!!.add(it)
log.debug("${it.context.image.url} accepted to run")
}
}
accepted.forEach {
pending[it.context.image.host]?.remove(it)
scheduleForDownload(it)
}
accepted.clear()
candidates.clear()
try {
condition.await()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
}).start()
}
fun stop(postIds: List<Long> = emptyList()) {
if (postIds.isNotEmpty()) {
stopInternal(postIds)
} else {
stopAll()
}
}
fun restartAll(postIds: List<Post> = emptyList()) {
if (postIds.isNotEmpty()) {
restart(postIds.associateWith { dataTransaction.findByPostIdAndIsNotCompleted(it.postId) })
} else {
restart(dataTransaction.findAllPosts()
.associateWith { dataTransaction.findByPostIdAndIsNotCompleted(it.postId) })
}
}
private fun restart(posts: Map<Post, List<Image>>) {
lock.withLock {
val toProcess = mutableMapOf<Post, List<Image>>()
for ((post, images) in posts) {
if (images.isEmpty()) {
continue
}
if (isPending(post.postId)) {
log.warn("Cannot restart, jobs are currently running for post id $posts")
continue
}
toProcess[post] = images
}
toProcess.forEach { (post, images) ->
post.status = Status.PENDING
images.forEach { image ->
with(image) {
this.status = Status.PENDING
this.downloaded = 0
}
}
}
transaction {
dataTransaction.updatePosts(
toProcess.keys.toList()
)
dataTransaction.updateImages(
toProcess.values.flatten()
)
}
toProcess.keys.forEach {
vgAuthService.leaveThanks(it)
}
toProcess.entries.forEach { (post, images) ->
images.forEach { image ->
log.debug("Enqueuing a job for ${image.url}")
val imageDownloadRunnable = ImageDownloadRunnable(
image, post.rank, settingsService.settings
)
pending.computeIfAbsent(
image.host
) { mutableListOf() }
pending[image.host]!!.add(imageDownloadRunnable)
}
}
condition.signal()
}
}
private fun isPending(postId: Long): Boolean {
return pending.values.flatten().any { it.context.image.postId == postId }
}
private fun isRunning(postId: Long): Boolean {
return running.values.flatten().any { it.context.image.postId == postId }
}
private fun stopAll() {
lock.withLock {
pending.values.clear()
running.values.flatten().forEach { obj: ImageDownloadRunnable -> obj.stop() }
dataTransaction.findAllNonCompletedPostIds().forEach {
dataTransaction.stopImagesByPostIdAndIsNotCompleted(it)
dataTransaction.finishPost(it)
}
}
}
private fun stopInternal(postIds: List<Long>) {
lock.withLock {
for (postId in postIds) {
pending.values.forEach { pending ->
pending.removeIf { it.context.image.postId == postId }
}
running.values.flatten().filter { p: ImageDownloadRunnable -> p.context.image.postId == postId }
.forEach { obj: ImageDownloadRunnable -> obj.stop() }
}
postIds.forEach {
dataTransaction.stopImagesByPostIdAndIsNotCompleted(it)
dataTransaction.finishPost(it)
}
}
}
private fun canRun(host: Byte): Boolean {
val totalRunning = running.values.sumOf { it.size }
return (running[host]!!.size < settingsService.settings.connectionSettings.maxConcurrentPerHost && if (settingsService.settings.connectionSettings.maxGlobalConcurrent == 0) totalRunning < maxPoolSize else totalRunning < settingsService.settings.connectionSettings.maxGlobalConcurrent)
}
private fun candidateCount(): Map<Byte, Int> {
val map: MutableMap<Byte, Int> = mutableMapOf()
Host.getHosts().values.forEach { host: Byte ->
val imageDownloadRunnableList: List<ImageDownloadRunnable> = running.computeIfAbsent(
host
) { mutableListOf() }
val count: Int =
settingsService.settings.connectionSettings.maxConcurrentPerHost - imageDownloadRunnableList.size
log.debug("Download slots for $host: $count")
map[host] = count
}
return map
}
private fun getCandidates(candidateCount: Map<Byte, Int>): List<ImageDownloadRunnable> {
val hostIntegerMap: MutableMap<Byte, Int> = candidateCount.toMutableMap()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
hosts@ for (host in pending.keys) {
val list: List<ImageDownloadRunnable> =
pending[host]!!.sortedWith(Comparator.comparingInt<ImageDownloadRunnable> { it.postRank }
.thenComparingInt { it.context.image.index })
for (imageDownloadRunnable in list) {
val count = hostIntegerMap[host] ?: 0
if (count > 0) {
candidates.add(imageDownloadRunnable)
hostIntegerMap[host] = count - 1
} else {
continue@hosts
}
}
}
return candidates
}
private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${imageDownloadRunnable.context.image.url}")
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload()).with(GLOBAL_EXECUTOR)
.onFailure {
try {
dataTransaction.saveLog(
LogEntry(
type = LogEntry.Type.DOWNLOAD,
status = LogEntry.Status.ERROR,
message = "Failed to download ${imageDownloadRunnable.context.image.url}\n ${it.failure.formatToString()}"
)
)
} catch (exp: Exception) {
log.error("Failed to save event", exp)
}
log.error(
"Failed to download ${imageDownloadRunnable.context.image.url} after ${it.attemptCount} tries",
it.failure
)
val image = imageDownloadRunnable.context.image
image.status = Status.ERROR
dataTransaction.updateImage(image)
}.onComplete {
afterJobFinish(imageDownloadRunnable)
eventBus.publishEvent(
QueueStateEvent(
QueueState(
runningCount(), pendingCount()
)
)
)
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
log.debug(
"Finished downloading ${imageDownloadRunnable.context.image.url}"
)
}.runAsync(imageDownloadRunnable::run)
}
private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
lock.withLock {
val image = imageDownloadRunnable.context.image
running[image.host]!!.remove(imageDownloadRunnable)
if (!isPending(image.postId) && !isRunning(
image.postId
) && !imageDownloadRunnable.context.stopped
) {
dataTransaction.finishPost(image.postId, true)
}
condition.signal()
}
}
private fun pendingCount(): Int {
return pending.values.sumOf { it.size }
}
private fun runningCount(): Int {
return running.values.sumOf { it.size }
}
}
@@ -0,0 +1,17 @@
package me.vripper.download
import me.vripper.entities.Image
import me.vripper.model.Settings
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
import org.apache.hc.client5.http.cookie.BasicCookieStore
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.koin.core.component.KoinComponent
class ImageDownloadContext(val image: Image, val settings: Settings) : KoinComponent {
val httpContext: HttpClientContext =
HttpClientContext.create().apply { cookieStore = BasicCookieStore() }
val requests = mutableListOf<HttpUriRequestBase>()
val postId = image.postIdRef
var stopped = false
}
@@ -1,20 +1,18 @@
package me.mnlr.vripper.download
package me.vripper.download
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.entities.Image
import me.mnlr.vripper.entities.Post
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.exception.DownloadException
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.getExtension
import me.mnlr.vripper.host.DownloadedImage
import me.mnlr.vripper.host.Host
import me.mnlr.vripper.host.ImageMimeType
import me.mnlr.vripper.model.Settings
import me.mnlr.vripper.services.*
import kotlinx.coroutines.*
import me.vripper.entities.Image
import me.vripper.entities.domain.Status
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.host.DownloadedImage
import me.vripper.host.Host
import me.vripper.host.ImageMimeType
import me.vripper.model.Settings
import me.vripper.services.*
import me.vripper.utilities.PathUtils
import me.vripper.utilities.PathUtils.getExtension
import net.jodah.failsafe.function.CheckedRunnable
import org.apache.hc.client5.http.cookie.BasicCookieStore
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.IOException
@@ -25,98 +23,74 @@ import java.util.*
import kotlin.io.path.pathString
class ImageDownloadRunnable(
private val imageInternalId: Long, private val settings: Settings
private val image: Image, val postRank: Int, private val settings: Settings
) : KoinComponent, CheckedRunnable {
private val log by LoggerDelegate()
private val log by me.vripper.delegate.LoggerDelegate()
private val dataTransaction: DataTransaction by inject()
private val hosts: List<Host> = getKoin().getAll()
val context: ImageDownloadContext = ImageDownloadContext(imageInternalId, settings)
private val image: Image
get() = context.image
private val post: Post
get() = context.post
val context: ImageDownloadContext = ImageDownloadContext(image, settings)
private var stopped: Boolean
get() = context.stopped
set(value) {
context.stopped = value
}
private val httpContext: HttpClientContext
get() = context.httpContext
init {
httpContext.cookieStore = BasicCookieStore()
httpContext.setAttribute(
HTTPService.ContextAttributes.CONTEXT_ATTRIBUTES, HTTPService.ContextAttributes()
)
}
@Throws(DownloadException::class)
fun download() {
try {
val image = image
with(image) {
this.status = Status.DOWNLOADING
this.current = 0
}
dataTransaction.update(image)
synchronized(post.id.toString().intern()) {
val post = context.post
image.status = Status.DOWNLOADING
image.downloaded = 0
dataTransaction.updateImage(image)
val downloadDirectory: String
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
downloadDirectory = post.downloadDirectory
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataTransaction.update(post)
dataTransaction.updatePost(post)
}
}
if (stopped) {
return
}
log.debug("Getting image url and name from ${image.url} using ${image.host}")
val host = hosts.first { it.isSupported(image.url) }
val downloadedImage = host.downloadInternal(image.url, context)
log.debug("Resolved name for ${image.url}: ${downloadedImage.name}")
log.debug(
"Downloaded image ${image.url} to ${downloadedImage.path}"
)
log.debug("Downloaded image {} to {}", image.url, downloadedImage.path)
val sanitizedFileName = PathUtils.sanitize(downloadedImage.name)
log.debug(
"Sanitizing image name from ${downloadedImage.name} to $sanitizedFileName"
)
checkImageTypeAndRename(
context.post, downloadedImage, image.index
downloadDirectory, downloadedImage, image.index
)
synchronized(image.postId.toString().intern()) {
if (image.downloaded == image.size && image.size > 0) {
image.status = Status.FINISHED
val post = dataTransaction.findPostById(context.postId).orElseThrow()
post.done += 1
post.downloaded += image.size
dataTransaction.updatePost(post)
} else {
image.status = Status.ERROR
}
dataTransaction.updateImage(image)
}
} catch (e: Exception) {
if (stopped) {
return
}
image.status = Status.ERROR
dataTransaction.updateImage(image)
throw DownloadException(e)
} finally {
synchronized(post.id.toString().intern()) {
val image = image
if (image.current == image.total && image.total > 0) {
image.status = Status.FINISHED
val post = context.post
post.done += 1
dataTransaction.update(post)
} else if (stopped) {
image.status = Status.STOPPED
} else {
image.status = Status.ERROR
}
dataTransaction.update(image)
}
}
}
@Throws(HostException::class)
private fun checkImageTypeAndRename(
post: Post, downloadedImage: DownloadedImage, index: Int
downloadDirectory: String, downloadedImage: DownloadedImage, index: Int
) {
val existingExtension = getExtension(downloadedImage.name)
val existingExtension = getExtension(downloadedImage.name).lowercase()
val extension = when (downloadedImage.type) {
ImageMimeType.IMAGE_BMP -> "BMP"
ImageMimeType.IMAGE_GIF -> "GIF"
@@ -127,7 +101,7 @@ class ImageDownloadRunnable(
val filename =
if (existingExtension.isBlank()) "${downloadedImage.name}.$extension" else downloadedImage.name
try {
val downloadDestinationFolder = Path.of(post.downloadDirectory)
val downloadDestinationFolder = Path.of(downloadDirectory)
synchronized(downloadDestinationFolder.pathString.intern()) {
Files.createDirectories(downloadDestinationFolder)
}
@@ -164,25 +138,15 @@ class ImageDownloadRunnable(
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val that = other as ImageDownloadRunnable
return imageInternalId == that.imageInternalId
return image.id == that.image.id
}
override fun hashCode(): Int {
return Objects.hash(imageInternalId)
return Objects.hash(image.id)
}
fun stop() {
context.requests.forEach { it.abort() }
stopped = true
val contextAttributes = httpContext.getAttribute(
HTTPService.ContextAttributes.CONTEXT_ATTRIBUTES,
HTTPService.ContextAttributes::class.java
)
if (contextAttributes != null) {
synchronized(contextAttributes.requests) {
for (request in contextAttributes.requests) {
request.abort()
}
}
}
}
}
@@ -1,17 +1,17 @@
package me.mnlr.vripper.entities
package me.vripper.entities
import me.mnlr.vripper.entities.domain.Status
import me.vripper.entities.domain.Status
data class Image(
var id: Long = -1,
val postId: String,
val id: Long = -1,
val postId: Long,
val url: String,
val thumbUrl: String,
val host: String,
val host: Byte,
val index: Int,
val postIdRef: Long,
var total: Long = -1,
var current: Long = 0,
val postIdRef: Long = -1,
var size: Long = -1,
var downloaded: Long = 0,
var status: Status = Status.STOPPED,
) {
override fun equals(other: Any?): Boolean {
@@ -1,13 +1,13 @@
package me.mnlr.vripper.entities
package me.vripper.entities
import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDateTime
data class LogEvent(
val id: Long? = null,
data class LogEntry(
val id: Long = -1,
val type: Type,
val status: Status,
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") val time: LocalDateTime = LocalDateTime.now(),
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") val time: LocalDateTime = LocalDateTime.now(),
val message: String,
) {
@@ -20,4 +20,4 @@ data class LogEvent(
enum class Status(val stringValue: String) {
PENDING("Pending"), PROCESSING("Processing"), DONE("Done"), ERROR("Error")
}
}
}
@@ -1,4 +1,4 @@
package me.mnlr.vripper.entities
package me.vripper.entities
class Metadata {
var postIdRef: Long? = null
@@ -1,25 +1,27 @@
package me.mnlr.vripper.entities
package me.vripper.entities
import com.fasterxml.jackson.annotation.JsonFormat
import me.mnlr.vripper.entities.domain.Status
import me.vripper.entities.domain.Status
import java.time.LocalDateTime
data class Post(
var id: Long = -1,
val id: Long = -1,
val postTitle: String,
val threadTitle: String,
val forum: String,
val url: String,
val token: String,
val postId: String,
val threadId: String,
val postId: Long,
val threadId: Long,
val total: Int,
val hosts: Set<String>,
val downloadDirectory: String,
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") val addedOn: LocalDateTime = LocalDateTime.now(),
var status: Status = Status.STOPPED,
var done: Int = 0,
var rank: Int = Int.MAX_VALUE
var rank: Int = Int.MAX_VALUE,
var size: Long = -1,
var downloaded: Long = 0,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -27,9 +29,7 @@ data class Post(
other as Post
if (postId != other.postId) return false
return true
return postId == other.postId
}
override fun hashCode(): Int {
@@ -1,10 +1,10 @@
package me.mnlr.vripper.entities
package me.vripper.entities
data class Thread(
val id: Long? = null,
val id: Long = -1,
val title: String,
val link: String,
val threadId: String,
val threadId: Long,
var total: Int = 0,
) {
override fun equals(other: Any?): Boolean {
@@ -1,4 +1,4 @@
package me.mnlr.vripper.entities.domain
package me.vripper.entities.domain
enum class Status(val stringValue: String) {
PENDING("Pending"), DOWNLOADING("Downloading"), FINISHED("Finished"), ERROR("Error"), STOPPED("Stopped")
@@ -0,0 +1,29 @@
package me.vripper.event
import me.vripper.entities.Image
import me.vripper.entities.LogEntry
import me.vripper.entities.Post
import me.vripper.entities.Thread
import me.vripper.model.DownloadSpeed
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.model.Settings
data class PostCreateEvent(val posts: List<Post>)
data class PostUpdateEvent(val posts: List<Post>)
data class PostDeleteEvent(val postIds: List<Long>)
data class ImageEvent(val images: List<Image>)
data class ThreadCreateEvent(val thread: Thread)
data class ThreadDeleteEvent(val threadId: Long)
class ThreadClearEvent
data class VGUserLoginEvent(val username: String)
data class DownloadSpeedEvent(val downloadSpeed: DownloadSpeed)
data class QueueStateEvent(val queueState: QueueState)
data class ErrorCountEvent(val errorCount: ErrorCount)
data class SettingsUpdateEvent(val settings: Settings)
data class LogCreateEvent(val logEntry: LogEntry)
data class LogUpdateEvent(val logEntry: LogEntry)
data class LogDeleteEvent(val deleted: List<Long>)
data class LoadingTasks(val loading: Boolean)
@@ -0,0 +1,17 @@
package me.vripper.event
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import reactor.core.scheduler.Scheduler
import reactor.core.scheduler.Schedulers
import java.util.concurrent.Executors
object EventBus {
private val scheduler: Scheduler = Schedulers.fromExecutor(Executors.newSingleThreadExecutor())
private val _events = Sinks.many().multicast().onBackpressureBuffer<Any>()
val events: Flux<Any> = _events.asFlux().publishOn(scheduler)
fun publishEvent(event: Any) {
_events.emitNext(event) { _, _ -> true }
}
}
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class DownloadException : Exception {
constructor(message: String?) : super(message)
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class HostException : Exception {
constructor(e: Throwable?) : super(e)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class HtmlProcessorException(e: Throwable?) : Exception(e)
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class PostParseException : Exception {
constructor(message: String?) : super(message)
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class QueueException : Exception {
constructor(message: String?) : super(message)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class RenameException(message: String?, e: Exception?) : Exception(message, e)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class ValidationException(message: String) : Exception(message)
@@ -1,4 +1,4 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class VripperException : Exception {
constructor(message: String?) : super(message)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.exception
package me.vripper.exception
class XpathException(e: Throwable?) : Exception(e)
@@ -1,15 +1,13 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import org.apache.hc.client5.http.classic.HttpClient
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse
import org.apache.hc.core5.http.NameValuePair
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.http.message.BasicNameValuePair
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -18,8 +16,8 @@ class AcidimgHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("acidimg.cc", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("acidimg.cc", 0, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -31,39 +29,37 @@ class AcidimgHost(
"Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, url
)
)
XpathService.getAsNode(document, CONTINUE_BUTTON_XPATH)
XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH)
} catch (e: XpathException) {
throw HostException(e)
}
log.debug(String.format("Click button found for %s", url))
val client: HttpClient = httpService.clientBuilder.build()
val httpPost = httpService.buildHttpPost(url, context.httpContext)
httpPost.addHeader("Referer", url)
val params: MutableList<NameValuePair> = ArrayList()
params.add(BasicNameValuePair("imgContinue", "Continue to your image"))
try {
httpPost.entity = UrlEncodedFormEntity(params)
} catch (e: Exception) {
throw HostException(e)
}
val httpPost = HttpPost(url).also {
it.addHeader("Referer", url)
it.entity = UrlEncodedFormEntity(
listOf(
BasicNameValuePair(
"imgContinue",
"Continue to your image"
)
)
)
}.also { context.requests.add(it) }
log.debug(String.format("Requesting %s", httpPost))
val doc = try {
(client.execute(
httpService.client.execute(
httpPost, context.httpContext
) as CloseableHttpResponse).use { response ->
)
{ response ->
log.debug(String.format("Cleaning response for %s", httpPost))
try {
HtmlProcessorService.clean(response.entity.content)
} finally {
EntityUtils.consumeQuietly(response.entity)
}
HtmlUtils.clean(response.entity.content)
}
} catch (e: Exception) {
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(doc, IMG_XPATH)
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
@@ -13,8 +13,8 @@ class DPicMeHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("dpic.me", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("dpic.me", 1, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -24,7 +24,7 @@ class DPicMeHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -0,0 +1,215 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.client5.http.classic.methods.HttpHead
import org.apache.hc.core5.http.ClassicHttpResponse
import org.apache.hc.core5.http.Header
import org.jetbrains.exposed.sql.transactions.transaction
import org.w3c.dom.Document
import java.nio.file.Files
import java.nio.file.Path
abstract class Host(
val hostName: String,
val hostId: Byte,
private val httpService: HTTPService,
private val dataTransaction: DataTransaction,
private val downloadSpeedService: DownloadSpeedService
) {
private val log by me.vripper.delegate.LoggerDelegate()
companion object {
private const val READ_BUFFER_SIZE = 8192
private val hosts: MutableMap<String, Byte> = mutableMapOf()
fun getHosts(): Map<String, Byte> {
return hosts.toMap()
}
}
init {
hosts[hostName] = hostId
}
@Throws(HostException::class)
abstract fun resolve(
url: String,
document: Document,
context: ImageDownloadContext
): Pair<String, String>
@Throws(HostException::class)
fun downloadInternal(url: String, context: ImageDownloadContext): DownloadedImage {
val headers = head(url, context)
// is the body of type image ?
val imageMimeType = getImageMimeType(headers)
val downloadedImage = if (imageMimeType != null) {
// a direct link, awesome
val downloadedImage = fetch(url, context) {
handleImageDownload(it, context)
}
DownloadedImage(getDefaultImageName(url), downloadedImage.first, downloadedImage.second)
} else {
// linked image ?
val value = headers.find { it.name.contains("content-type", true) }?.value
if (value != null) {
if (value.contains("text/html")) {
val document = fetch(url, context) {
HtmlUtils.clean(it.entity.content)
}
if (log.isDebugEnabled) {
log.debug("Cleaning $url response", url)
}
val resolvedImage = resolve(url, document, context)
val downloadImage: Pair<Path, ImageMimeType> =
fetch(resolvedImage.second, context) {
handleImageDownload(it, context)
}
DownloadedImage(resolvedImage.first, downloadImage.first, downloadImage.second)
} else {
throw HostException("Unable to download $url, can't process content type $value")
}
} else {
throw HostException("Unexpected server response for $url, response have no content type")
}
}
return downloadedImage
}
private fun handleImageDownload(
response: ClassicHttpResponse,
context: ImageDownloadContext
): Pair<Path, ImageMimeType> {
val mimeType = getImageMimeType(response.headers)
?: throw HostException("Unsupported image type ${response.getFirstHeader("content-type")}")
val tempImage = Files.createTempFile(
Path.of(context.settings.systemSettings.tempPath),
"vripper_",
".tmp"
)
return Files.newOutputStream(tempImage).use { fos ->
val image = context.image
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
val size = if (image.size < 0) {
response.entity.contentLength
} else {
0
}
image.size = response.entity.contentLength
post.size += size
transaction {
dataTransaction.updateImage(image)
dataTransaction.updatePost(post)
}
}
log.debug(
"Length is ${image.size}"
)
log.debug(
"Starting data transfer"
)
val buffer = ByteArray(READ_BUFFER_SIZE)
var read: Int
while (response.entity.content.read(buffer, 0, READ_BUFFER_SIZE)
.also { read = it } != -1 && !context.stopped
) {
fos.write(buffer, 0, read)
image.downloaded += read
dataTransaction.updateImage(image)
downloadSpeedService.reportDownloadedBytes(read.toLong())
}
Pair(tempImage, mimeType)
}
}
fun isSupported(url: String): Boolean {
return url.contains(hostName)
}
@Throws(HostException::class)
fun head(url: String, context: ImageDownloadContext): Array<Header> {
val httpHead = HttpHead(url).also { context.requests.add(it) }
log.debug(String.format("Requesting %s", url))
return httpService.client.execute(
httpHead,
context.httpContext
) {
if (it.code / 100 != 2) {
throw HostException("Unexpected response code: ${it.code}")
}
it.headers
}
}
@Throws(HostException::class)
fun <T> fetch(
url: String,
context: ImageDownloadContext,
transformer: (ClassicHttpResponse) -> T
): T {
val httpGet = HttpGet(url).also { it.addHeader("Referer", context.image.url) }.also { context.requests.add(it) }
log.debug(String.format("Requesting %s", url))
return httpService.client.execute(httpGet, context.httpContext) {
if (it.code / 100 != 2) {
throw DownloadException("Server returned code ${it.code}")
}
transformer(it)
}
}
private fun getImageMimeType(headers: Array<Header>): ImageMimeType? {
// first check if content type header exists
val value = headers.find { it.name.contains("content-type", true) }?.value
// header found, check the type
return if (value != null) {
ImageMimeType.entries.find {
value.contains(it.strValue, true)
}
} else {
null
}
}
fun getDefaultImageName(imgUrl: String): String {
val imageTitle = imgUrl.substring(imgUrl.lastIndexOf('/') + 1)
log.debug(String.format("Extracting name from url %s: %s", imgUrl, imageTitle))
return getFileNameWithoutExtension(imageTitle)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Host
return hostName == other.hostName
}
override fun hashCode(): Int {
return hostName.hashCode()
}
override fun toString(): String {
return hostName
}
}
data class DownloadedImage(val name: String, val path: Path, val type: ImageMimeType)
enum class ImageMimeType(val strValue: String) {
IMAGE_BMP("image/bmp"),
IMAGE_GIF("image/gif"),
IMAGE_JPEG("image/jpeg"),
IMAGE_PNG("image/png"),
IMAGE_WEBP("image/webp"),
}
@@ -1,10 +1,11 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.impl.cookie.BasicClientCookie
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -17,8 +18,8 @@ class ImageBamHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagebam.com", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("imagebam.com", 2, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -28,7 +29,7 @@ class ImageBamHost(
): Pair<String, String> {
val doc = try {
log.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_XPATH, url))
if (XpathService.getAsNode(document, CONTINUE_XPATH) != null) {
if (XpathUtils.getAsNode(document, CONTINUE_XPATH) != null) {
val clientCookie = BasicClientCookie("nsfw_inter", "1")
clientCookie.domain = "www.imagebam.com"
clientCookie.path = "/"
@@ -38,7 +39,7 @@ class ImageBamHost(
)
context.httpContext.cookieStore.addCookie(clientCookie)
fetch(url, context) {
HtmlProcessorService.clean(it.entity.content)
HtmlUtils.clean(it.entity.content)
}
} else {
document
@@ -48,7 +49,7 @@ class ImageBamHost(
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(doc, IMG_XPATH)
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
@@ -13,8 +13,8 @@ class ImageTwistHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagetwist.com", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("imagetwist.com", 3, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -24,7 +24,7 @@ class ImageTwistHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,11 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -12,8 +13,8 @@ class ImageVenueHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagevenue.com", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("imagevenue.com", 4, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -29,10 +30,10 @@ class ImageVenueHost(
url
)
)
if (XpathService.getAsNode(document, CONTINUE_BUTTON_XPATH) != null) {
if (XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH) != null) {
// Button detected. No need to actually click it, just make the call again.
fetch(url, context) {
HtmlProcessorService.clean(it.entity.content)
HtmlUtils.clean(it.entity.content)
}
} else {
document
@@ -42,7 +43,7 @@ class ImageVenueHost(
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(doc, IMG_XPATH)
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,18 +1,18 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
class ImageZillaHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagezilla.net", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("imagezilla.net", 5, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -22,7 +22,7 @@ class ImageZillaHost(
): Pair<String, String> {
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -12,8 +12,8 @@ class ImgSpiceHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imgspice.com", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("imgspice.com", 7, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -23,7 +23,7 @@ class ImgSpiceHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -12,8 +12,8 @@ class ImgboxHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imgbox.com", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("imgbox.com", 6, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -23,7 +23,7 @@ class ImgboxHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,17 +1,14 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.HtmlProcessorException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import org.apache.hc.client5.http.classic.HttpClient
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.HtmlProcessorException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse
import org.apache.hc.core5.http.NameValuePair
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.http.message.BasicNameValuePair
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -21,8 +18,8 @@ class ImxHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imx.to", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("imx.to", 8, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -34,7 +31,7 @@ class ImxHost(
var value: String? = null
try {
log.debug("Looking for xpath expression $CONTINUE_BUTTON_XPATH in $httpsUrl")
val contDiv = XpathService.getAsNode(document, CONTINUE_BUTTON_XPATH)
val contDiv = XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH)
?: throw HostException("$CONTINUE_BUTTON_XPATH cannot be found")
val node = contDiv.attributes.getNamedItem("value")
if (node != null) {
@@ -47,26 +44,16 @@ class ImxHost(
throw HostException("Failed to obtain value attribute from continue input")
}
log.debug("Click button found for $httpsUrl")
val client: HttpClient = httpService.clientBuilder.build()
val httpPost: HttpPost = httpService.buildHttpPost(httpsUrl, context.httpContext)
val params: MutableList<NameValuePair> = ArrayList()
params.add(BasicNameValuePair("imgContinue", value))
try {
httpPost.entity = UrlEncodedFormEntity(params)
} catch (e: Exception) {
throw HostException(e)
}
log.debug("Requesting $httpPost")
val httpPost: HttpPost = HttpPost(httpsUrl).also {
it.entity = UrlEncodedFormEntity(listOf(BasicNameValuePair("imgContinue", value)))
}.also { context.requests.add(it) }
log.debug("Requesting {}", httpPost)
val doc = try {
(client.execute(
httpService.client.execute(
httpPost, context.httpContext
) as CloseableHttpResponse).use { response ->
log.debug("Cleaning response for $httpPost")
try {
HtmlProcessorService.clean(response.entity.content)
} finally {
EntityUtils.consumeQuietly(response.entity)
}
) { response ->
log.debug("Cleaning response for {}", httpPost)
HtmlUtils.clean(response.entity.content)
}
} catch (e: IOException) {
throw HostException(e)
@@ -75,7 +62,7 @@ class ImxHost(
}
val imgNode: Node = try {
log.debug("Looking for xpath expression $IMG_XPATH in $httpsUrl")
XpathService.getAsNode(doc, IMG_XPATH)
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,11 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.net.URI
@@ -14,8 +15,8 @@ class PimpandhostHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pimpandhost.com", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("pimpandhost.com", 9, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -30,11 +31,11 @@ class PimpandhostHost(
throw HostException(e)
}
val doc = fetch(newUrl, context) {
HtmlProcessorService.clean(it.entity.content)
HtmlUtils.clean(it.entity.content)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, newUrl))
XpathService.getAsNode(doc, IMG_XPATH)
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -12,8 +12,8 @@ class PixRouteHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixroute.com", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("pixroute.com", 11, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -23,7 +23,7 @@ class PixRouteHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -12,8 +12,8 @@ class PixhostHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixhost.to", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("pixhost.to", 10, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -23,7 +23,7 @@ class PixhostHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,18 +1,18 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
class PixxxelsHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixxxels.cc", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("pixxxels.cc", 12, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -22,7 +22,7 @@ class PixxxelsHost(
): Pair<String, String> {
val imgNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -34,7 +34,7 @@ class PixxxelsHost(
)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
XpathService.getAsNode(document, TITLE_XPATH)
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
@@ -13,8 +13,8 @@ class PostImgHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("postimg.cc", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("postimg.cc", 13, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -24,7 +24,7 @@ class PostImgHost(
): Pair<String, String> {
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
XpathService.getAsNode(document, TITLE_XPATH)
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -36,7 +36,7 @@ class PostImgHost(
)
val urlNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
@@ -12,8 +12,8 @@ class TurboImageHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("turboimagehost.com", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("turboimagehost.com", 14, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -24,7 +24,7 @@ class TurboImageHost(
var title: String?
title = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
val titleNode: Node? = XpathService.getAsNode(document, TITLE_XPATH)
val titleNode: Node? = XpathUtils.getAsNode(document, TITLE_XPATH)
log.debug(String.format("Resolving name for %s", url))
titleNode?.textContent?.trim { it <= ' ' }
} catch (e: XpathException) {
@@ -33,7 +33,7 @@ class TurboImageHost(
if (title.isNullOrEmpty()) {
title = getDefaultImageName(url)
}
val urlNode: Node = XpathService.getAsNode(document, IMG_XPATH)
val urlNode: Node = XpathUtils.getAsNode(document, IMG_XPATH)
?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
@@ -1,10 +1,10 @@
package me.mnlr.vripper.host
package me.vripper.host
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.ImageDownloadContext
import me.mnlr.vripper.exception.HostException
import me.mnlr.vripper.exception.XpathException
import me.mnlr.vripper.services.*
import me.vripper.download.ImageDownloadContext
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
@@ -13,8 +13,8 @@ class ViprImHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("vipr.im", httpService, dataTransaction, downloadSpeedService) {
private val log by LoggerDelegate()
) : Host("vipr.im", 15, httpService, dataTransaction, downloadSpeedService) {
private val log by me.vripper.delegate.LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
@@ -24,7 +24,7 @@ class ViprImHost(
): Pair<String, String> {
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
XpathService.getAsNode(document, IMG_XPATH)
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
@@ -1,16 +1,14 @@
package me.mnlr.vripper.listeners
package me.vripper.listeners
import me.mnlr.vripper.ApplicationProperties.BASE_DIR_NAME
import me.mnlr.vripper.ApplicationProperties.baseDir
import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR
import java.io.RandomAccessFile
import java.nio.file.Files
import kotlin.io.path.Path
import kotlin.io.path.pathString
import kotlin.system.exitProcess
object AppLock {
fun exclusiveLock() {
val lock = Path(baseDir).resolve(BASE_DIR_NAME).resolve("lock")
val lock = VRIPPER_DIR.resolve("lock")
try {
val randomFile = RandomAccessFile(lock.pathString, "rw")
val channel = randomFile.channel
@@ -1,7 +1,7 @@
package me.mnlr.vripper.listeners
package me.vripper.listeners
import me.mnlr.vripper.download.DownloadService
import me.mnlr.vripper.services.*
import me.vripper.download.DownloadService
import me.vripper.services.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -15,7 +15,6 @@ open class OnStartupListener : KoinComponent {
open fun run() {
dataTransaction.setDownloadingToStopped()
dataTransaction.sortPostsByRank()
httpService.init()
retryPolicyService.init()
vgAuthService.init()
@@ -0,0 +1,3 @@
package me.vripper.model
data class DownloadSpeed(val speed: Long)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.model
package me.vripper.model
data class ErrorCount(val count: Int)
@@ -1,5 +1,5 @@
package me.mnlr.vripper.model
package me.vripper.model
import me.mnlr.vripper.host.Host
import me.vripper.host.Host
data class ImageItem(val mainLink: String, val thumbLink: String, val host: Host)
@@ -1,3 +1,3 @@
package me.mnlr.vripper.model
package me.vripper.model
data class LoggedUser(val user: String)
@@ -1,9 +1,9 @@
package me.mnlr.vripper.model
package me.vripper.model
data class PostItem(
val threadId: String,
val threadId: Long,
val threadTitle: String,
val postId: String,
val postId: Long,
val number: Int,
val title: String,
val imageCount: Int,
@@ -1,4 +1,4 @@
package me.mnlr.vripper.model
package me.vripper.model
data class QueueState(
val running: Int,
@@ -1,11 +1,10 @@
package me.mnlr.vripper.model
package me.vripper.model
data class Settings(
var maxEventLog: Int = 1_000,
var connectionSettings: ConnectionSettings = ConnectionSettings(),
var downloadSettings: DownloadSettings = DownloadSettings(),
var viperSettings: ViperSettings = ViperSettings(),
var clipboardSettings: ClipboardSettings = ClipboardSettings()
var systemSettings: SystemSettings = SystemSettings()
)
data class ViperSettings(
@@ -18,21 +17,26 @@ data class ViperSettings(
data class DownloadSettings(
var downloadPath: String = System.getProperty("user.home"),
var tempPath: String = System.getProperty("java.io.tmpdir"),
var autoStart: Boolean = true,
var autoQueueThreshold: Int = 1,
var forceOrder: Boolean = false,
var forumSubfolder: Boolean = false,
var forumSubDirectory: Boolean = false,
var threadSubLocation: Boolean = false,
var clearCompleted: Boolean = false,
var appendPostId: Boolean = false
)
data class ConnectionSettings(
var maxThreads: Int = 2,
var maxTotalThreads: Int = 0,
var maxConcurrentPerHost: Int = 2,
var maxGlobalConcurrent: Int = 0,
var timeout: Long = 30,
var maxAttempts: Int = 3,
)
data class ClipboardSettings(var enable: Boolean = false, var pollingRate: Int = 500)
data class SystemSettings(
var tempPath: String = System.getProperty("java.io.tmpdir"),
var cachePath: String = System.getProperty("java.io.tmpdir"),
var enableClipboardMonitoring: Boolean = false,
var clipboardPollingRate: Int = 500,
var maxEventLog: Int = 1_000,
)
@@ -1,7 +1,7 @@
package me.mnlr.vripper.model
package me.vripper.model
data class ThreadItem(
val threadId: String,
val threadId: Long,
val title: String,
val securityToken: String,
val forum: String,
@@ -0,0 +1,3 @@
package me.vripper.model
data class ThreadPostId(val threadId: Long, val postId: Long)
@@ -0,0 +1,67 @@
package me.vripper.parser
import me.vripper.exception.DownloadException
import me.vripper.exception.PostParseException
import me.vripper.model.ThreadItem
import me.vripper.services.HTTPService
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.function.CheckedSupplier
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.core5.net.URIBuilder
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.BufferedInputStream
import java.util.*
import javax.xml.parsers.SAXParserFactory
class ThreadLookupAPIParser(private val threadId: Long) : KoinComponent {
private val log by me.vripper.delegate.LoggerDelegate()
private val cm: HTTPService by inject()
private val retryPolicyService: RetryPolicyService by inject()
private val vgAuthService: VGAuthService by inject()
private val settingsService: SettingsService by inject()
@Throws(PostParseException::class)
fun parse(): ThreadItem {
log.debug("Parsing thread $threadId")
val httpGet =
HttpGet(URIBuilder(settingsService.settings.viperSettings.host + "/vr.php").also {
it.setParameter(
"t",
threadId.toString()
)
}.build())
val threadLookupAPIResponseHandler = ThreadLookupAPIResponseHandler()
log.debug("Requesting {}", httpGet)
return try {
Failsafe.with(retryPolicyService.buildGenericRetryPolicy<Any>()).onFailure {
log.error(
"parsing failed for thread $threadId",
it.failure
)
}.get(CheckedSupplier {
cm.client.execute(
httpGet, vgAuthService.context
) { response ->
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
}
factory.newSAXParser().parse(
BufferedInputStream(response.entity.content),
threadLookupAPIResponseHandler
)
threadLookupAPIResponseHandler.result
}
})
} catch (e: Exception) {
throw PostParseException(e)
}
}
companion object {
private val factory = SAXParserFactory.newInstance()
}
}
@@ -1,18 +1,17 @@
package me.mnlr.vripper.parser
package me.vripper.parser
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.host.Host
import me.mnlr.vripper.model.ImageItem
import me.mnlr.vripper.model.PostItem
import me.mnlr.vripper.model.ThreadItem
import me.mnlr.vripper.services.SettingsService
import me.vripper.host.Host
import me.vripper.model.ImageItem
import me.vripper.model.PostItem
import me.vripper.model.ThreadItem
import me.vripper.services.SettingsService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.xml.sax.Attributes
import org.xml.sax.helpers.DefaultHandler
class ThreadLookupAPIResponseHandler : KoinComponent, DefaultHandler() {
private val log by LoggerDelegate()
private val log by me.vripper.delegate.LoggerDelegate()
private val supportedHosts: List<Host> = getKoin().getAll()
private val settingsService: SettingsService by inject()
private var error: String = ""
@@ -20,11 +19,11 @@ class ThreadLookupAPIResponseHandler : KoinComponent, DefaultHandler() {
private val postItemList: MutableList<PostItem> = mutableListOf()
private val imageItemList: MutableList<ImageItem> = mutableListOf()
private lateinit var threadItem: ThreadItem
private lateinit var threadId: String
private var threadId: Long = -1
private lateinit var threadTitle: String
private lateinit var forum: String
private lateinit var securityToken: String
private lateinit var postId: String
private var postId: Long = -1
private lateinit var postTitle: String
private var postCounter: Int = 0
@@ -38,14 +37,14 @@ class ThreadLookupAPIResponseHandler : KoinComponent, DefaultHandler() {
when (qName.lowercase()) {
"error" -> error = attributes.getValue("details")
"thread" -> {
threadId = attributes.getValue("id")?.trim() ?: ""
threadId = attributes.getValue("id")?.trim()?.toLong() ?: -1
threadTitle = attributes.getValue("title")?.trim() ?: ""
}
"forum" -> forum = attributes.getValue("title")?.trim() ?: ""
"user" -> securityToken = attributes.getValue("hash")?.trim() ?: ""
"post" -> {
postId = attributes.getValue("id")?.trim() ?: ""
postId = attributes.getValue("id")?.trim()?.toLong() ?: -1
postCounter = attributes.getValue("number")?.trim()?.toInt() ?: 0
val title = attributes.getValue("title")?.trim() ?: ""
postTitle = title.ifBlank { threadTitle }
@@ -56,17 +55,12 @@ class ThreadLookupAPIResponseHandler : KoinComponent, DefaultHandler() {
val thumbLink = attributes.getValue("thumb_url")?.trim() ?: ""
val type = attributes.getValue("type")?.trim() ?: ""
if (type == "linked") {
try {
val host = supportedHosts.first {
it.isSupported(mainLink)
}.let { host ->
hostMap.computeIfAbsent(host) { 0 }
hostMap[host] = hostMap[host]!! + 1
host
}
supportedHosts.firstOrNull {
it.isSupported(mainLink)
}?.also { host ->
hostMap.computeIfAbsent(host) { 0 }
hostMap[host] = hostMap[host]!! + 1
imageItemList.add(ImageItem(mainLink, thumbLink, host))
} catch (e: Exception) {
log.warn("Unsupported link: $mainLink", e)
}
}
}
@@ -85,7 +79,7 @@ class ThreadLookupAPIResponseHandler : KoinComponent, DefaultHandler() {
postTitle,
imageItemList.size,
"${settingsService.settings.viperSettings.host}/threads/?p=$postId&viewfull=1#post$postId",
hostMap.toMap().map { Pair(it.key.hostId, it.value) },
hostMap.toMap().map { Pair(it.key.hostName, it.value) },
securityToken,
forum,
imageItemList.toList()
@@ -0,0 +1,20 @@
package me.vripper.repositories
import me.vripper.entities.Image
import java.util.*
interface ImageRepository {
fun save(image: Image): Image
fun save(imageList: List<Image>)
fun deleteAllByPostId(postId: Long)
fun findByPostId(postId: Long): List<Image>
fun countError(): Int
fun findByPostIdAndIsNotCompleted(postId: Long): List<Image>
fun stopByPostIdAndIsNotCompleted(postId: Long): Int
fun stopByPostIdAndIsNotCompleted(): Int
fun findByPostIdAndIsError(postId: Long): List<Image>
fun findById(id: Long): Optional<Image>
fun update(image: Image)
fun update(images: List<Image>)
fun deleteAllByPostId(postIds: List<Long>)
}
@@ -0,0 +1,14 @@
package me.vripper.repositories
import me.vripper.entities.LogEntry
import java.util.*
interface LogRepository {
fun save(logEntry: LogEntry): LogEntry
fun update(logEntry: LogEntry)
fun findAll(): List<LogEntry>
fun findById(id: Long): Optional<LogEntry>
fun deleteOldest(): List<Long>
fun deleteAll()
}
@@ -0,0 +1,10 @@
package me.vripper.repositories
import me.vripper.entities.Metadata
import java.util.*
interface MetadataRepository {
fun save(metadata: Metadata): Metadata
fun findByPostId(postId: Long): Optional<Metadata>
fun deleteByPostId(postId: Long): Int
}
@@ -0,0 +1,21 @@
package me.vripper.repositories
import me.vripper.entities.Post
import java.util.*
interface PostDownloadStateRepository {
fun save(posts: List<Post>): List<Post>
fun findByPostId(postId: Long): Optional<Post>
fun findById(id: Long): Optional<Post>
fun findCompleted(): List<Long>
fun findAll(): List<Post>
fun existByPostId(postId: Long): Boolean
fun setDownloadingToStopped(): Int
fun deleteByPostId(postId: Long): Int
fun update(post: Post)
fun update(posts: List<Post>)
fun findMaxRank(): Int?
fun deleteAll(postIds: List<Long>)
fun stopAll()
fun findAllNonCompletedPostIds(): List<Long>
}
@@ -1,13 +1,13 @@
package me.mnlr.vripper.repositories
package me.vripper.repositories
import me.mnlr.vripper.entities.Thread
import me.vripper.entities.Thread
import java.util.*
interface ThreadRepository {
fun save(thread: Thread): Thread
fun findByThreadId(threadId: String): Optional<Thread>
fun findByThreadId(threadId: Long): Optional<Thread>
fun findAll(): List<Thread>
fun findById(id: Long): Optional<Thread>
fun deleteByThreadId(threadId: String): Int
fun deleteByThreadId(threadId: Long): Int
fun deleteAll()
}
@@ -1,22 +1,24 @@
package me.mnlr.vripper.repositories.impl
package me.vripper.repositories.impl
import me.mnlr.vripper.entities.Image
import me.mnlr.vripper.entities.domain.Status
import me.mnlr.vripper.repositories.ImageRepository
import me.mnlr.vripper.tables.ImageTable
import me.vripper.entities.Image
import me.vripper.entities.domain.Status
import me.vripper.repositories.ImageRepository
import me.vripper.tables.ImageTable
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.*
class ImageRepositoryImpl : ImageRepository {
override fun save(image: Image): Image {
val id = ImageTable.insertAndGetId {
it[current] = image.current
it[downloaded] = image.downloaded
it[host] = image.host
it[index] = image.index
it[postId] = image.postId
it[status] = image.status.name
it[total] = image.total
it[size] = image.size
it[url] = image.url
it[thumbUrl] = image.thumbUrl
it[postIdRef] = image.postIdRef
@@ -26,23 +28,23 @@ class ImageRepositoryImpl : ImageRepository {
override fun save(imageList: List<Image>) {
ImageTable.batchInsert(imageList, shouldReturnGeneratedValues = false) {
this[ImageTable.current] = it.current
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.total] = it.total
this[ImageTable.size] = it.size
this[ImageTable.url] = it.url
this[ImageTable.thumbUrl] = it.thumbUrl
this[ImageTable.postIdRef] = it.postIdRef
}
}
override fun deleteAllByPostId(postId: String) {
override fun deleteAllByPostId(postId: Long) {
ImageTable.deleteWhere { ImageTable.postId eq postId }
}
override fun findByPostId(postId: String): List<Image> {
override fun findByPostId(postId: Long): List<Image> {
return ImageTable.select {
ImageTable.postId eq postId
}.map(this::transform)
@@ -55,20 +57,26 @@ class ImageRepositoryImpl : ImageRepository {
.count().toInt()
}
override fun findByPostIdAndIsNotCompleted(postId: String): List<Image> {
override fun findByPostIdAndIsNotCompleted(postId: Long): List<Image> {
return ImageTable
.select {
(ImageTable.postId eq postId) and (ImageTable.status neq Status.FINISHED.name)
}.map(this::transform)
}
override fun stopByPostIdAndIsNotCompleted(postId: String): Int {
override fun stopByPostIdAndIsNotCompleted(postId: Long): Int {
return ImageTable.update({ (ImageTable.postId eq postId) and (ImageTable.status neq Status.FINISHED.name) }) {
it[status] = Status.STOPPED.name
}
}
override fun findByPostIdAndIsError(postId: String): List<Image> {
override fun stopByPostIdAndIsNotCompleted(): Int {
return ImageTable.update({ (ImageTable.status neq Status.FINISHED.name) }) {
it[status] = Status.STOPPED.name
}
}
override fun findByPostIdAndIsError(postId: Long): List<Image> {
return ImageTable.select {
(ImageTable.postId eq postId) and (ImageTable.status eq Status.ERROR.name)
}.map(this::transform)
@@ -88,11 +96,49 @@ class ImageRepositoryImpl : ImageRepository {
override fun update(image: Image) {
ImageTable.update({ ImageTable.id eq image.id }) {
it[status] = image.status.name
it[current] = image.current
it[total] = image.total
it[downloaded] = image.downloaded
it[size] = image.size
}
}
override fun update(images: List<Image>) {
ImageTable.batchReplace(images, shouldReturnGeneratedValues = false) {
this[ImageTable.id] = it.id
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
}
}
override fun deleteAllByPostId(postIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE LOCAL TEMPORARY TABLE IMAGES_DELETE(POST_ID BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO IMAGES_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM IMAGE WHERE POST_ID IN (SELECT POST_ID FROM IMAGES_DELETE)")
.use {
it.execute()
}
conn.prepareStatement("TRUNCATE TABLE IMAGES_DELETE")
}
private fun transform(resultRow: ResultRow): Image {
val id = resultRow[ImageTable.id].value
val postId = resultRow[ImageTable.postId]
@@ -100,8 +146,8 @@ class ImageRepositoryImpl : ImageRepository {
val thumbUrl = resultRow[ImageTable.thumbUrl]
val host = resultRow[ImageTable.host]
val index = resultRow[ImageTable.index]
val current = resultRow[ImageTable.current]
val total = resultRow[ImageTable.total]
val current = resultRow[ImageTable.downloaded]
val total = resultRow[ImageTable.size]
val status = Status.valueOf(resultRow[ImageTable.status])
val postIdRef = resultRow[ImageTable.postIdRef]
return Image(
@@ -0,0 +1,74 @@
package me.vripper.repositories.impl
import me.vripper.entities.LogEntry
import me.vripper.repositories.LogRepository
import me.vripper.services.SettingsService
import me.vripper.tables.LogTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.notInSubQuery
import java.util.*
class LogRepositoryImpl(val settingsService: SettingsService) : LogRepository {
@Synchronized
override fun save(logEntry: LogEntry): LogEntry {
val id = LogTable.insertAndGetId {
it[type] = logEntry.type.name
it[status] = logEntry.status.name
it[time] = logEntry.time
it[message] = logEntry.message.take(500)
}.value
return logEntry.copy(id = id)
}
override fun update(logEntry: LogEntry) {
LogTable.update({ LogTable.id eq logEntry.id }) {
it[type] = logEntry.type.name
it[status] = logEntry.status.name
it[message] = logEntry.message.take(500)
}
}
override fun findById(id: Long): Optional<LogEntry> {
val result = LogTable.select {
LogTable.id eq id
}.map(::transform)
return if (result.isEmpty()) {
Optional.empty()
} else {
Optional.of(result.first())
}
}
override fun findAll(): List<LogEntry> {
return LogTable.selectAll().map(::transform)
}
override fun deleteOldest(): List<Long> {
val sub = LogTable.slice(LogTable.id).selectAll().orderBy(LogTable.time to SortOrder.DESC)
.limit(settingsService.settings.systemSettings.maxEventLog)
val ids = LogTable.select { LogTable.id notInSubQuery sub }.map { it[LogTable.id].value }
LogTable.deleteWhere { LogTable.id notInSubQuery sub }
return ids
}
override fun deleteAll() {
LogTable.deleteAll()
}
private fun transform(resultRow: ResultRow): LogEntry {
val id = resultRow[LogTable.id].value
val type = LogEntry.Type.valueOf(resultRow[LogTable.type])
val status = LogEntry.Status.valueOf(resultRow[LogTable.status])
val time = resultRow[LogTable.time]
val message = resultRow[LogTable.message]
return LogEntry(
id,
type,
status,
time,
message
)
}
}
@@ -0,0 +1,20 @@
package me.vripper.repositories.impl
import me.vripper.entities.Metadata
import me.vripper.repositories.MetadataRepository
import java.util.*
class MetadataRepositoryImpl: MetadataRepository {
override fun save(metadata: Metadata): Metadata {
return metadata
}
override fun findByPostId(postId: Long): Optional<Metadata> {
return Optional.empty()
}
override fun deleteByPostId(postId: Long): Int {
return 0
}
}
@@ -0,0 +1,198 @@
package me.vripper.repositories.impl
import me.vripper.entities.Post
import me.vripper.entities.domain.Status
import me.vripper.repositories.PostDownloadStateRepository
import me.vripper.tables.PostTable
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.*
class PostDownloadStateRepositoryImpl :
PostDownloadStateRepository {
private val delimiter = ";"
override fun save(posts: List<Post>): List<Post> {
return PostTable.batchInsert(posts, shouldReturnGeneratedValues = true) { post ->
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.postId] = post.postId
this[PostTable.threadId] = post.threadId
this[PostTable.postTitle] = post.postTitle
this[PostTable.threadTitle] = post.threadTitle
this[PostTable.forum] = post.forum
this[PostTable.url] = post.url
this[PostTable.token] = post.token
this[PostTable.addedAt] = post.addedOn
this[PostTable.size] = post.size
this[PostTable.downloaded] = post.downloaded
}.map(::transform)
}
override fun findByPostId(postId: Long): Optional<Post> {
val result = PostTable.select {
PostTable.postId eq postId
}.map(::transform)
return if (result.isEmpty()) {
Optional.empty()
} else {
Optional.of(result.first())
}
}
override fun findCompleted(): List<Long> {
return PostTable.slice(PostTable.postId).select {
(PostTable.status eq Status.FINISHED.name) and (PostTable.done greaterEq PostTable.total)
}.map { it[PostTable.postId] }
}
override fun findById(id: Long): Optional<Post> {
val result = PostTable.select {
PostTable.id eq id
}.map { transform(it) }
return if (result.isEmpty()) {
Optional.empty()
} else {
Optional.of(result.first())
}
}
override fun findAll(): List<Post> {
return PostTable.selectAll().map { transform(it) }
}
override fun existByPostId(postId: Long): Boolean {
return PostTable.slice(PostTable.id).select { PostTable.postId eq postId }.count() > 0
}
override fun setDownloadingToStopped(): Int {
return PostTable.update({ (PostTable.status eq Status.DOWNLOADING.name) or (PostTable.status eq Status.PENDING.name) }) {
it[status] = Status.STOPPED.name
}
}
override fun deleteByPostId(postId: Long): Int {
return PostTable.deleteWhere { PostTable.postId eq postId }
}
override fun update(post: Post) {
PostTable.update({ PostTable.id eq post.id }) {
it[status] = post.status.name
it[done] = post.done
it[rank] = post.rank
it[size] = post.size
it[downloaded] = post.downloaded
}
}
override fun update(posts: List<Post>) {
PostTable.batchReplace(posts, shouldReturnGeneratedValues = false) { post ->
this[PostTable.id] = post.id
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.postId] = post.postId
this[PostTable.threadId] = post.threadId
this[PostTable.postTitle] = post.postTitle
this[PostTable.threadTitle] = post.threadTitle
this[PostTable.forum] = post.forum
this[PostTable.url] = post.url
this[PostTable.token] = post.token
this[PostTable.size] = post.size
this[PostTable.downloaded] = post.downloaded
}
}
override fun findMaxRank(): Int? {
return PostTable.slice(PostTable.rank.max())
.selectAll()
.firstOrNull()
?.get(PostTable.rank.max())
}
override fun deleteAll(postIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE LOCAL TEMPORARY TABLE POSTS_DELETE(POST_ID BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO POSTS_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM POST WHERE POST_ID IN (SELECT POST_ID FROM POSTS_DELETE)")
.use {
it.execute()
}
conn.prepareStatement("TRUNCATE TABLE POSTS_DELETE")
}
override fun stopAll() {
PostTable.update {
it[status] = Status.STOPPED.name
}
}
override fun findAllNonCompletedPostIds(): List<Long> {
return PostTable.slice(PostTable.postId).select {
PostTable.status neq Status.FINISHED.name
}.map { it[PostTable.postId] }
}
private fun transform(resultRow: ResultRow): Post {
val id = resultRow[PostTable.id].value
val status = Status.valueOf(resultRow[PostTable.status])
val postId = resultRow[PostTable.postId]
val threadId = resultRow[PostTable.threadId]
val postTitle = resultRow[PostTable.postTitle]
val threadTitle = resultRow[PostTable.threadTitle]
val forum = resultRow[PostTable.forum]
val url = resultRow[PostTable.url]
val token = resultRow[PostTable.token]
val done = resultRow[PostTable.done]
val total = resultRow[PostTable.total]
val hosts =
resultRow[PostTable.hosts].split(delimiter).dropLastWhile { it.isEmpty() }.toSet()
val downloadDirectory = resultRow[PostTable.outputPath]
val addedOn = resultRow[PostTable.addedAt]
val rank = resultRow[PostTable.rank]
val size = resultRow[PostTable.size]
val downloaded = resultRow[PostTable.downloaded]
return Post(
id,
postTitle,
threadTitle,
forum,
url,
token,
postId,
threadId,
total,
hosts,
downloadDirectory,
addedOn,
status,
done,
rank,
size,
downloaded
)
}
}
@@ -1,8 +1,8 @@
package me.mnlr.vripper.repositories.impl
package me.vripper.repositories.impl
import me.mnlr.vripper.entities.Thread
import me.mnlr.vripper.repositories.ThreadRepository
import me.mnlr.vripper.tables.ThreadTable
import me.vripper.entities.Thread
import me.vripper.repositories.ThreadRepository
import me.vripper.tables.ThreadTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import java.util.*
@@ -19,7 +19,7 @@ class ThreadRepositoryImpl : ThreadRepository {
return thread.copy(id = id)
}
override fun findByThreadId(threadId: String): Optional<Thread> {
override fun findByThreadId(threadId: Long): Optional<Thread> {
val result = ThreadTable.select {
ThreadTable.threadId eq threadId
}.map(this::transform)
@@ -45,7 +45,7 @@ class ThreadRepositoryImpl : ThreadRepository {
}
}
override fun deleteByThreadId(threadId: String): Int {
override fun deleteByThreadId(threadId: Long): Int {
return ThreadTable.deleteWhere { ThreadTable.threadId eq threadId }
}
@@ -1,13 +1,13 @@
package me.mnlr.vripper
package me.vripper.services
import me.mnlr.vripper.delegate.LoggerDelegate
import me.mnlr.vripper.download.DownloadService
import me.mnlr.vripper.download.PostDownloadRunnable
import me.mnlr.vripper.exception.PostParseException
import me.mnlr.vripper.model.PostItem
import me.mnlr.vripper.repositories.LogEventRepository
import me.mnlr.vripper.services.*
import me.mnlr.vripper.tasks.ThreadLookupRunnable
import me.vripper.download.DownloadService
import me.vripper.exception.PostParseException
import me.vripper.model.PostItem
import me.vripper.model.ThreadPostId
import me.vripper.services.*
import me.vripper.tasks.AddPostRunnable
import me.vripper.tasks.ThreadLookupRunnable
import me.vripper.utilities.GLOBAL_EXECUTOR
import java.util.*
import java.util.concurrent.CompletableFuture
import java.util.regex.Pattern
@@ -16,10 +16,9 @@ class AppEndpointService(
private val downloadService: DownloadService,
private val dataTransaction: DataTransaction,
private val threadCacheService: ThreadCacheService,
private val eventRepository: LogEventRepository,
private val settingsService: SettingsService
) {
private val log by LoggerDelegate()
private val log by me.vripper.delegate.LoggerDelegate()
@Synchronized
fun scanLinks(postLinks: String) {
@@ -27,29 +26,35 @@ class AppEndpointService(
log.warn("Nothing to scan")
return
}
val urlList = postLinks.split(Pattern.compile("\\r?\\n")).dropLastWhile { it.isEmpty() }
.map { it.trim() }.filter { it.isNotEmpty() }
val urlList = postLinks.split(Pattern.compile("\\r?\\n")).dropLastWhile { it.isEmpty() }.map { it.trim() }
.filter { it.isNotEmpty() }
for (link in urlList) {
log.debug("Starting to process thread: $link")
if (!link.startsWith(settingsService.settings.viperSettings.host)) {
continue
}
var threadId: String
var postId: String?
var threadId: Long
var postId: Long?
val m = Pattern.compile(
Pattern.quote(settingsService.settings.viperSettings.host) + "/threads/(\\d+)((.*p=)(\\d+))?"
).matcher(link)
if (m.find()) {
threadId = m.group(1)
postId = m.group(4)
threadId = m.group(1).toLong()
postId = m.group(4)?.toLong()
if (postId == null) {
CompletableFuture.runAsync(ThreadLookupRunnable(
threadId, settingsService.settings
))
CompletableFuture.runAsync(
ThreadLookupRunnable(
threadId, settingsService.settings
),
GLOBAL_EXECUTOR
)
} else {
CompletableFuture.runAsync(PostDownloadRunnable(
threadId, postId
))
CompletableFuture.runAsync(
AddPostRunnable(
listOf(ThreadPostId(threadId, postId))
),
GLOBAL_EXECUTOR
)
}
} else {
log.error("Cannot retrieve thread id from URL $link")
@@ -59,38 +64,37 @@ class AppEndpointService(
}
@Synchronized
fun restartAll(posIds: List<String> = listOf()) {
downloadService.restartAll(posIds)
fun restartAll(posIds: List<Long> = listOf()) {
downloadService.restartAll(posIds.map { dataTransaction.findPostsByPostId(it) }.filter { it.isPresent }
.map { it.get() })
}
@Synchronized
fun download(posts: List<Pair<String, String>>) {
posts.forEach {
CompletableFuture.runAsync(PostDownloadRunnable(
it.first, it.second
))
}
fun download(posts: List<ThreadPostId>) {
CompletableFuture.runAsync(
AddPostRunnable(posts), GLOBAL_EXECUTOR
)
}
@Synchronized
fun stopAll(postIdList: List<String>?) {
downloadService.stopAll(postIdList)
fun stopAll(postIdList: List<Long> = emptyList()) {
downloadService.stop(postIdList)
}
@Synchronized
fun remove(postIdList: List<String>) {
downloadService.stopAll(postIdList)
fun remove(postIdList: List<Long>) {
downloadService.stop(postIdList)
dataTransaction.removeAll(postIdList)
}
@Synchronized
fun clearCompleted(): List<String> {
fun clearCompleted(): List<Long> {
return dataTransaction.clearCompleted()
}
@Synchronized
@Throws(PostParseException::class)
fun grab(threadId: String): List<PostItem> {
fun grab(threadId: Long): List<PostItem> {
return try {
val thread = dataTransaction.findThreadByThreadId(threadId).orElseThrow {
PostParseException(
@@ -122,7 +126,7 @@ class AppEndpointService(
}
@Synchronized
fun threadRemove(threadIdList: List<String>) {
fun threadRemove(threadIdList: List<Long>) {
threadIdList.forEach {
dataTransaction.removeThread(it)
}
@@ -134,6 +138,7 @@ class AppEndpointService(
}
fun logClear() {
eventRepository.deleteAll()
dataTransaction.deleteAllLogs()
}
}
@@ -0,0 +1,277 @@
package me.vripper.services
import me.vripper.entities.*
import me.vripper.entities.domain.Status
import me.vripper.event.*
import me.vripper.model.ErrorCount
import me.vripper.model.PostItem
import me.vripper.repositories.*
import me.vripper.utilities.PathUtils
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*
import kotlin.io.path.pathString
class DataTransaction(
private val settingsService: SettingsService,
private val postDownloadStateRepository: PostDownloadStateRepository,
private val imageRepository: ImageRepository,
private val threadRepository: ThreadRepository,
private val metadataRepository: MetadataRepository,
private val logRepository: LogRepository,
private val eventBus: EventBus,
) {
private fun save(posts: List<Post>): List<Post> {
return transaction { postDownloadStateRepository.save(posts) }
}
fun updatePosts(posts: List<Post>) {
transaction { postDownloadStateRepository.update(posts) }
eventBus.publishEvent(PostUpdateEvent(posts))
}
fun updatePost(post: Post) {
transaction { postDownloadStateRepository.update(post) }
eventBus.publishEvent(PostUpdateEvent(listOf(post)))
}
fun save(thread: Thread) {
val savedThread = transaction { threadRepository.save(thread) }
eventBus.publishEvent(ThreadCreateEvent(savedThread))
}
fun updateImages(images: List<Image>) {
transaction { imageRepository.update(images) }
eventBus.publishEvent(ImageEvent(images))
}
fun updateImage(image: Image) {
transaction { imageRepository.update(image) }
eventBus.publishEvent(ImageEvent(listOf(image)))
}
fun exists(postId: Long): Boolean {
return transaction { postDownloadStateRepository.existByPostId(postId) }
}
@Synchronized
fun newPosts(postItems: List<PostItem>): List<Post> {
var queuePosition = transaction {
getQueuePosition()?.plus(1) ?: 0
}
val posts = postItems.associate { postItem ->
val post = Post(
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 = queuePosition++,
downloadDirectory = PathUtils.calculateDownloadPath(
postItem.forum,
postItem.threadTitle,
postItem.title,
postItem.postId,
settingsService.settings
).pathString
)
val images = postItem.imageItemList.mapIndexed { index, imageItem ->
Image(
postId = postItem.postId,
url = imageItem.mainLink,
thumbUrl = imageItem.thumbLink,
host = imageItem.host.hostId,
index = index,
)
}
Pair(post, images)
}
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
}
eventBus.publishEvent(PostCreateEvent(savedPosts))
return savedPosts
}
private fun getQueuePosition(): Int? {
return postDownloadStateRepository.findMaxRank()
}
private fun save(images: List<Image>) {
transaction { imageRepository.save(images) }
}
fun finishPost(postId: Long, automatic: Boolean = false) {
val post = findPostsByPostId(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<Image> {
return transaction { imageRepository.findByPostIdAndIsError(postId) }
}
private fun remove(postIds: List<Long>) {
transaction {
imageRepository.deleteAllByPostId(postIds)
postDownloadStateRepository.deleteAll(postIds)
sortPostsByRank()
}
eventBus.publishEvent(PostDeleteEvent(postIds = postIds))
eventBus.publishEvent(ErrorCountEvent(ErrorCount(countImagesInError())))
}
fun removeThread(threadId: Long) {
transaction { threadRepository.deleteByThreadId(threadId) }
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(Post::postId))
}
}
fun stopImagesByPostIdAndIsNotCompleted(postId: Long) {
transaction { imageRepository.stopByPostIdAndIsNotCompleted(postId) }
}
@Synchronized
fun setMetadata(post: Post, metadata: Metadata) {
if (metadataRepository.findByPostId(post.postId).isEmpty) {
metadata.postIdRef = post.id
metadataRepository.save(metadata)
}
}
fun clearQueueLinks() {
transaction { threadRepository.deleteAll() }
eventBus.publishEvent(ThreadClearEvent())
}
@Synchronized
fun sortPostsByRank() {
val posts = findAllPosts().sortedWith(Comparator.comparing(Post::addedOn))
for (i in posts.indices) {
posts[i].rank = i
}
updatePosts(posts)
}
fun setDownloadingToStopped() {
transaction { postDownloadStateRepository.setDownloadingToStopped() }
}
fun findAllPosts(): List<Post> {
return transaction { postDownloadStateRepository.findAll() }
}
fun findPostById(id: Long): Optional<Post> {
return transaction { postDownloadStateRepository.findById(id) }
}
fun findImagesByPostId(postId: Long): List<Image> {
return transaction { imageRepository.findByPostId(postId) }
}
fun findImageById(id: Long): Optional<Image> {
return transaction { imageRepository.findById(id) }
}
fun findAllThreads(): List<Thread> {
return transaction { threadRepository.findAll() }
}
fun findThreadById(id: Long): Optional<Thread> {
return transaction {
threadRepository.findById(id)
}
}
fun findByPostIdAndIsNotCompleted(postId: Long): List<Image> {
return transaction { imageRepository.findByPostIdAndIsNotCompleted(postId) }
}
fun countImagesInError(): Int {
return transaction { imageRepository.countError() }
}
fun findPostsByPostId(postId: Long): Optional<Post> {
return transaction { postDownloadStateRepository.findByPostId(postId) }
}
fun findThreadByThreadId(threadId: Long): Optional<Thread> {
return transaction { threadRepository.findByThreadId(threadId) }
}
fun saveLog(logEntry: LogEntry): LogEntry {
val pair = transaction {
val saved = logRepository.save(logEntry)
val deleted = logRepository.deleteOldest()
Pair(saved, deleted)
}
eventBus.publishEvent(LogCreateEvent(pair.first))
eventBus.publishEvent(LogDeleteEvent(pair.second))
return pair.first
}
fun updateLog(logEntry: LogEntry) {
transaction { logRepository.update(logEntry) }
eventBus.publishEvent(LogUpdateEvent(logEntry))
}
fun deleteAllLogs() {
transaction { logRepository.deleteAll() }
}
fun findAllLogs(): List<LogEntry> {
return transaction { logRepository.findAll() }
}
fun findAllNonCompletedPostIds(): List<Long> {
return transaction { postDownloadStateRepository.findAllNonCompletedPostIds() }
}
}
@@ -1,11 +1,10 @@
package me.mnlr.vripper.services
package me.vripper.services
import kotlinx.coroutines.*
import me.mnlr.vripper.event.DownloadSpeedEvent
import me.mnlr.vripper.event.EventBus
import me.mnlr.vripper.event.QueueStateEvent
import me.mnlr.vripper.formatSI
import me.mnlr.vripper.model.DownloadSpeed
import me.vripper.event.DownloadSpeedEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.model.DownloadSpeed
import java.util.concurrent.atomic.AtomicLong
class DownloadSpeedService(
@@ -16,23 +15,22 @@ class DownloadSpeedService(
const val DOWNLOAD_POLL_RATE = 2500
}
private val coroutineScope = CoroutineScope(Dispatchers.Default)
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val bytesCount = AtomicLong(0)
private var job: Job? = null
fun init() {
coroutineScope.launch {
eventBus.subscribe<QueueStateEvent> {
eventBus.events.ofType(QueueStateEvent::class.java).subscribe {
if (it.queueState.running + it.queueState.remaining > 0) {
if (job == null || job?.isActive == false) {
job = coroutineScope.launch {
eventBus.publishEvent(DownloadSpeedEvent(DownloadSpeed(0L.formatSI())))
eventBus.publishEvent(DownloadSpeedEvent(DownloadSpeed(0L)))
while (isActive) {
delay(DOWNLOAD_POLL_RATE.toLong())
val newValue = bytesCount.getAndSet(0)
eventBus.publishEvent(DownloadSpeedEvent(DownloadSpeed(((newValue * 1000) / DOWNLOAD_POLL_RATE).formatSI())))
eventBus.publishEvent(DownloadSpeedEvent(DownloadSpeed(((newValue * 1000) / DOWNLOAD_POLL_RATE))))
}
}
}
@@ -40,7 +38,7 @@ class DownloadSpeedService(
job?.cancel()
coroutineScope.launch {
delay(DOWNLOAD_POLL_RATE + 500L)
eventBus.publishEvent(DownloadSpeedEvent(DownloadSpeed(0L.formatSI())))
eventBus.publishEvent(DownloadSpeedEvent(DownloadSpeed(0L)))
}
}
}

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