Initial commit

🏳️‍⚧️
eater 5 years ago
commit d62969aafc
Signed by: eater
GPG Key ID: AD2560A0F84F0759

6
.gitignore vendored

@ -0,0 +1,6 @@
*.sqlite
*.sqlite
out
build
.gradle
.idea

@ -0,0 +1,3 @@
# Index (Librorum Animum)

@ -0,0 +1,80 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.3.72'
id 'org.jetbrains.kotlin.kapt' version '1.3.72'
id 'application'
}
group 'moe.odango'
version '1.0-SNAPSHOT'
repositories {
mavenCentral()
jcenter()
maven { url 'https://jitpack.io' }
}
test {
useJUnitPlatform()
}
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
implementation 'org.xerial:sqlite-jdbc:3.31.1'
implementation 'io.ktor:ktor-server-core:1.3.2'
implementation 'io.ktor:ktor-server-netty:1.3.2'
implementation 'io.ktor:ktor-jackson:1.3.2'
implementation 'io.requery:requery:1.6.1'
implementation 'io.requery:requery-kotlin:1.6.1'
kapt 'io.requery:requery-processor:1.6.1'
implementation 'org.slf4j:slf4j-simple:1.6.1'
implementation 'org.apache.logging.log4j:log4j-core:2.13.3'
implementation 'com.github.kittinunf.fuel:fuel:2.2.0'
implementation 'com.github.kittinunf.fuel:fuel-coroutines:2.2.0'
implementation 'com.fasterxml:aalto-xml:1.2.1'
implementation 'org.kodein.di:kodein-di:7.0.0'
implementation 'com.github.ajalt:clikt:2.7.1'
implementation 'org.xerial:sqlite-jdbc:3.8.11.2'
implementation 'org.postgresql:postgresql:42.2.5'
implementation 'com.moandjiezana.toml:toml4j:0.7.2'
implementation 'org.apache.commons:commons-compress:1.18'
implementation 'com.expediagroup:graphql-kotlin-schema-generator:3.1.1'
implementation 'com.graphql-java:graphql-java:15.0'
implementation 'com.github.excitement-engineer:ktor-graphql:1.0.0'
implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.7.0'
implementation 'com.github.jillesvangurp:es-kotlin-wrapper-client:1.0-X-Beta-5-7.7.0'
testImplementation 'io.kotest:kotest-runner-junit5-jvm:4.0.5'
testImplementation 'io.kotest:kotest-assertions-core-jvm:4.0.5'
testImplementation 'io.kotest:kotest-property-jvm:4.0.5'
}
application {
mainClassName = 'moe.odango.index.MainKt'
}
compileKotlin {
kotlinOptions.jvmTarget = '1.8'
}
compileTestKotlin {
kotlinOptions.jvmTarget = '1.8'
}

@ -0,0 +1,8 @@
[database]
driver = "sqlite"
uri = "jdbc:sqlite:test.sql"
[elastic]
index = "anime"
replicas = 0
shards = 2

@ -0,0 +1,9 @@
version: '3.7'
services:
es:
image: elasticsearch:7.7.0
environment:
discovery.type: single-node
ports:
- 9200:9200
- 9300:9300

@ -0,0 +1 @@
kotlin.code.style=official

Binary file not shown.

@ -0,0 +1,6 @@
#Sat Jun 06 16:52:37 CEST 2020
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-all.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

183
gradlew vendored

@ -0,0 +1,183 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

100
gradlew.bat vendored

@ -0,0 +1,100 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

@ -0,0 +1,2 @@
rootProject.name = 'index'

@ -0,0 +1,26 @@
package moe.odango.index.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.option
import kotlinx.coroutines.runBlocking
import moe.odango.index.sync.AniDBTitleSync
import java.io.File
class AniDBSync : CliktCommand(name = "anidb",help = "Sync the titles from AniDB with the local database") {
private val file by option(help = "Read from file instead of downloading")
override fun run() {
val sync = AniDBTitleSync()
val file = file
runBlocking {
if (file == null) {
sync.run()
} else {
val fileObj = File(file).takeIf { it.isFile } ?: throw RuntimeException("File doesn't exist")
sync.syncWithFile(fileObj)
}
}
}
}

@ -0,0 +1,28 @@
package moe.odango.index.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import io.requery.sql.KotlinConfiguration
import io.requery.sql.SchemaModifier
import io.requery.sql.TableCreationMode
import moe.odango.index.di
import org.kodein.di.instance
class DatabaseMigration : CliktCommand(name = "db:create") {
private val print by option().flag(default = false)
private val exec by option().flag(default = false)
override fun run() {
val config by di.instance<KotlinConfiguration>()
val modifier = SchemaModifier(config)
if (exec) {
modifier.createTables(TableCreationMode.CREATE_NOT_EXISTS)
}
if (print) {
println(modifier.createTablesString(TableCreationMode.CREATE_NOT_EXISTS))
}
}
}

@ -0,0 +1,10 @@
package moe.odango.index.cli
import com.github.ajalt.clikt.core.CliktCommand
import moe.odango.index.es.Indexer
class ElasticIndex : CliktCommand(name = "elastic:index") {
override fun run() {
Indexer().run()
}
}

@ -0,0 +1,10 @@
package moe.odango.index.cli
import com.github.ajalt.clikt.core.CliktCommand
import moe.odango.index.http.Server
class HTTPServer : CliktCommand(name = "http:serve") {
override fun run() {
Server().run()
}
}

@ -0,0 +1,13 @@
package moe.odango.index.cli
import com.github.ajalt.clikt.core.CliktCommand
import kotlinx.coroutines.runBlocking
import moe.odango.index.sync.MyAnimeListListingSync
class MyAnimeListListingSync : CliktCommand(name = "mal:sync-listing") {
override fun run() {
runBlocking {
MyAnimeListListingSync().run()
}
}
}

@ -0,0 +1,13 @@
package moe.odango.index.cli
import com.github.ajalt.clikt.core.CliktCommand
import kotlinx.coroutines.runBlocking
import moe.odango.index.sync.MyAnimeListPageSync
class MyAnimeListPageSync : CliktCommand(name = "mal:sync-page") {
override fun run() {
runBlocking {
MyAnimeListPageSync().run()
}
}
}

@ -0,0 +1,8 @@
package moe.odango.index.config
data class DatabaseConfiguration(
val uri: String,
val driver: String = "sqlite",
val username: String? = null,
val password: String? = null
)

@ -0,0 +1,8 @@
package moe.odango.index.config
data class ElasticSearchConfiguration(
val host: String = "localhost:9200",
val index: String = "index_anime",
val replicas: Int = 0,
val shards: Int = 2
)

@ -0,0 +1,6 @@
package moe.odango.index.config
data class IndexConfiguration(
val database: DatabaseConfiguration = DatabaseConfiguration("jdbc:sqlite:index.sqlite"),
val elastic: ElasticSearchConfiguration = ElasticSearchConfiguration()
)

@ -0,0 +1,59 @@
package moe.odango.index
import com.moandjiezana.toml.Toml
import io.inbot.eskotlinwrapper.IndexRepository
import io.requery.Persistable
import io.requery.sql.KotlinConfiguration
import io.requery.sql.KotlinEntityDataStore
import moe.odango.index.config.IndexConfiguration
import moe.odango.index.entity.Models
import moe.odango.index.es.dto.AnimeDTO
import org.apache.http.HttpHost
import org.elasticsearch.client.RestClient
import org.elasticsearch.client.RestHighLevelClient
import org.elasticsearch.client.indexRepository
import org.kodein.di.*
import org.sqlite.SQLiteDataSource
import java.io.File
import javax.sql.CommonDataSource
val di = DI {
bind<RestHighLevelClient>() with provider {
RestHighLevelClient(RestClient.builder(HttpHost.create(instance<IndexConfiguration>().elastic.host)))
}
bind<IndexRepository<AnimeDTO>>() with provider {
instance<RestHighLevelClient>().indexRepository<AnimeDTO>(instance<IndexConfiguration>().elastic.index)
}
bind<KotlinConfiguration>() with singleton { KotlinConfiguration(Models.DEFAULT, dataSource = instance()) }
bind<KotlinEntityDataStore<Persistable>>() with singleton { KotlinEntityDataStore<Persistable>(instance()) }
constant(tag = "config-file") with mutableListOf(
"/etc/index/config.toml",
System.getenv("HOME") + "/.index/config.toml"
)
bind<IndexConfiguration>() with singleton {
val toml = instance<MutableList<String>>("config-file")
.find { File(it).isFile }
?.let { File(it).readText(Charsets.UTF_8) }
toml?.let {
Toml()
.read(toml)
.to(IndexConfiguration::class.java)
} ?: IndexConfiguration()
}
bind<CommonDataSource>() with singleton {
val config: IndexConfiguration = instance()
when (config.database.driver) {
"sqlite" -> SQLiteDataSource().apply {
url = config.database.uri
}
else -> throw RuntimeException("Database driver '${config.database.driver}' not recognized")
}
}
}

@ -0,0 +1,58 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface AniDBInfo : Persistable {
@get:Key
val id: UUID
@get:OneToOne
@get:ForeignKey
val anime: Anime
var type: ReleaseType
var restricted: Boolean
var episodes: Int?
var description: String
var image: String?
var startYear: Int?
var startMonth: Int?
var startDay: Int?
var endYear: Int?
var endMonth: Int?
var endDay: Int?
enum class ReleaseType {
Movie,
MusicVideo,
OVA,
Other,
TVSeries,
TVSpecial,
Web,
Unknown;
companion object {
fun fromAniDBString(value: String): ReleaseType = when (value.toLowerCase()) {
"movie" -> Movie
"music video" -> MusicVideo
"ova" -> OVA
"other" -> Other
"tv series" -> TVSeries
"tv special" -> TVSpecial
"web" -> Web
else -> Unknown
}
}
}
companion object : EntityHelper<AniDBInfoEntity> by helper(::AniDBInfoEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,50 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface Anime : Persistable {
@get:Key
val id: UUID
@get:Index
var aniDbId: Long?
@get:OneToOne
val aniDbInfo: AniDBInfo?
@get:OneToMany(mappedBy = "anime")
val titles: List<Title>
@get:Index
var myAnimeListId: Long?
@get:ManyToMany
@get:JunctionTable(type = AnimeProducer::class)
val producers: List<Producer>
@get:OneToOne
val myAnimeListInfo: MyAnimeListInfo?
@get:OneToMany(mappedBy = "anime_from")
val relatedTo: List<AnimeRelation>
@get:OneToMany(mappedBy = "anime_to")
val relatedFrom: List<AnimeRelation>
@get:OneToMany(mappedBy = "anime")
val genres: List<AnimeGenre>
@get:ManyToOne
@get:ForeignKey
var series: AnimeSeries?
val replacedWith: UUID?
companion object : EntityHelper<AnimeEntity> by helper(::AnimeEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,27 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.InfoSource
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface AnimeGenre : Persistable {
@get:Key
val id: UUID
@get:ManyToOne
@get:ForeignKey
val anime: Anime
@get:ManyToOne
@get:ForeignKey
val genre: Genre
val source: InfoSource
companion object : EntityHelper<AnimeGenreEntity> by helper(::AnimeGenreEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,28 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.InfoSource
import moe.odango.index.utils.ProducerFunction
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface AnimeProducer : Persistable {
@get:Key
val id: UUID
@get:ManyToOne
val anime: Anime
@get:ManyToOne
val producer: Producer
@get:Column(name = "producer_function")
var function: ProducerFunction
val source: InfoSource
companion object : EntityHelper<AnimeProducerEntity> by helper(::AnimeProducerEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,82 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.InfoSource
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface AnimeRelation : Persistable {
@get:Key
val id: UUID
@get:ManyToOne
@get:ForeignKey
@get:Column(name = "anime_from")
val from: Anime
@get:ManyToOne
@get:ForeignKey
@get:Column(name = "anime_to")
val to: Anime
var relation: RelationType
val source: InfoSource
companion object : EntityHelper<AnimeRelationEntity> by helper(::AnimeRelationEntity, {
setId(UUID.randomUUID())
})
enum class RelationType {
Prequel,
Sequel,
SideStory,
FullStory,
ParentStory,
Summary,
SpinOff,
AlternateVersion,
AlternateSetting,
SameSetting,
Other,
Character;
val inverse: RelationType
get() {
return when (this) {
Prequel -> Sequel
Sequel -> Prequel
Summary -> FullStory
FullStory -> Summary
SideStory -> ParentStory
SpinOff -> ParentStory
ParentStory -> SideStory
AlternateSetting -> AlternateSetting
AlternateVersion -> AlternateVersion
SameSetting -> SameSetting
Other -> Other
Character -> Character
}
}
companion object {
fun fromString(type: String): RelationType {
return when (type.toLowerCase()) {
"alternate setting" -> AlternateSetting
"alternate version" -> AlternateVersion
"prequel" -> Prequel
"sequel" -> Sequel
"spin off" -> SpinOff
"summary" -> Summary
"side story" -> SideStory
"full story" -> FullStory
"parent story" -> ParentStory
"character" -> Character
"same setting" -> SameSetting
else -> Other
}
}
}
}
}

@ -0,0 +1,20 @@
package moe.odango.index.entity
import io.requery.Entity
import io.requery.Key
import io.requery.Persistable
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface AnimeSeries : Persistable {
@get:Key
val id: UUID
val name: String?
var replacedWith: UUID?
companion object : EntityHelper<AnimeSeriesEntity> by helper(::AnimeSeriesEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,27 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.InfoSource
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface AnimeTag : Persistable {
@get:Key
val id: UUID
@get:ManyToOne
@get:ForeignKey
val anime: Anime
@get:ManyToOne
@get:ForeignKey
val tag: Tag
val source: InfoSource
val spoiler: Boolean
companion object : EntityHelper<AnimeTagEntity> by helper(::AnimeTagEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,21 @@
package moe.odango.index.entity
import io.requery.Entity
import io.requery.Key
import io.requery.Persistable
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.InfoSource
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface Genre : Persistable {
@get:Key
val id: UUID
val myAnimeListId: Int
val name: String
companion object : EntityHelper<GenreEntity> by helper(::GenreEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,89 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.scraper.mal.AnimePageScraper
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.ISOTextConverter
import moe.odango.index.utils.helper
import moe.odango.index.utils.toIntDate
import org.joda.time.DateTime
import java.sql.Date
import java.util.*
@Entity
interface MyAnimeListInfo : Persistable {
@get:Key
val id: UUID
@get:OneToOne
@get:ForeignKey
val anime: Anime
var releaseType: ReleaseType
var episodes: Int?
@get:Convert(ISOTextConverter::class)
var lastScrape: DateTime?
var rating: Rating?
var image: String?
var description: String?
var source: String?
var airedStart: Date?
var airedEnd: Date?
var premieredSeason: String?
var premieredYear: Int?
var duration: Int?
@get:Transient
val aired: AnimePageScraper.Aired?
get() = airedStart?.let { AnimePageScraper.Aired(it.toIntDate(), airedEnd?.toIntDate()) }
@get:Transient
val premiered: AnimePageScraper.Premiered?
get() = premieredSeason
?.let(AnimePageScraper.Premiered.Season.Companion::fromString)
?.let { s ->
premieredYear?.let { y ->
AnimePageScraper.Premiered(s, y)
}
}
enum class Rating {
G,
PG,
PG13,
R,
RPlus,
Rx;
companion object {
fun fromString(rating: String): Rating? = when (rating) {
"G" -> G
"PG" -> PG
"PG-13" -> PG13
"R" -> R
"R+" -> RPlus
"Rx" -> Rx
else -> null
}
}
}
enum class ReleaseType {
TV,
ONA,
OVA,
Movie,
Special,
Music,
Unknown
}
companion object : EntityHelper<MyAnimeListInfoEntity> by helper(::MyAnimeListInfoEntity, {
setId(UUID.randomUUID())
episodes = null
releaseType = ReleaseType.Unknown
lastScrape = null
})
}

@ -0,0 +1,22 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface Producer : Persistable {
@get:Key
val id: UUID
val myAnimeListId: Int?
val name: String
@get:ManyToMany
val animes: List<Anime>
companion object : EntityHelper<ProducerEntity> by helper(::ProducerEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,25 @@
package moe.odango.index.entity
import io.requery.Entity
import io.requery.Key
import io.requery.Persistable
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface Tag : Persistable {
@get:Key
val id: UUID
val aniDbId: Long?
val parentId: UUID
val name: String
val description: String
val spoiler: Boolean
companion object : EntityHelper<TagEntity> by helper(::TagEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,63 @@
package moe.odango.index.entity
import io.requery.*
import moe.odango.index.utils.EntityHelper
import moe.odango.index.utils.InfoSource
import moe.odango.index.utils.helper
import java.util.*
@Entity
interface Title : Persistable {
@get:Key
val id: UUID
@get:Index
var name: String
@get:ManyToOne
@get:ForeignKey
val anime: Anime
val source: InfoSource
@get:Column(name = "lang")
val language: String?
val type: TitleType
var hidden: Boolean
enum class TitleType {
Synonym,
TitleCard,
Kana,
Short,
Official,
Main;
fun toAniDBString() = when (this) {
Main -> "main"
Synonym -> "syn"
TitleCard -> "card"
Kana -> "kana"
Short -> "short"
Official -> "official"
}
companion object {
fun fromAniDBString(type: String) = when (type) {
"card" -> TitleCard
"official" -> Official
"short" -> Short
"syn" -> Synonym
"kana" -> Kana
"main" -> Main
else -> Synonym
}
}
}
companion object : EntityHelper<TitleEntity> by helper(::TitleEntity, {
setId(UUID.randomUUID())
})
}

@ -0,0 +1,141 @@
package moe.odango.index.es
import io.inbot.eskotlinwrapper.IndexRepository
import io.requery.Persistable
import io.requery.kotlin.invoke
import io.requery.sql.KotlinEntityDataStore
import moe.odango.index.config.IndexConfiguration
import moe.odango.index.di
import moe.odango.index.entity.Anime
import moe.odango.index.es.dto.AnimeDescriptionDTO
import moe.odango.index.es.dto.AnimeTitleDTO
import moe.odango.index.utils.InfoSource
import org.elasticsearch.client.RequestOptions
import org.elasticsearch.client.RestHighLevelClient
import org.elasticsearch.client.configure
import org.elasticsearch.client.indices.GetIndexRequest
import org.kodein.di.instance
import moe.odango.index.es.dto.AnimeDTO as AnimeDTO
class Indexer {
private val indexRepo by di.instance<IndexRepository<AnimeDTO>>()
private val indexConfig by di.instance<IndexConfiguration>()
private val client by di.instance<RestHighLevelClient>()
private val config by lazy { indexConfig.elastic }
private val entityStore by di.instance<KotlinEntityDataStore<Persistable>>()
fun run() {
createIndex()
index()
client.close()
}
fun createIndex() {
val indexExists = client.indices()
.exists(GetIndexRequest(config.index), RequestOptions.DEFAULT);
if (indexExists)
return
indexRepo.createIndex {
configure {
settings {
replicas = config.replicas
shards = config.shards
addTokenizer("autocomplete") {
this["type"] = "edge_ngram"
this["min_gram"] = 2
this["max_gram"] = 10
this["token_chars"] = listOf("letter")
}
addAnalyzer("autocomplete") {
this["tokenizer"] = "autocomplete"
this["filter"] = listOf("lowercase")
}
addAnalyzer("autocomplete_search") {
this["tokenizer"] = "lowercase"
}
}
mappings {
nestedField("title") {
text("name") {
analyzer = "autocomplete"
searchAnalyzer = "autocomplete_search"
}
}
nestedField("description") {
text("text") {
analyzer = "standard"
searchAnalyzer = "standard"
}
}
keyword("genre")
objField("premiered") {
keyword("season")
number<Int>("year")
}
objField("aired") {
field("start", "date")
field("end", "date")
}
}
}
}
}
fun index() {
var i = 0;
entityStore {
val q = select(Anime::class)
indexRepo.bulk(50) {
for (item in q()) {
if (item.replacedWith != null) continue;
i++
if (i % 1_000 == 0) {
println(" => $i - ${item.id}")
}
index(
item.id.toString(),
AnimeDTO(
item.id,
item.titles.map {
AnimeTitleDTO(
it.name,
it.language,
it.type.toString(),
it.source
)
},
item.myAnimeListInfo?.let {
it.description?.let { desc ->
listOf(
AnimeDescriptionDTO(
desc,
InfoSource.MyAnimeList
)
)
}
} ?: listOf(),
item.genres.map { it.genre.name }.toSet().toList(),
item.myAnimeListInfo?.premiered,
item.myAnimeListInfo?.aired
),
false
)
}
}
}
println(" => Indexed $i entries.")
}
}

@ -0,0 +1,13 @@
package moe.odango.index.es.dto
import moe.odango.index.scraper.mal.AnimePageScraper
import java.util.*
data class AnimeDTO(
val id: UUID,
val title: Collection<AnimeTitleDTO>,
val description: Collection<AnimeDescriptionDTO>,
val genre: Collection<String>,
val premiered: AnimePageScraper.Premiered?,
val aired: AnimePageScraper.Aired?
)

@ -0,0 +1,8 @@
package moe.odango.index.es.dto
import moe.odango.index.utils.InfoSource
data class AnimeDescriptionDTO(
val text: String,
val source: InfoSource
)

@ -0,0 +1,10 @@
package moe.odango.index.es.dto
import moe.odango.index.utils.InfoSource
data class AnimeTitleDTO(
val name: String,
val language: String?,
val type: String,
val source: InfoSource
)

@ -0,0 +1,41 @@
package moe.odango.index.http
import com.expediagroup.graphql.SchemaGeneratorConfig
import com.expediagroup.graphql.TopLevelObject
import com.expediagroup.graphql.toSchema
import io.ktor.http.content.resource
import io.ktor.http.content.resources
import io.ktor.http.content.static
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import ktor.graphql.GraphQLRouteConfig
import ktor.graphql.graphQL
import moe.odango.index.http.graphql.AnimeService
class Server {
fun run() {
embeddedServer(Netty, port = 3336) {
routing {
graphQL("/graphql",toSchema(
SchemaGeneratorConfig(listOf("moe.odango.index")),
listOf(TopLevelObject(AnimeService()))
)) {
GraphQLRouteConfig(
graphiql = true
)
}
static {
resource("/", "web/index.html")
static("/") {
resources("web")
}
}
}
}
.start(true)
}
}

@ -0,0 +1,100 @@
package moe.odango.index.http.graphql
import io.inbot.eskotlinwrapper.IndexRepository
import io.inbot.eskotlinwrapper.dsl.*
import moe.odango.index.di
import moe.odango.index.entity.Title
import moe.odango.index.es.dto.AnimeDTO
import moe.odango.index.http.graphql.dto.AnimeItem
import moe.odango.index.http.graphql.dto.AnimeTitleItem
import moe.odango.index.http.graphql.dto.AutoCompleteItem
import moe.odango.index.utils.ScoreMode
import moe.odango.index.utils.nested
import org.elasticsearch.action.search.dsl
import org.elasticsearch.action.search.source
import org.elasticsearch.client.RestHighLevelClient
import org.elasticsearch.common.unit.Fuzziness
import org.elasticsearch.search.suggest.SuggestBuilder
import org.elasticsearch.search.suggest.completion.CompletionSuggestionBuilder
import org.kodein.di.instance
class AnimeService {
private val es by di.instance<IndexRepository<AnimeDTO>>()
private val client by di.instance<RestHighLevelClient>()
fun autocomplete(title: String): List<AutoCompleteItem> {
return es.search {
source {
suggest(
SuggestBuilder().addSuggestion(
"title",
CompletionSuggestionBuilder("title.name").prefix(title, Fuzziness.AUTO)
)
)
}
}
.hits
.mapNotNull { (s, item) ->
AutoCompleteItem(
s.innerHits["title"]?.hits?.get(0)?.sourceAsMap?.get("name")?.toString() ?: return@mapNotNull null,
item?.id.toString() ?: return@mapNotNull null
)
}
.toList()
}
fun search(
title: String? = null,
description: String? = null,
genre: List<String>? = null
): List<AnimeItem> {
val items = mutableListOf<ESQuery>()
title?.let {
items.add(
nested("title") {
query = MatchQuery("title.name", it)
scoreMode = ScoreMode.max
}
)
}
genre?.let {
items.add(
bool {
must(*it.toSet().map { genre -> TermQuery("genre", genre) }.toTypedArray())
}
)
}
description?.let {
items.add(
nested("description") {
query = MatchQuery("description.name", it)
scoreMode = ScoreMode.max
}
)
}
return es.search {
dsl {
query = disMax {
queries(*items.toTypedArray())
}
}
}
.hits
.mapNotNull { (_, dto) ->
dto?.let {
AnimeItem(
it.id.toString(),
it.title.map { dto ->
AnimeTitleItem(dto.name, dto.language, dto.type.let(Title.TitleType::valueOf), dto.source)
},
it.genre.toSet().toList()
)
}
}
.toList()
}
}

@ -0,0 +1,7 @@
package moe.odango.index.http.graphql.dto
data class AnimeItem(
val id: String,
val titles: List<AnimeTitleItem>,
val genres: List<String>
)

@ -0,0 +1,11 @@
package moe.odango.index.http.graphql.dto
import moe.odango.index.entity.Title
import moe.odango.index.utils.InfoSource
data class AnimeTitleItem(
val name: String,
val language: String?,
val type: Title.TitleType,
val source: InfoSource
)

@ -0,0 +1,6 @@
package moe.odango.index.http.graphql.dto
data class AutoCompleteItem(
val title: String,
val id: String
)

@ -0,0 +1,30 @@
package moe.odango.index
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.option
import moe.odango.index.cli.*
import org.kodein.di.instance
fun main(args: Array<String>) {
object : CliktCommand() {
val config: String? by option()
init {
subcommands(
AniDBSync(),
DatabaseMigration(),
MyAnimeListListingSync(),
MyAnimeListPageSync(),
HTTPServer(),
ElasticIndex()
)
}
override fun run() {
val configFile by di.instance<MutableList<String>>("config-file")
config?.let { configFile.add(0, it) }
}
}
.main(args)
}

@ -0,0 +1,33 @@
package moe.odango.index.scraper.mal
import moe.odango.index.entity.MyAnimeListInfo
import org.jsoup.Jsoup
import java.net.URI
class AnimeListScraper(body: String) {
private val dom = Jsoup.parse(body)
data class MyAnimeListListingItem(val myAnimeListId: Long, val title: String, val type: MyAnimeListInfo.ReleaseType, val episodes: Int?)
fun getItems(): List<MyAnimeListListingItem> {
val arr = dom
.select("[id=content] .list tbody tr")
.toList()
return arr.drop(1).mapNotNull {
val link = it.select("a[id^=sinfo]").first()
val id = link
.attr("href")
.split("/")
.let {
it[it.indexOf("anime") + 1].toLongOrNull()
}
val title = link.text()
val type = it.child(2).text()
val eps = it.child(3).text().toIntOrNull()
id?.let { MyAnimeListListingItem(id, title, MyAnimeListInfo.ReleaseType.valueOf(type), eps) }
}
}
}

@ -0,0 +1,246 @@
package moe.odango.index.scraper.mal
import moe.odango.index.entity.AnimeRelation
import moe.odango.index.entity.MyAnimeListInfo
import moe.odango.index.utils.IntDate
import moe.odango.index.utils.ProducerFunction
import moe.odango.index.utils.brText
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.time.Duration
import java.util.*
class AnimePageScraper(body: String) {
private val dom = Jsoup.parse(body)
private val items: MutableMap<String, Pair<String, Element>> = mutableMapOf()
data class Info(
val title: String,
val englishName: String?,
val japaneseName: String?,
val synonyms: List<String>,
val description: String,
val type: MyAnimeListInfo.ReleaseType,
val episodes: Int?,
val source: String?,
val image: String?,
val genres: List<Genre>,
val aired: Aired?,
val premiered: Premiered?,
val rating: MyAnimeListInfo.Rating?,
val duration: Duration?,
val related: List<Relation>,
val producers: List<ProducerRelation>
)
fun getInfo() = Info(
getTitle(),
getEnglishName(),
getJapaneseName(),
getSynonyms(),
getDescription(),
getReleaseType(),
getEpisodes(),
getSource(),
getImage(),
getGenres(),
getAired(),
getPremiered(),
getRating(),
getDuration(),
getRelated(),
getProducers()
)
fun getEpisodes(): Int? {
return items["episodes"]?.first?.toIntOrNull()
}
fun getReleaseType(): MyAnimeListInfo.ReleaseType {
return MyAnimeListInfo.ReleaseType.valueOf(items["type"]!!.first)
}
fun getTitle(): String {
return dom.select(".h1-title span[itemprop=name]").first().brText().split("\n").first()
}
fun getSynonyms(): List<String> {
// If the english name or title contains a comma we can't parse synonyms since they're splice by comma's
return if (getEnglishName()?.contains(",") == true || getTitle().contains(",")) listOf() else items["synonyms"]?.first?.split(
","
)?.map { it.trim() } ?: listOf()
}
fun getJapaneseName(): String? {
return items["japanese"]?.first
}
fun getEnglishName(): String? {
return items["english"]?.first
}
init {
val after = dom
.select(".dark_text")
.parents()
for (item in after) {
val parts = item.text().split(":", limit = 2)
val key = parts.first().trim().toLowerCase()
val value = parts.last().trim()
items[key] = value to item
}
}
fun getSource(): String? {
return items["source"]?.first
}
fun getImage(): String? {
return dom.selectFirst("img[itemprop=image]")?.attr("data-src")
}
fun getDescription(): String {
return dom.selectFirst("[itemprop=description]")?.brText() ?: ""
}
fun getAired(): Aired? {
return items["aired"]?.let {
val (from, to) = it.first.split(" to ", limit = 2)
.let { part -> part.first() to part.getOrNull(1) }
return Aired(IntDate.parse("MMM d, yyyy", from, Locale.US) ?: return null, to?.let { dateStr ->
IntDate.parse("MMM d, yyyy", dateStr, Locale.US)
})
}
}
data class Aired(val start: IntDate, val end: IntDate? = null)
fun getPremiered(): Premiered? {
val (season, year) = items["premiered"]
?.first
?.split(" ", limit = 2)
?.takeUnless { it.size != 2 }
?: return null
return Premiered(Premiered.Season.fromString(season) ?: return null, year.toIntOrNull() ?: return null)
}
data class Premiered(val season: Season, val year: Int) {
enum class Season {
Spring,
Summer,
Fall,
Winter;
companion object {
fun fromString(season: String): Season? {
return when (season.toLowerCase()) {
"spring" -> Spring
"summer" -> Summer
"fall" -> Fall
"winter" -> Winter
else -> null
}
}
}
}
}
fun getGenres(): List<Genre> {
return items["genres"]?.second?.let {
it.select("a").map { a ->
val href = a.attr("href").split("/")
Genre(href[href.indexOf("genre") + 1].toInt(), a.text().trim())
}
} ?: emptyList()
}
data class Genre(val id: Int, val name: String) {
companion object {
val COMEDY = Genre(4, "Comedy")
val DEMONS = Genre(6, "Demons")
val DRAMA = Genre(8, "Drama")
val FANTASY = Genre(10, "Fantasy")
val HENTAI = Genre(12, "Hentai")
val MAGIC = Genre(16, "Magic")
val PARODY = Genre(20, "Parody")
val ROMANCE = Genre(22, "Romance")
val SCHOOL = Genre(23, "School")
val SCIFI = Genre(24, "Sci-Fi")
val SHOUJO = Genre(25, "Shoujo")
val HAREM = Genre(35, "Harem")
val MILITARY = Genre(38, "Military")
}
}
fun getRelated(): List<Relation> {
return dom.select("table.anime_detail_related_anime tr").flatMap {
val type = it.child(0).text().trim().replace(":", "")
it.child(1).select("a").mapNotNull { el ->
el.attr("href")
?.let { href ->
val parts = href.split("/")
if (parts.contains("anime")) {
parts[parts.indexOf("anime") + 1].toLongOrNull()
} else {
null
}
}
?.let { id -> Relation(type, id) }
}
}
}
fun getProducers(): List<ProducerRelation> {
fun Pair<String, Element>?.getProducers(): List<Producer> {
return this?.second?.let {
it.select("a").mapNotNull { a ->
val href = a.attr("href").split("/")
if (href.contains("producer")) Producer(
href[href.indexOf("producer") + 1].toInt(),
a.text().trim()
) else null
}
} ?: emptyList()
}
val producers = items["producers"].getProducers()
val studios = items["studios"].getProducers()
val licensors = items["licensors"].getProducers()
return producers.map { ProducerRelation(ProducerFunction.Producer, it) } +
studios.map { ProducerRelation(ProducerFunction.Studio, it) } +
licensors.map { ProducerRelation(ProducerFunction.Licensor, it) }
}
data class ProducerRelation(val function: ProducerFunction, val producer: Producer)
data class Producer(val id: Int, val name: String)
fun getRating(): MyAnimeListInfo.Rating? {
return MyAnimeListInfo.Rating.fromString(items["rating"]?.first?.split(" - ")?.first()?.trim() ?: return null)
}
private val durationRegex = Regex("(?:(\\d+) hrs.)?(?:(\\d+) min.)?")
fun getDuration(): Duration? {
val txt = items["duration"]?.first?.split(" per ", limit = 2)?.firstOrNull() ?: return null
val grps = durationRegex.matchEntire(txt)?.groups ?: return null
val hrs = grps[1]
val min = grps[2]
var x = Duration.ofMillis(0)
x += Duration.ofHours(hrs?.value?.toInt() ?: 0)
x += Duration.ofMinutes(min?.value?.toInt() ?: 0)
return x
}
data class Relation(val type: AnimeRelation.RelationType, val id: Long) {
constructor(type: String, id: Long) : this(AnimeRelation.RelationType.fromString(type), id)
}
}

@ -0,0 +1,178 @@
package moe.odango.index.sync
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
import com.github.kittinunf.fuel.httpDownload
import io.requery.Persistable
import io.requery.kotlin.`in`
import io.requery.kotlin.eq
import io.requery.kotlin.invoke
import io.requery.sql.KotlinEntityDataStore
import moe.odango.index.di
import moe.odango.index.entity.Anime
import moe.odango.index.entity.Title
import moe.odango.index.utils.InfoSource
import moe.odango.index.utils.XMLOutputStreamReader
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.kodein.di.instance
import java.io.File
import java.io.InputStream
import java.util.concurrent.TimeUnit
import java.util.zip.InflaterOutputStream
class AniDBTitleSync : ScheduledSync(1, TimeUnit.DAYS) {
private val entityStore by di.instance<KotlinEntityDataStore<Persistable>>()
data class AniDBTitle(val aid: Long, val name: String, val type: String, val language: String)
override suspend fun run() {
val (feeder, cacheMap) = getFeeder()
"https://anidb.net/api/anime-titles.xml.gz"
.httpDownload()
.streamDestination { _, _ ->
InflaterOutputStream(feeder) to { InputStream.nullInputStream() }
}
.awaitByteArrayResponseResult()
syncTitles(cacheMap)
}
private fun getFeeder(): Pair<XMLOutputStreamReader, MutableMap<Long, MutableList<AniDBTitle>>> {
var animeId: Long = 0;
var language = ""
var type = ""
var title = ""
val cacheMap = mutableMapOf<Long, MutableList<AniDBTitle>>()
return XMLOutputStreamReader {
if (isStartElement) {
if (name.localPart == "anime") {
animeId = getAttributeValue("", "aid").toLong()
}
if (name.localPart == "title") {
title = ""
type = getAttributeValue("", "type")
language = getAttributeValue("http://www.w3.org/XML/1998/namespace", "lang")
}
}
if (isCharacters) {
title += text
}
if (isEndElement) {
if (name.localPart == "title") {
cacheMap
.getOrPut(animeId, ::mutableListOf)
.add(AniDBTitle(animeId, title, type, language))
println("$animeId => [$type/$language] $title")
title = ""
}
if (name.localPart == "anime") {
if (cacheMap.size >= 100) {
syncTitles(cacheMap)
cacheMap.clear()
}
}
}
} to cacheMap
}
fun syncWithFile(file: File) {
val (feeder, cacheMap) = getFeeder()
file
.inputStream()
.let {
if (file.name.endsWith(".gz")) {
GzipCompressorInputStream(it)
} else {
it
}
}
.transferTo(feeder)
syncTitles(cacheMap)
}
private fun syncTitles(cacheMap: Map<Long, List<AniDBTitle>>) {
val ids = cacheMap.keys
val (animes, titles) = entityStore {
val animes = (select(Anime::class) where (Anime::aniDbId `in` ids)).get().toList()
val query = select(Title::class) where (Title::anime `in` animes) and (Title::source eq InfoSource.AniDB)
animes to query().toList()
}
val animeById = animes.associateBy { it.aniDbId!! }
val titlesByAniDBId = titles.groupBy {
it.anime.aniDbId!!
}
val newAnimes = mutableListOf<Pair<Long, List<AniDBTitle>>>()
entityStore.withTransaction {
for ((aniDb, newTitles) in cacheMap) {
val newAniDbTitlesSet = newTitles.map { Triple(it.language, it.type, it.name) }.toMutableSet()
val anime = animeById[aniDb]
if (anime == null) {
newAnimes.add(aniDb to newTitles)
continue
}
val dbTitles = titlesByAniDBId[aniDb] ?: listOf()
for (dbTitle in dbTitles) {
if (!newAniDbTitlesSet.remove(
Triple(
dbTitle.language,
dbTitle.type.toAniDBString(),
dbTitle.name
)
)
) {
// If it wasn't in the set, remove it from the DB
delete(dbTitle)
}
}
for ((language, type, name) in newAniDbTitlesSet) {
val title = Title {
setAnime(anime)
setLanguage(language)
setType(Title.TitleType.fromAniDBString(type))
setSource(InfoSource.AniDB)
this.name = name
}
insert(title)
}
}
for ((aniDb, aniDbTitles) in newAnimes) {
val anime = Anime {
aniDbId = aniDb
}
insert(anime)
for (aniDbTitle in aniDbTitles) {
insert(Title {
setAnime(anime)
setLanguage(aniDbTitle.language)
setType(Title.TitleType.fromAniDBString(aniDbTitle.type))
setSource(InfoSource.AniDB)
name = aniDbTitle.name
})
}
}
}
}
}

@ -0,0 +1,318 @@
package moe.odango.index.sync
import io.requery.Persistable
import io.requery.kotlin.`in`
import io.requery.kotlin.eq
import io.requery.kotlin.invoke
import io.requery.sql.KotlinEntityDataStore
import moe.odango.index.di
import moe.odango.index.entity.*
import moe.odango.index.utils.InfoSource
import moe.odango.index.utils.MergeMap
import moe.odango.index.utils.brText
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser
import org.kodein.di.instance
import java.io.File
import java.util.*
class AniDBXMLSync {
private val entityStore by di.instance<KotlinEntityDataStore<Persistable>>()
private val seriesToMerge = MergeMap<UUID>()
fun run(xmlDir: String) {
val files = File(xmlDir)
.listFiles()
.filter {
it.extension == "xml"
}
for (file in files) {
val xml = file.readText(Charsets.UTF_8)
indexAniDBEntry(xml)
}
}
private fun indexAniDBEntry(xml: String) {
val doc = Jsoup.parse(xml, "", Parser.xmlParser())
val anime = doc.selectFirst("anime")
val aid = anime.attr("id").toLongOrNull() ?: return
val animeEnt: Anime? = entityStore {
val q = select(Anime::class) where (Anime::aniDbId eq aid)
q().firstOrNull()
}
val myAnimeListId = anime
.selectFirst("resource[type=2] externalentity identifier")
?.text()
?.toLongOrNull()
val malAnimeEnt = myAnimeListId?.let {
entityStore {
val q = select(Anime::class) where (Anime::myAnimeListId eq myAnimeListId)
q().firstOrNull()
}
}
if (malAnimeEnt != null && animeEnt != null) {
// WE GOT ISSUES OH NO
TODO()
}
val entity = malAnimeEnt ?: animeEnt ?: Anime {
this.myAnimeListId = myAnimeListId
this.aniDbId = aid
}.let { entityStore.insert(it) }
val aniDbInfo = entity.aniDbInfo ?: AniDBInfo {
setAnime(entity)
}.let { entityStore.insert(it) }
aniDbInfo.restricted = anime.attr("restricted") == "true"
aniDbInfo.description = doc.selectFirst("anime > description")?.brText() ?: ""
aniDbInfo.episodes = doc.selectFirst("anime > episodecount")?.text()?.trim()?.toIntOrNull()
aniDbInfo.type =
doc.selectFirst("anime > type")?.text()?.trim()?.let { AniDBInfo.ReleaseType.fromAniDBString(it) }
?: AniDBInfo.ReleaseType.Unknown
aniDbInfo.image = doc.selectFirst("anime > picture")?.text()?.trim()
val startDate = doc.selectFirst("anime > startdate")?.text()?.trim()?.let {
val items = it.split("-")
if (items.size > 3) {
null
} else {
items.map { item -> item.toIntOrNull() }
}
} ?: emptyList()
aniDbInfo.startYear = startDate[0]
aniDbInfo.startMonth = startDate[1]
aniDbInfo.startDay = startDate[2]
val endDate = doc.selectFirst("anime > enddate")?.text()?.trim()?.let {
val items = it.split("-")
if (items.size > 3) {
null
} else {
items.map { item -> item.toIntOrNull() }
}
} ?: emptyList()
aniDbInfo.endYear = endDate[0]
aniDbInfo.endMonth = endDate[1]
aniDbInfo.endDay = endDate[2]
entityStore.update(aniDbInfo)
val aniDbTitles = entityStore {
val q = select(Title::class) where (Title::anime eq entity) and (Title::source eq InfoSource.AniDB)
q().toList()
}
.associateBy { "${it.type}/${it.language}/${it.name}" }
.toMutableMap()
for (title in doc.select("anime > titles > title")) {
val name = title.text() ?: continue
val lang = title.attr("lang") ?: null
val type = title.attr("type")?.let { Title.TitleType.fromAniDBString(it) } ?: Title.TitleType.Synonym
val handle = "$type/$lang/$name"
if (aniDbTitles.remove(handle) == null) {
entityStore.insert(Title {
setAnime(entity)
setSource(InfoSource.AniDB)
setLanguage(lang)
setType(type)
this.name = name
})
}
}
for ((_, title) in aniDbTitles) {
entityStore.delete(title)
}
val relatedAnimeFrom = entityStore {
val q =
select(AnimeRelation::class) where (AnimeRelation::from eq entity) and (AnimeRelation::source eq InfoSource.AniDB)
q().toList()
}
.associateBy { it.to.aniDbId!! }
.toMutableMap()
val relatedAnimeTo = entityStore {
val q =
select(AnimeRelation::class) where (AnimeRelation::to eq entity) and (AnimeRelation::source eq InfoSource.AniDB)
q().toList()
}
.associateBy { it.from.aniDbId!! }
.toMutableMap()
for (related in doc.select("anime > relatedanime > anime")) {
val id = related.attr("id").toLongOrNull() ?: continue
val relatedAnime = entityStore {
val q = select(Anime::class) where (Anime::aniDbId eq id)
q().firstOrNull()
} ?: Anime { aniDbId = id }.let { entityStore.insert(it) }
val type = related.attr("type")?.let { AnimeRelation.RelationType.fromString(it) }
?: AnimeRelation.RelationType.Other
val currentFromRelation = relatedAnimeFrom.remove(id)
if (currentFromRelation == null) {
entityStore.insert(AnimeRelation {
setFrom(entity)
setTo(relatedAnime)
setSource(InfoSource.AniDB)
relation = type
})
} else if (currentFromRelation.relation != type) {
currentFromRelation.relation = type
entityStore.update(currentFromRelation)
}
val currentToRelation = relatedAnimeTo.remove(id)
if (currentToRelation == null) {
entityStore.insert(AnimeRelation {
setFrom(relatedAnime)
setTo(entity)
setSource(InfoSource.AniDB)
relation = type.inverse
})
}
// Character only have characters of that anime in the other anime
// So are not part of The Series, see e.g. Isekai Quartet
if (type != AnimeRelation.RelationType.Character) {
if (relatedAnime.series != null && entity.series == null) {
entity.series = relatedAnime.series
entityStore.update(entity)
} else if (relatedAnime.series == null && entity.series != null) {
relatedAnime.series = entity.series
entityStore.update(relatedAnime)
} else if (relatedAnime.series == null && entity.series == null) {
val newSeries = AnimeSeries {}
entityStore.insert(newSeries)
entity.series = newSeries
entityStore.update(entity)
relatedAnime.series = newSeries
entityStore.update(relatedAnime)
} else if (relatedAnime.series != null && entity.series != null) {
seriesToMerge.add(relatedAnime.series!!.id, entity.series!!.id)
}
}
}
val tags = entityStore {
val q =
select(AnimeTag::class) join Tag::class on (AnimeTag::tag eq Tag::class) where (AnimeTag::anime eq entity) and (AnimeTag::source eq InfoSource.AniDB)
q().toList()
}
.associateBy { it.tag.aniDbId!! }
.toMutableMap()
val tagIds = doc.select("anime > tags > tag").flatMap {
val ids = mutableSetOf<Long>()
it.attr("id")?.toLongOrNull()?.let(ids::add)
it.attr("parentid")?.toLongOrNull()?.let(ids::add)
ids
}
.toSet()
val tagsById = entityStore {
val q = select(Tag::class) where (Tag::aniDbId `in` tagIds)
q().toList()
}
.associateBy { it.aniDbId!! }
.toMutableMap()
val after = mutableListOf<Element>()
fun getTag(tag: Element): Tag {
val tagId = tag.attr("id")?.toLongOrNull()!!
val name = tag.selectFirst("name")?.text()?.trim()!!
val description = tag.selectFirst("description")?.brText() ?: ""
val parentId = tag.attr("parentid")?.toLongOrNull()
return tagsById.getOrPut(tagId) {
val newTag = Tag {
setAniDbId(tagId)
setName(name)
setDescription(description)
setSpoiler(tag.attr("globalspoiler") == "true")
parentId?.let {
setParentId(tagsById[it]!!.id)
}
}
entityStore.insert(newTag)
newTag
}
}
fun ensureTag(tag: Element, tagId: Long) {
if (tags.remove(tagId) == null) {
val tagEntity = getTag(tag)
val animeTag = AnimeTag {
setTag(tagEntity)
setAnime(entity)
setSpoiler(tag.attr("localspoiler") == "true")
setSource(InfoSource.AniDB)
}
entityStore.insert(animeTag)
}
}
for (tag in doc.select("anime > tags > tag")) {
val tagId = tag.attr("id")?.toLongOrNull() ?: continue
val name = tag.selectFirst("name")?.text()?.trim() ?: continue
val parentId = tag.attr("parentid")?.toLongOrNull()
if (parentId != null && !tagsById.contains(parentId)) {
after.add(tag)
continue
}
ensureTag(tag, tagId)
}
for (tag in after) {
val tagId = tag.attr("id")?.toLongOrNull() ?: continue
ensureTag(tag, tagId)
}
for ((_, tag) in tags) {
entityStore.delete(tag)
}
}
fun mergeSeries() {
for (coll in seriesToMerge) {
val items = coll.toMutableSet()
val first = coll.first()
items.remove(first)
entityStore {
val u = update(AnimeSeries::class)
.set(AnimeSeriesEntity.REPLACED_WITH, first)
.where((AnimeSeries::id `in` items) or (AnimeSeries::replacedWith `in` items))
u()
val u2 = update(Anime::class)
.set(AnimeEntity.SERIES_ID, first)
.where(Anime::series `in` items)
u2()
}
}
}
}

@ -0,0 +1,122 @@
package moe.odango.index.sync
import com.github.kittinunf.fuel.coroutines.awaitStringResult
import com.github.kittinunf.fuel.httpGet
import io.requery.Persistable
import io.requery.kotlin.`in`
import io.requery.kotlin.eq
import io.requery.kotlin.invoke
import io.requery.sql.KotlinEntityDataStore
import moe.odango.index.di
import moe.odango.index.entity.Anime
import moe.odango.index.entity.MyAnimeListInfo
import moe.odango.index.entity.Title
import moe.odango.index.scraper.mal.AnimeListScraper
import moe.odango.index.utils.InfoSource
import org.kodein.di.instance
import java.util.concurrent.TimeUnit
class MyAnimeListListingSync : ScheduledSync(2, TimeUnit.DAYS) {
val entityStore: KotlinEntityDataStore<Persistable> by di.instance()
override suspend fun run() {
var offset = 0
do {
println(" => MAL Offset $offset")
val items = getListing(offset)
val animes = entityStore {
val q = select(Anime::class) where (Anime::myAnimeListId `in` items.map { it.myAnimeListId })
q().toList()
}.associateBy {
it.myAnimeListId!!
}
val infos = entityStore {
val q =
select(MyAnimeListInfo::class) join Anime::class on (MyAnimeListInfo::anime `in` animes.values)
q().toList()
}.associateBy {
it.anime.myAnimeListId!!
}
val titles = entityStore {
val q =
select(Title::class) where (Title::source eq InfoSource.MyAnimeList) and (Title::language eq "en") and (Title::anime `in` animes.values)
q().toList()
}.associateBy {
it.anime.myAnimeListId!!
}
entityStore.withTransaction {
for (item in items) {
println("Syncing MAL#${item.myAnimeListId} ${item.title}")
val anime = animes[item.myAnimeListId] ?: run {
val newAnime = Anime {
myAnimeListId = item.myAnimeListId
}
insert(newAnime)
newAnime
}
val title = titles[item.myAnimeListId] ?: run {
val newTitle = Title {
setAnime(anime)
setLanguage("en")
setType(Title.TitleType.Official)
setSource(InfoSource.MyAnimeList)
name = item.title
}
insert(newTitle)
newTitle
}
if (title.name != item.title) {
title.name = item.title
update(title)
}
val info = infos[item.myAnimeListId] ?: run {
val newInfo = MyAnimeListInfo {
setAnime(anime)
episodes = item.episodes
releaseType = item.type
}
insert(newInfo)
newInfo
}
if (info.episodes != item.episodes || info.releaseType != item.type) {
info.episodes = item.episodes
info.releaseType = item.type
update(info)
}
}
}
offset += items.size
} while (items.size == 50)
}
suspend fun getListing(offset: Int): List<AnimeListScraper.MyAnimeListListingItem> {
val url = "https://myanimelist.net/anime.php?o=9&c[0]=a&c[1]=b&cv=2&w=1&show=$offset"
val body = url
.httpGet()
.awaitStringResult()
.get()
return AnimeListScraper(body).getItems()
}
}

@ -0,0 +1,327 @@
package moe.odango.index.sync
import com.github.kittinunf.fuel.coroutines.awaitString
import com.github.kittinunf.fuel.httpGet
import io.requery.Persistable
import io.requery.kotlin.*
import io.requery.query.function.Random
import io.requery.sql.KotlinEntityDataStore
import moe.odango.index.di
import moe.odango.index.entity.*
import moe.odango.index.scraper.mal.AnimePageScraper
import moe.odango.index.utils.InfoSource
import org.joda.time.DateTime
import org.kodein.di.instance
import java.time.Duration
import java.time.Instant
import java.util.concurrent.TimeUnit
import kotlin.math.max
class MyAnimeListPageSync : ScheduledSync(1, TimeUnit.DAYS) {
val entityStore: KotlinEntityDataStore<Persistable> by di.instance()
override suspend fun run() {
val weekAgo = DateTime((Instant.now() - Duration.ofDays(7)).toEpochMilli())
val infos = entityStore {
val q =
select(MyAnimeListInfo::class) where (MyAnimeListInfo::lastScrape lt weekAgo) or (MyAnimeListInfo::lastScrape.isNull()) orderBy (Random()) limit 300
q().toList()
}
val animes = infos.map { it.anime }
val animeById = animes.associateBy { it.id }
val titles = entityStore {
val q = select(Title::class) where (Title::anime `in` animes) and (Title::source eq InfoSource.MyAnimeList)
q().toList()
}
val genres = entityStore {
val q =
select(AnimeGenre::class) where (AnimeGenre::anime `in` animes) and (AnimeGenre::source eq InfoSource.MyAnimeList)
q().toList()
}.groupBy {
it.anime.id
}.mapValues { entry ->
entry.value.associateBy { it.genre.myAnimeListId }.toMutableMap()
}
val fromRelations = entityStore {
val q =
select(AnimeRelation::class) where (AnimeRelation::from `in` animes) and (AnimeRelation::source eq InfoSource.MyAnimeList)
q().toList()
}
.groupBy { it.from.id }
.mapValues { it.value.associateBy { it.from.myAnimeListId!! }.toMutableMap() }
val toRelations = entityStore {
val q =
select(AnimeRelation::class) where (AnimeRelation::source eq InfoSource.MyAnimeList) and (AnimeRelation::to `in` animes)
q().toList()
}
.groupBy { it.to.id }
.mapValues { it.value.associateBy { it.to.myAnimeListId!! }.toMutableMap() }
val producers = entityStore {
val q =
select(AnimeProducer::class) where (AnimeProducer::anime `in` animes) and (AnimeProducer::source eq InfoSource.MyAnimeList)
q().toList()
}
.groupBy { it.anime.id }
.mapValues { it.value.associateBy { it.producer.myAnimeListId!! }.toMutableMap() }
val titlesByMyAnimeListId = mutableMapOf<Long, MutableMap<String, Title>>()
for (title in titles) {
titlesByMyAnimeListId
.getOrPut(title.anime.myAnimeListId!!, ::mutableMapOf)[title.name] = title
}
val bodies = mutableMapOf<Long, String>()
for (info in infos) {
try {
val myAnimeListId = info.anime.myAnimeListId!!
println("=> Fetching MAL Page: $myAnimeListId")
val body = "https://myanimelist.net/anime/$myAnimeListId/"
.httpGet()
.awaitString()
bodies[myAnimeListId] = body
} catch (t: Throwable) {
t.printStackTrace()
}
}
val allGenres = mutableMapOf<Int, Genre>()
val allProducers = mutableMapOf<Int, Producer>()
val seriesToMerge = mutableListOf<Pair<AnimeSeries, AnimeSeries>>()
entityStore.withTransaction {
fun getGenre(genre: AnimePageScraper.Genre): Genre {
return allGenres.getOrPut(genre.id) {
val gq = select(Genre::class) where (Genre::myAnimeListId eq genre.id)
gq().firstOrNull() ?: run {
val gen = Genre {
setMyAnimeListId(genre.id)
setName(genre.name)
}
insert(gen)
gen
}
}
}
fun getProducer(producer: AnimePageScraper.Producer): Producer {
return allProducers.getOrPut(producer.id) {
val pq = select(Producer::class) where (Producer::myAnimeListId eq producer.id)
pq().firstOrNull() ?: run {
val prod = Producer {
setMyAnimeListId(producer.id)
setName(producer.name)
}
insert(prod)
prod
}
}
}
for (info in infos) {
val myAnimeListId = info.anime.myAnimeListId!!
println("=> Indexing MAL Page: $myAnimeListId")
val body = bodies[myAnimeListId] ?: continue
val currentTitles = titlesByMyAnimeListId[myAnimeListId] ?: mutableMapOf()
val scraper = AnimePageScraper(body)
val aired = scraper.getAired()
val premiered = scraper.getPremiered()
info.airedEnd = aired?.end?.toDate()
info.airedStart = aired?.start?.toDate()
info.premieredSeason = premiered?.season?.name
info.premieredYear = premiered?.year
info.releaseType = scraper.getReleaseType()
info.image = scraper.getImage()
info.source = scraper.getSource()
info.description = scraper.getDescription()
info.episodes = scraper.getEpisodes()
info.rating = scraper.getRating()
info.duration = scraper.getDuration()?.toSeconds()?.toInt()?.let { max(it, 0) }
info.lastScrape = DateTime.now()
update(info)
val done = mutableSetOf<String>()
val title = scraper.getTitle()
if (currentTitles.remove(title) == null && !done.contains(title)) {
insert(Title {
setAnime(info.anime)
name = title
setType(Title.TitleType.Main)
setLanguage("x-jat")
setSource(InfoSource.MyAnimeList)
})
}
done.add(title)
val englishName = scraper.getEnglishName()
if (englishName != null && currentTitles.remove(englishName) == null && !done.contains(englishName)) {
insert(Title {
setAnime(info.anime)
name = englishName
setType(Title.TitleType.Official)
setLanguage("en")
setSource(InfoSource.MyAnimeList)
})
}
englishName?.let(done::add)
val japaneseName = scraper.getJapaneseName()
if (japaneseName != null && currentTitles.remove(japaneseName) == null && !done.contains(japaneseName)) {
insert(Title {
setAnime(info.anime)
name = japaneseName
setType(Title.TitleType.Official)
setLanguage("ja")
setSource(InfoSource.MyAnimeList)
})
}
japaneseName?.let(done::add)
val synonyms = scraper.getSynonyms()
for (synonym in synonyms) {
if (currentTitles.remove(synonym) == null && !done.contains(synonym)) {
insert(Title {
setAnime(info.anime)
name = synonym
setType(Title.TitleType.Synonym)
setLanguage("x-jat")
setSource(InfoSource.MyAnimeList)
})
}
done.add(synonym)
}
for ((_, currentTitle) in currentTitles) {
delete(currentTitle)
}
val currentGenres = genres[info.anime.id] ?: mutableMapOf()
for (genre in scraper.getGenres()) {
if (currentGenres.remove(genre.id) == null) {
val genreEnt = getGenre(genre)
insert(AnimeGenre {
setAnime(info.anime)
setGenre(genreEnt)
setSource(InfoSource.MyAnimeList)
})
}
}
for ((_, genreEnt) in currentGenres) {
delete(genreEnt)
}
val currentFromRelations = fromRelations[info.anime.id] ?: mutableMapOf()
val currentToRelations = toRelations[info.anime.id] ?: mutableMapOf()
val related = scraper.getRelated()
val relatedAnimesQuery = select(Anime::class) where (Anime::myAnimeListId `in` related.map { it.id })
val relatedAnimes = relatedAnimesQuery().toList().associateBy { it.myAnimeListId!! }.toMutableMap()
for (relation in related) {
val currentFromRelation = currentFromRelations.remove(relation.id)
val relatedAnime = relatedAnimes.getOrPut(relation.id) {
val anim = Anime {
this@Anime.myAnimeListId = relation.id
}
insert(anim)
anim
}
if (currentFromRelation == null) {
insert(AnimeRelation {
setFrom(info.anime)
setTo(relatedAnime)
setSource(InfoSource.MyAnimeList)
this@AnimeRelation.relation = relation.type
})
} else if (currentFromRelation.relation != relation.type) {
currentFromRelation.relation = relation.type
update(currentFromRelation)
}
val currentToRelation = currentToRelations.remove(relation.id)
if (currentToRelation == null) {
insert(AnimeRelation {
setTo(info.anime)
setFrom(relatedAnime)
setSource(InfoSource.MyAnimeList)
this@AnimeRelation.relation = relation.type.inverse
})
}
// Character only have characters of that anime in the other anime
// So are not part of The Series, see e.g. Isekai Quartet
if (relation.type != AnimeRelation.RelationType.Character) {
val currAnime = info.anime
if (relatedAnime.series != null && currAnime.series == null) {
currAnime.series = relatedAnime.series
update(currAnime)
} else if (relatedAnime.series == null && currAnime.series != null) {
relatedAnime.series = currAnime.series
update(relatedAnime)
} else if (relatedAnime.series == null && currAnime.series == null) {
val newSeries = AnimeSeries {}
insert(newSeries)
currAnime.series = newSeries
update(currAnime)
relatedAnime.series = newSeries
update(relatedAnime)
} else if (relatedAnime.series != null && currAnime.series != null) {
seriesToMerge.add(relatedAnime.series!! to currAnime.series!!)
}
}
val currentProducers = producers[info.anime.id] ?: mutableMapOf()
val malProducers = scraper.getProducers()
for (producer in malProducers) {
val currentProducer = currentProducers.remove(producer.producer.id)
if (currentProducer != null) {
if (currentProducer.function != producer.function) {
currentProducer.function = producer.function
update(currentProducer)
}
continue
}
val producerEntity = getProducer(producer.producer)
insert(AnimeProducer {
setAnime(info.anime)
setProducer(producerEntity)
setSource(InfoSource.MyAnimeList)
function = producer.function
})
}
for ((_, currentProducer) in currentProducers) {
delete(currentProducer)
}
}
}
}
}
}

@ -0,0 +1,7 @@
package moe.odango.index.sync
import java.util.concurrent.TimeUnit
abstract class ScheduledSync(val amount: Long, val timeUnit: TimeUnit) {
abstract suspend fun run()
}

@ -0,0 +1,6 @@
package moe.odango.index.utils
interface EntityHelper<T> {
operator fun invoke(block: T.() -> Unit): T;
}

@ -0,0 +1,18 @@
package moe.odango.index.utils
import org.joda.time.DateTime
import org.joda.time.format.ISODateTimeFormat
class ISOTextConverter : io.requery.Converter<DateTime, String> {
override fun convertToMapped(type: Class<out DateTime>?, value: String?): DateTime? {
return DateTime.parse(value ?: return null)
}
override fun getPersistedType(): Class<String> = String::class.java
override fun getMappedType(): Class<DateTime> = DateTime::class.java
override fun convertToPersisted(value: DateTime): String =
value.toString(ISODateTimeFormat.dateTime())
override fun getPersistedSize(): Int? = null
}

@ -0,0 +1,7 @@
package moe.odango.index.utils
enum class InfoSource {
UserDefined,
MyAnimeList,
AniDB;
}

@ -0,0 +1,29 @@
package moe.odango.index.utils
import java.sql.Date
import java.text.SimpleDateFormat
import java.util.*
data class IntDate(val year: Int, val month: Int, val day: Int) {
fun toDate(): Date = Date(year - 1900, month, day)
companion object {
fun parse(format: String, input: String, locale: Locale = Locale.getDefault(Locale.Category.FORMAT)): IntDate? {
val formatter = SimpleDateFormat(format, locale)
return try {
formatter.parse(input)?.let {
val nr = GregorianCalendar().apply {
time = it
}
IntDate(nr.get(Calendar.YEAR), nr.get(Calendar.MONTH), nr.get(Calendar.DAY_OF_MONTH))
}
} catch (t: Throwable) {
null
}
}
}
}
fun Date.toIntDate() = IntDate(this.year + 1900, this.month, this.date)

@ -0,0 +1,50 @@
package moe.odango.index.utils
class MergeMap<T> {
private var bucketIndex = 0L
private val buckets: MutableMap<Long, MutableSet<T>> = mutableMapOf()
private val bucketIndexByItem: MutableMap<T, Long> = mutableMapOf()
fun add(a: T, b: T) {
val bucketA = bucketIndexByItem[a]
val bucketB = bucketIndexByItem[b]
if (bucketA == null && bucketB == null) {
bucketIndexByItem[a] = bucketIndex
bucketIndexByItem[b] = bucketIndex
buckets[bucketIndex] = mutableSetOf(a, b)
bucketIndex++
return
}
if (bucketB == bucketA) {
return
}
if (bucketA == null && bucketB != null) {
bucketIndexByItem[a] = bucketB
buckets.getOrPut(bucketB, ::mutableSetOf).add(a)
return
}
if (bucketB == null && bucketA != null) {
bucketIndexByItem[b] = bucketA
buckets.getOrPut(bucketA, ::mutableSetOf).add(b)
return
}
// Always false :)
if (bucketA == null || bucketB == null) return
val bucket = buckets.getOrPut(bucketB, ::mutableSetOf)
buckets.getOrPut(bucketA, ::mutableSetOf).addAll(bucket)
for (item in bucket) {
bucketIndexByItem[item] = bucketA
}
}
operator fun get(item: T): Set<T>? {
return buckets[bucketIndexByItem[item] ?: return null]
}
operator fun iterator(): Iterator<Set<T>> = buckets.values.iterator()
}

@ -0,0 +1,28 @@
package moe.odango.index.utils
import io.inbot.eskotlinwrapper.MapBackedProperties
import io.inbot.eskotlinwrapper.dsl.ESQuery
class NestedQuery : ESQuery(name = "nested") {
var path: String by queryDetails.property()
var ignoreUnmapped: Boolean by queryDetails.property()
var innerHits: Map<Any, Any?> by queryDetails.property()
var scoreMode: ScoreMode by queryDetails.property()
var query: ESQuery by queryDetails.esQueryProperty()
}
fun nested(path: String, config: NestedQuery.() -> Unit): NestedQuery {
val q = NestedQuery()
q.path = path
config(q)
return q
}
@Suppress("EnumEntryName")
enum class ScoreMode {
avg,
max,
min,
none,
sum
}

@ -0,0 +1,7 @@
package moe.odango.index.utils
enum class ProducerFunction {
Studio,
Producer,
Licensor;
}

@ -0,0 +1,38 @@
package moe.odango.index.utils
import io.inbot.eskotlinwrapper.dsl.ESQuery
class TermsSetQuery : ESQuery(name = "terms_set") {
operator fun set(field: String, value: TermSetConfig) {
queryDetails[field] = value.toMap()
}
operator fun String.invoke(block: TermSetConfig.() -> Unit) {
val config = TermSetConfig()
block(config)
set(this, config)
}
}
fun termsSet(block: TermsSetQuery.() -> Unit): TermsSetQuery {
val q = TermsSetQuery()
block(q)
return q
}
class TermSetConfig {
private val map = mutableMapOf<String, Any>()
@Suppress("UNCHECKED_CAST")
var terms: Set<Any>
set(value) {
map["terms"] = value as Any
}
get() = map["terms"] as? Set<Any> ?: emptySet()
var minimumShouldMatch: Int?
get() = map["minimum_should_match_field"] as? Int
set(value) { map["minimum_should_match_field"] = value as Any }
fun toMap() = map
}

@ -0,0 +1,26 @@
package moe.odango.index.utils
import com.fasterxml.aalto.AsyncByteArrayFeeder
import com.fasterxml.aalto.AsyncXMLStreamReader
import com.fasterxml.aalto.stax.InputFactoryImpl
import java.io.OutputStream
class XMLOutputStreamReader(private val afterWrite: AsyncXMLStreamReader<*>.() -> Unit) : OutputStream() {
private val reader = InputFactoryImpl().createAsyncForByteArray()!!
private val feeder = reader.inputFeeder!!
override fun write(b: ByteArray) {
write(b, 0, b.size)
}
override fun write(b: ByteArray, off: Int, len: Int) {
feeder.feedInput(b, off, len)
while (reader.next() != AsyncXMLStreamReader.EVENT_INCOMPLETE) {
afterWrite(reader)
}
}
override fun write(b: Int) {
write(byteArrayOf(b.toByte()), 0, 1)
}
}

@ -0,0 +1,13 @@
package moe.odango.index.utils
inline fun<reified T> helper(crossinline constructor: () -> T, crossinline init: T.() -> Unit = {}): EntityHelper<T> {
return object : EntityHelper<T> {
override fun invoke(block: T.() -> Unit): T {
val entity = constructor()
init(entity)
block(entity)
return entity
}
}
}

@ -0,0 +1,74 @@
package moe.odango.index.utils
import org.jsoup.internal.StringUtil
import org.jsoup.nodes.CDataNode
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.jsoup.select.NodeTraversor
import org.jsoup.select.NodeVisitor
fun Element.brText(): String {
val accum = StringUtil.borrowBuilder();
NodeTraversor.traverse(object : NodeVisitor {
override fun head(node: Node, depth: Int) {
if (node is TextNode) {
appendNormalisedText(accum, node);
} else if (node is Element) {
if (accum.isNotEmpty() &&
((node.isBlock && !accum.lastIsWhitespace()) || node.tagName() == "br")
) {
if (node.tagName() == "br") {
var lastIndex = accum.lastIndex
while (accum[lastIndex] == ' ') {
lastIndex--
}
accum.delete(lastIndex + 1, accum.length)
accum.append('\n')
} else {
accum.append(' ');
}
}
}
}
override fun tail(node: Node, depth: Int) {
// make sure there is a space between block tags and immediately following text nodes <div>One</div>Two should be "One Two".
if (node is Element) {
if (node.isBlock && (node.nextSibling() is TextNode) && (accum.lastIsWhitespace()))
accum.append(' ');
}
}
}, this);
return StringUtil.releaseBuilder(accum).trim()
}
fun StringBuilder.lastIsWhitespace() = lastOrNull() == ' ' || lastOrNull() == '\n'
fun appendNormalisedText(accum: StringBuilder, textNode: TextNode) {
val text = textNode.wholeText;
if (preserveWhitespace(textNode.parentNode()) || textNode is CDataNode)
accum.append(text);
else
StringUtil.appendNormalisedWhitespace(accum, text, accum.lastIsWhitespace());
}
fun preserveWhitespace(node: Node): Boolean {
// looks only at this element and five levels up, to prevent recursion & needless stack searches
if (node is Element) {
var el: Node? = node
var i = 0;
do {
val ele = el ?: return false
if (ele is Element && ele.tag().preserveWhitespace())
return true;
el = ele.parent()
i++;
} while (i < 6 && el != null);
}
return false;
}

@ -0,0 +1,208 @@
package moe.odango.index.test.scraper.mal
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import moe.odango.index.entity.AnimeRelation
import moe.odango.index.entity.MyAnimeListInfo
import moe.odango.index.scraper.mal.AnimePageScraper
import moe.odango.index.scraper.mal.AnimePageScraper.Genre
import moe.odango.index.utils.IntDate
import moe.odango.index.utils.ProducerFunction
import java.time.Duration
class AnimePageScraperTest : StringSpec({
"test-bodyscraper" {
val body = this::class.java.getResourceAsStream("/test-pages/25313.html")
.readAllBytes()
.toString(Charsets.UTF_8)
val scraper = AnimePageScraper(body)
scraper.getDescription().trim() shouldBe """
Bundled with limited edition of the 58th Gintama manga volume.
The tagline for the bundled anime reads, "It's time for all the Yorozuya members ...to wake up just one more time."
(Source: MAL News, edited)
""".trim()
scraper.getImage() shouldBe "https://cdn.myanimelist.net/images/anime/12/64865.jpg"
val related = scraper.getRelated()
related shouldHaveSize 1
related[0].id shouldBe 15417L
related[0].type shouldBe AnimeRelation.RelationType.ParentStory
scraper.getPremiered() shouldBe null
scraper.getAired() shouldNotBe null
scraper.getAired()?.start shouldBe IntDate(2015, 3, 3)
scraper.getSource() shouldBe "Manga"
scraper.getDuration() shouldBe Duration.ofMinutes(24)
scraper.getRating() shouldBe MyAnimeListInfo.Rating.PG13
}
val items = mapOf(
"616.html" to AnimePageScraper.Info(
"Nurse Angel Ririka SOS",
null,
"ナースエンジェルりりかSOS",
listOf(),
"""
The Evil Forces of Dark Joker are closing in on our planet after having destroyed the beautiful planet of Queen Earth. Now, 10 year old Moriya Ririka, with the help of her childhood friend Seiya and the mysterious Kanon, must transform into the Nurse Angel and find the elusive Flower of Life, the only way to defeat the evil forces. The Flower of Life, that once bloomed all over the Earth, is where no one thought it ever would be. And Ririka must make the hardest decision of her life in order to acquire it and rid the universe of evil once and for all.
(Source: ANN)
""".trimIndent(),
MyAnimeListInfo.ReleaseType.TV,
35,
"Manga",
"https://cdn.myanimelist.net/images/anime/10/10506.jpg",
listOf(
Genre.DRAMA,
Genre.FANTASY,
Genre.MAGIC,
Genre.SHOUJO
),
AnimePageScraper.Aired(
IntDate(1995, 6, 7),
IntDate(1996, 2, 29)
),
AnimePageScraper.Premiered(AnimePageScraper.Premiered.Season.Summer, 1995),
MyAnimeListInfo.Rating.PG,
Duration.ofMinutes(24),
listOf(),
listOf(
AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(16, "TV Tokyo")),
AnimePageScraper.ProducerRelation(
ProducerFunction.Producer,
AnimePageScraper.Producer(139, "Nihon Ad Systems")
),
AnimePageScraper.ProducerRelation(ProducerFunction.Studio, AnimePageScraper.Producer(36, "Gallop"))
)
),
"817.html" to AnimePageScraper.Info(
"Tactical Roar",
null,
"タクティカルロア",
listOf("Tactical Rawr"),
"""
In the near future the world's climate shifted creating in the Western Pacific a perpetual super cyclone: the Grand Roar that altered the earth, flooding most countries. Shipping and navigation became important to nations and following the appearance of ocean pirates, necessisated companies to hire escort cruisers to safeguard their investments. Hyousuke Nagimiya is a system engineer that was comissioned to upgrade the Pascal Magi manned by an entire crew of women with its captain, Misaki Nanaha. Together the crew strives to prove themselves to their detractors that they are no mere 'Alice Brand'. Yet as they go about their mission a larger global conspiracy seems to be working behind the scenes to take advantage of this new world order.
(Source: ANN)
""".trimIndent(),
MyAnimeListInfo.ReleaseType.TV,
13,
"Unknown",
"https://cdn.myanimelist.net/images/anime/8/61829.jpg",
listOf(
Genre.COMEDY,
Genre.MILITARY,
Genre.ROMANCE,
Genre.SCIFI
),
AnimePageScraper.Aired(
IntDate(2006, 0, 8),
IntDate(2006, 3, 2)
),
AnimePageScraper.Premiered(AnimePageScraper.Premiered.Season.Winter, 2006),
MyAnimeListInfo.Rating.PG13,
Duration.ofMinutes(25),
listOf(
AnimePageScraper.Relation(AnimeRelation.RelationType.SideStory, 1790)
),
listOf(
AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(23, "Bandai Visual")),
AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(104, "Lantis")),
AnimePageScraper.ProducerRelation(ProducerFunction.Studio, AnimePageScraper.Producer(60, "Actas"))
)
),
"1558.html" to AnimePageScraper.Info(
"Yarima Queen",
"Sex Demon Queen",
"ヤーリマクィーン",
listOf(),
"""
The sorceress Kuri uses her magic to defend herself from perverted monsters and demons, but her partner, Rima, would much rather do perverted things than defend herself. When the two save a woman from a gang rape, they catch the eye of an evil Sex Queen and her dog-demons. When the sex demons release the passions of Kuri and Rima, even the duo`s formidable powers will be useless. Don`t miss it as all four girls redefine the meaning of Doggie Style!
(Source: AniDB)
""".trimIndent(),
MyAnimeListInfo.ReleaseType.OVA,
1,
"Unknown",
"https://cdn.myanimelist.net/images/anime/10/41571.jpg",
listOf(
Genre.COMEDY,
Genre.DEMONS,
Genre.FANTASY,
Genre.HENTAI,
Genre.MAGIC,
Genre.PARODY
),
AnimePageScraper.Aired(
IntDate(2000, 5, 25)
),
null,
MyAnimeListInfo.Rating.Rx,
Duration.ofMinutes(30),
listOf(),
listOf(
AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(48, "AIC")),
AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(152, "Green Bunny")),
AnimePageScraper.ProducerRelation(ProducerFunction.Licensor, AnimePageScraper.Producer(250, "Media Blasters")),
AnimePageScraper.ProducerRelation(ProducerFunction.Licensor, AnimePageScraper.Producer(595, "NYAV Post"))
)
),
"1581.html" to AnimePageScraper.Info(
"Gift: Eternal Rainbow",
null,
"ギフト~ eternal rainbow",
listOf(),
"""
Amaumi Haruhiko is a high school student who attends Shimano Academy in a town called Narasakicho. Narasakicho contains an unknown rainbow which constantly overlooks the town and is related to granting a magical wish called "Gift." Gift is a once-in-a-lifetime present between two people.
As a child, Haruhiko has been close with his childhood friend, Kirino, until he obtains a new non-blood sister by the name of Riko. Haruhiko develops a strong relationship with Riko until they sadly depart due to the fact Haruhiko's father could no longer support the two of them.
After some times passes by, Riko finally returns to the town of Narasakicho, and along with Kirino, starts to attend Shimano Academy with Haruhiko. The series revolves around the relationship among these main protagonists and slowly reveals the story behind both Gift and the rainbow.
""".trimIndent(),
MyAnimeListInfo.ReleaseType.TV,
12,
"Visual novel",
"https://cdn.myanimelist.net/images/anime/2/75540.jpg",
listOf(
Genre.COMEDY,
Genre.DRAMA,
Genre.HAREM,
Genre.MAGIC,
Genre.ROMANCE,
Genre.SCHOOL
),
AnimePageScraper.Aired(
IntDate(2006, 9, 6),
IntDate(2006, 11, 22)
),
AnimePageScraper.Premiered(AnimePageScraper.Premiered.Season.Fall, 2006),
MyAnimeListInfo.Rating.PG13,
Duration.ofMinutes(24),
listOf(
AnimePageScraper.Relation(AnimeRelation.RelationType.SideStory, 2784)
),
listOf(
AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(829, "Studio Jack")),
AnimePageScraper.ProducerRelation(ProducerFunction.Studio, AnimePageScraper.Producer(28, "OLM"))
)
)
)
for ((file, info) in items) {
"Test $file" {
val html = this::class.java.getResourceAsStream("/test-pages/$file")
.readAllBytes()
.toString(Charsets.UTF_8)
AnimePageScraper(html)
.getInfo() shouldBe info
}
}
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save