mirror of
https://github.com/gotson/komga.git
synced 2025-12-20 07:23:34 +01:00
feat: page hashing enhancement
only hash pages for cbz delete non-cbz page hashes store page hashes
This commit is contained in:
parent
368d0d5147
commit
a96335dbee
34 changed files with 1189 additions and 165 deletions
30
komga-webui/package-lock.json
generated
30
komga-webui/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"axios": "^0.25.0",
|
||||
"core-js": "^3.20.3",
|
||||
"date-fns": "^2.28.0",
|
||||
"filesize": "^8.0.7",
|
||||
"js-file-downloader": "^1.1.24",
|
||||
"language-tags": "^1.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
@ -9261,10 +9262,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/filesize": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz",
|
||||
"integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==",
|
||||
"dev": true,
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
|
||||
"integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
|
|
@ -20275,6 +20275,15 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/filesize": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz",
|
||||
"integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/ws": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz",
|
||||
|
|
@ -28286,10 +28295,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"filesize": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz",
|
||||
"integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==",
|
||||
"dev": true
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
|
||||
"integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ=="
|
||||
},
|
||||
"fill-range": {
|
||||
"version": "4.0.0",
|
||||
|
|
@ -37203,6 +37211,12 @@
|
|||
"integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
|
||||
"dev": true
|
||||
},
|
||||
"filesize": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz",
|
||||
"integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==",
|
||||
"dev": true
|
||||
},
|
||||
"ws": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"axios": "^0.25.0",
|
||||
"core-js": "^3.20.3",
|
||||
"date-fns": "^2.28.0",
|
||||
"filesize": "^8.0.7",
|
||||
"js-file-downloader": "^1.1.24",
|
||||
"language-tags": "^1.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
|
|||
163
komga-webui/src/components/PageHashKnownCard.vue
Normal file
163
komga-webui/src/components/PageHashKnownCard.vue
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-container fluid>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-img
|
||||
width="200"
|
||||
height="300"
|
||||
contain
|
||||
@click="$emit('image-clicked')"
|
||||
:src="pageHashKnownThumbnailUrl(hash)"
|
||||
style="cursor: zoom-in"
|
||||
/>
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-card-text style="min-width: 200px">
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-chip label small :color="actionColor">
|
||||
{{ $t(`enums.page_hash_action.${hash.action}`) }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div>{{ hash.mediaType }}</div>
|
||||
<div>{{ getFileSize(hash.size) || $t('duplicate_pages.unknown_size') }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn
|
||||
v-if="matchCount"
|
||||
@click="$emit('matches-clicked')"
|
||||
outlined
|
||||
rounded
|
||||
>
|
||||
{{ $tc('duplicate_pages.matches_n', matchCount) }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div
|
||||
v-if="hash.deleteCount"
|
||||
>{{ $t('duplicate_pages.deleted_count', {count: hash.deleteCount}) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hash.deleteCount"
|
||||
>{{ $t('duplicate_pages.saved_size', {size: getFileSize(hash.size * hash.deleteCount)}) }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn v-if="hash.action === PageHashAction.DELETE_MANUAL" color="primary" @click="deleteMatches">
|
||||
{{ $t('duplicate_pages.action_delete_matches') }}
|
||||
</v-btn>
|
||||
<v-btn v-if="hash.action !== PageHashAction.IGNORE" text @click="ignore">{{
|
||||
$t('duplicate_pages.action_ignore')
|
||||
}}
|
||||
</v-btn>
|
||||
<v-btn v-if="hash.action !== PageHashAction.DELETE_MANUAL" text @click="deleteManual">
|
||||
{{ $t('duplicate_pages.action_delete_manual') }}
|
||||
</v-btn>
|
||||
<v-btn v-if="hash.action !== PageHashAction.DELETE_AUTO" text @click="deleteAuto" :disabled="!hash.size">
|
||||
{{ $t('duplicate_pages.action_delete_auto') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, {PropType} from 'vue'
|
||||
import {pageHashKnownThumbnailUrl} from '@/functions/urls'
|
||||
import {PageHashKnownDto} from '@/types/komga-pagehashes'
|
||||
import {PageHashAction} from '@/types/enum-pagehashes'
|
||||
import {getFileSize} from '@/functions/file'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'PageHashKnownCard',
|
||||
props: {
|
||||
hash: {
|
||||
type: Object as PropType<PageHashKnownDto>,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pageHashKnownThumbnailUrl,
|
||||
getFileSize,
|
||||
PageHashAction,
|
||||
matchCount: undefined as number | undefined,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
actionColor(): string {
|
||||
switch (this.hash.action) {
|
||||
case PageHashAction.DELETE_AUTO:
|
||||
return 'success'
|
||||
case PageHashAction.DELETE_MANUAL:
|
||||
return 'warning'
|
||||
default:
|
||||
return 'grey'
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getMatchCount()
|
||||
},
|
||||
watch: {
|
||||
hash: {
|
||||
handler() {
|
||||
this.getMatchCount()
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async getMatchCount() {
|
||||
if (this.hash?.action === PageHashAction.DELETE_MANUAL)
|
||||
this.matchCount = (await this.$komgaPageHashes.getUnknownPageHashMatches(this.hash, {size: 0})).totalElements
|
||||
else
|
||||
this.matchCount = undefined
|
||||
},
|
||||
deleteMatches() {
|
||||
},
|
||||
ignore() {
|
||||
this.updatePageHash(PageHashAction.IGNORE)
|
||||
},
|
||||
deleteManual() {
|
||||
this.updatePageHash(PageHashAction.DELETE_MANUAL)
|
||||
},
|
||||
deleteAuto() {
|
||||
this.updatePageHash(PageHashAction.DELETE_AUTO)
|
||||
},
|
||||
async updatePageHash(action: PageHashAction) {
|
||||
try {
|
||||
const p = {
|
||||
hash: this.hash.hash,
|
||||
mediaType: this.hash.mediaType,
|
||||
size: this.hash.size,
|
||||
action: action,
|
||||
}
|
||||
await this.$komgaPageHashes.createOrUpdatePageHash(p)
|
||||
this.$emit('updated', p)
|
||||
} catch (e) {
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -33,13 +33,13 @@
|
|||
<script lang="ts">
|
||||
import Vue, {PropType} from 'vue'
|
||||
import {bookPageThumbnailUrl} from '@/functions/urls'
|
||||
import {PageHashMatchDto, PageHashUnknownDto} from '@/types/komga-pagehashes'
|
||||
import {PageHashDto, PageHashMatchDto, PageHashUnknownDto} from '@/types/komga-pagehashes'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'PageHashMatchesTable',
|
||||
props: {
|
||||
hash: {
|
||||
type: Object as PropType<PageHashUnknownDto>,
|
||||
type: Object as PropType<PageHashDto>,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
|
|
@ -74,7 +74,7 @@ export default Vue.extend({
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
async loadData(hash: PageHashUnknownDto) {
|
||||
async loadData(hash: PageHashDto) {
|
||||
this.loading = true
|
||||
|
||||
const {sortBy, sortDesc, page, itemsPerPage} = this.options
|
||||
|
|
|
|||
105
komga-webui/src/components/PageHashUnknownCard.vue
Normal file
105
komga-webui/src/components/PageHashUnknownCard.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-container fluid>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-img
|
||||
width="200"
|
||||
height="300"
|
||||
contain
|
||||
@click="$emit('image-clicked')"
|
||||
:src="pageHashUnknownThumbnailUrl(hash, 500)"
|
||||
style="cursor: zoom-in"
|
||||
/>
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-card-text style="min-width: 200px">
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div>{{ hash.mediaType }}</div>
|
||||
<div>{{ getFileSize(hash.size) || $t('duplicate_pages.unknown_size') }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn
|
||||
@click="$emit('matches-clicked')"
|
||||
outlined
|
||||
rounded
|
||||
>
|
||||
{{ $tc('duplicate_pages.matches_n', hash.matchCount) }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div
|
||||
v-if="hash.size"
|
||||
>{{ $t('duplicate_pages.delete_to_save', {size: getFileSize(hash.size * hash.matchCount)}) }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn text @click="ignore">{{ $t('duplicate_pages.action_ignore') }}</v-btn>
|
||||
<v-btn text @click="deleteManual">{{ $t('duplicate_pages.action_delete_manual') }}</v-btn>
|
||||
<v-btn text @click="deleteAuto" :disabled="!hash.size">{{ $t('duplicate_pages.action_delete_auto') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, {PropType} from 'vue'
|
||||
import {pageHashUnknownThumbnailUrl} from '@/functions/urls'
|
||||
import {PageHashUnknownDto} from '@/types/komga-pagehashes'
|
||||
import {PageHashAction} from '@/types/enum-pagehashes'
|
||||
import {getFileSize} from '@/functions/file'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'PageHashUnknownCard',
|
||||
props: {
|
||||
hash: {
|
||||
type: Object as PropType<PageHashUnknownDto>,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pageHashUnknownThumbnailUrl,
|
||||
getFileSize,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
ignore() {
|
||||
this.createPageHash(PageHashAction.IGNORE)
|
||||
},
|
||||
deleteManual() {
|
||||
this.createPageHash(PageHashAction.DELETE_MANUAL)
|
||||
},
|
||||
deleteAuto() {
|
||||
this.createPageHash(PageHashAction.DELETE_AUTO)
|
||||
},
|
||||
async createPageHash(action: PageHashAction) {
|
||||
try {
|
||||
await this.$komgaPageHashes.createOrUpdatePageHash({
|
||||
hash: this.hash.hash,
|
||||
mediaType: this.hash.mediaType,
|
||||
size: this.hash.size,
|
||||
action: action,
|
||||
})
|
||||
this.$emit('created')
|
||||
} catch (e) {
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import filesize from 'filesize'
|
||||
|
||||
export async function getFileFromUrl(url: string, name: string = url, defaultType = 'image/jpeg') {
|
||||
const response = await fetch(url)
|
||||
const data = await response.blob()
|
||||
|
|
@ -5,3 +7,11 @@ export async function getFileFromUrl(url: string, name: string = url, defaultTyp
|
|||
type: data.type || defaultType,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const filesizePartial = filesize.partial({round: 1})
|
||||
|
||||
export function getFileSize(n?: number): string | undefined {
|
||||
if(!n) return undefined
|
||||
return filesizePartial(n)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {PageHashUnknownDto} from '@/types/komga-pagehashes'
|
||||
import {PageHashKnownDto, PageHashUnknownDto} from '@/types/komga-pagehashes'
|
||||
|
||||
const fullUrl = process.env.VUE_APP_KOMGA_API_URL
|
||||
? process.env.VUE_APP_KOMGA_API_URL
|
||||
|
|
@ -75,9 +75,13 @@ export function transientBookPageUrl(transientBookId: string, page: number): str
|
|||
}
|
||||
|
||||
export function pageHashUnknownThumbnailUrl(pageHash: PageHashUnknownDto, resize?: number): string {
|
||||
let url = `${urls.originNoSlash}/api/v1/page-hashes/unknown/${pageHash.hash}/thumbnail?media_type=${pageHash.mediaType}&file_size=${pageHash.sizeBytes || -1}`
|
||||
let url = `${urls.originNoSlash}/api/v1/page-hashes/unknown/${pageHash.hash}/thumbnail?media_type=${pageHash.mediaType}&file_size=${pageHash.size || -1}`
|
||||
if(resize) {
|
||||
url += `&resize=${resize}`
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export function pageHashKnownThumbnailUrl(pageHash: PageHashKnownDto): string {
|
||||
return `${urls.originNoSlash}/api/v1/page-hashes/${pageHash.hash}/thumbnail?media_type=${pageHash.mediaType}&file_size=${pageHash.size || -1}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -531,16 +531,21 @@
|
|||
"duplicate_pages": {
|
||||
"action_delete_auto": "Auto delete",
|
||||
"action_delete_manual": "Manual delete",
|
||||
"action_delete_matches": "Delete matches",
|
||||
"action_ignore": "Ignore",
|
||||
"matches_n": "No matches | 1 match | {count} matches",
|
||||
"title": "Duplicate pages",
|
||||
"unknown_size": "Unknown size",
|
||||
"delete_to_save": "Delete to save {size}",
|
||||
"deleted_count": "Deleted {count} times",
|
||||
"filter": {
|
||||
"total_size": "Total size",
|
||||
"count": "Count",
|
||||
"delete_count": "Deletion count",
|
||||
"delete_size": "Space saved",
|
||||
"size": "Size",
|
||||
"count": "Count"
|
||||
}
|
||||
"total_size": "Total size"
|
||||
},
|
||||
"matches_n": "No matches | 1 match | {count} matches",
|
||||
"saved_size": "Saved {size}",
|
||||
"title": "Duplicate pages",
|
||||
"unknown_size": "Unknown size"
|
||||
},
|
||||
"duplicates": {
|
||||
"file_hash": "File hash",
|
||||
|
|
@ -560,6 +565,11 @@
|
|||
"UNKNOWN": "Unknown",
|
||||
"UNSUPPORTED": "Unsupported"
|
||||
},
|
||||
"page_hash_action": {
|
||||
"DELETE_AUTO": "Auto delete",
|
||||
"DELETE_MANUAL": "Manual delete",
|
||||
"IGNORE": "Ignore"
|
||||
},
|
||||
"reading_direction": {
|
||||
"LEFT_TO_RIGHT": "Left to right",
|
||||
"RIGHT_TO_LEFT": "Right to left",
|
||||
|
|
|
|||
|
|
@ -97,8 +97,22 @@ const router = new Router({
|
|||
{
|
||||
path: '/settings/duplicate-pages',
|
||||
name: 'settings-duplicate-pages',
|
||||
beforeEnter: adminGuard,
|
||||
component: () => import(/* webpackChunkName: "settings-duplicate-pages" */ './views/SettingsDuplicatePages.vue'),
|
||||
redirect: {name: 'settings-duplicate-pages-known'},
|
||||
component: () => import(/* webpackChunkName: "settings-duplicate-pages" */ './views/SettingsDuplicatePagesHolder.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '/settings/duplicate-pages/known',
|
||||
name: 'settings-duplicate-pages-known',
|
||||
beforeEnter: adminGuard,
|
||||
component: () => import(/* webpackChunkName: "settings-duplicate-pages" */ './views/SettingsDuplicatePagesKnown.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings/duplicate-pages/unknown',
|
||||
name: 'settings-duplicate-pages-unknown',
|
||||
beforeEnter: adminGuard,
|
||||
component: () => import(/* webpackChunkName: "settings-duplicate-pages" */ './views/SettingsDuplicatePagesUnknown.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/settings/server',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import {AxiosInstance} from 'axios'
|
||||
import {PageHashMatchDto, PageHashUnknownDto} from '@/types/komga-pagehashes'
|
||||
import {
|
||||
PageHashCreationDto,
|
||||
PageHashDto,
|
||||
PageHashKnownDto,
|
||||
PageHashMatchDto,
|
||||
PageHashUnknownDto,
|
||||
} from '@/types/komga-pagehashes'
|
||||
|
||||
const qs = require('qs')
|
||||
|
||||
|
|
@ -12,6 +18,23 @@ export default class KomgaPageHashesService {
|
|||
this.http = http
|
||||
}
|
||||
|
||||
async getKnownHashes(actions: string[], pageRequest?: PageRequest): Promise<Page<PageHashKnownDto>> {
|
||||
try {
|
||||
const params = {...pageRequest} as any
|
||||
if (actions) params.action = actions
|
||||
return (await this.http.get(API_PAGE_HASH, {
|
||||
params: params,
|
||||
paramsSerializer: params => qs.stringify(params, {indices: false}),
|
||||
})).data
|
||||
} catch (e) {
|
||||
let msg = 'An error occurred while trying to retrieve known page hashes'
|
||||
if (e.response.data.message) {
|
||||
msg += `: ${e.response.data.message}`
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async getUnknownHashes(pageRequest?: PageRequest): Promise<Page<PageHashUnknownDto>> {
|
||||
try {
|
||||
return (await this.http.get(`${API_PAGE_HASH}/unknown`, {
|
||||
|
|
@ -27,19 +50,31 @@ export default class KomgaPageHashesService {
|
|||
}
|
||||
}
|
||||
|
||||
async getUnknownPageHashMatches(hash: PageHashUnknownDto, pageRequest?: PageRequest): Promise<Page<PageHashMatchDto>> {
|
||||
async getUnknownPageHashMatches(pageHash: PageHashDto, pageRequest?: PageRequest): Promise<Page<PageHashMatchDto>> {
|
||||
try {
|
||||
const params = {
|
||||
...pageRequest,
|
||||
media_type: hash.mediaType,
|
||||
file_size: hash.sizeBytes || -1,
|
||||
media_type: pageHash.mediaType,
|
||||
file_size: pageHash.size || -1,
|
||||
}
|
||||
return (await this.http.get(`${API_PAGE_HASH}/unknown/${hash.hash}`, {
|
||||
return (await this.http.get(`${API_PAGE_HASH}/unknown/${pageHash.hash}`, {
|
||||
params: params,
|
||||
paramsSerializer: params => qs.stringify(params, {indices: false}),
|
||||
})).data
|
||||
} catch (e) {
|
||||
let msg = `An error occurred while trying to retrieve matches for page hash: ${hash}`
|
||||
let msg = `An error occurred while trying to retrieve matches for page hash: ${pageHash}`
|
||||
if (e.response.data.message) {
|
||||
msg += `: ${e.response.data.message}`
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async createOrUpdatePageHash(pageHash: PageHashCreationDto) {
|
||||
try {
|
||||
await this.http.put(API_PAGE_HASH, pageHash)
|
||||
} catch (e) {
|
||||
let msg = `An error occurred while trying to add page hash ${pageHash}`
|
||||
if (e.response.data.message) {
|
||||
msg += `: ${e.response.data.message}`
|
||||
}
|
||||
|
|
|
|||
5
komga-webui/src/types/enum-pagehashes.ts
Normal file
5
komga-webui/src/types/enum-pagehashes.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum PageHashAction {
|
||||
DELETE_AUTO = 'DELETE_AUTO',
|
||||
DELETE_MANUAL = 'DELETE_MANUAL',
|
||||
IGNORE = 'IGNORE',
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
export interface PageHashUnknownDto {
|
||||
import {PageHashAction} from '@/types/enum-pagehashes'
|
||||
|
||||
export interface PageHashDto {
|
||||
hash: string,
|
||||
mediaType: string,
|
||||
sizeBytes?: number,
|
||||
size?: string,
|
||||
totalSize?: string,
|
||||
size?: number,
|
||||
}
|
||||
|
||||
export interface PageHashUnknownDto extends PageHashDto {
|
||||
matchCount: number,
|
||||
}
|
||||
|
||||
|
|
@ -13,3 +16,17 @@ export interface PageHashMatchDto {
|
|||
pageNumber: number,
|
||||
fileName: string,
|
||||
}
|
||||
|
||||
export interface PageHashCreationDto {
|
||||
hash: string,
|
||||
mediaType: string,
|
||||
size?: number,
|
||||
action: PageHashAction,
|
||||
}
|
||||
|
||||
export interface PageHashKnownDto extends PageHashDto {
|
||||
action: PageHashAction
|
||||
deleteCount: number,
|
||||
createdDate: string,
|
||||
lastModifiedDate: string,
|
||||
}
|
||||
|
|
|
|||
21
komga-webui/src/views/SettingsDuplicatePagesHolder.vue
Normal file
21
komga-webui/src/views/SettingsDuplicatePagesHolder.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-tabs grow>
|
||||
<v-tab :to="{name: 'settings-duplicate-pages-known'}">Known</v-tab>
|
||||
<v-tab :to="{name: 'settings-duplicate-pages-unknown'}">New</v-tab>
|
||||
</v-tabs>
|
||||
<router-view/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SettingsDuplicatePagesHolder',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
224
komga-webui/src/views/SettingsDuplicatePagesKnown.vue
Normal file
224
komga-webui/src/views/SettingsDuplicatePagesKnown.vue
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<v-row align="center">
|
||||
<v-col cols="auto">
|
||||
<v-pagination
|
||||
v-if="totalPages > 1"
|
||||
v-model="page"
|
||||
:total-visible="paginationVisible"
|
||||
:length="totalPages"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-spacer/>
|
||||
|
||||
<v-col>
|
||||
<v-select
|
||||
:items="selectOptions"
|
||||
v-model="filterActive"
|
||||
small-chips
|
||||
deletable-chips
|
||||
multiple
|
||||
>
|
||||
</v-select>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
v-for="sortOption in sortOptions"
|
||||
:key="sortOption.key"
|
||||
cols="auto"
|
||||
>
|
||||
<v-btn
|
||||
rounded
|
||||
small
|
||||
:color="sortActive.key === sortOption.key ? 'primary' : ''"
|
||||
@click="setSort(sortOption.key)"
|
||||
>
|
||||
{{ sortOption.name }}
|
||||
<v-icon
|
||||
v-if="sortActive.key === sortOption.key"
|
||||
class="ms-2"
|
||||
>
|
||||
{{ sortActive.order === 'desc' ? 'mdi-sort-variant' : 'mdi-sort-reverse-variant' }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-slide-x-transition
|
||||
v-for="(element, i) in elements"
|
||||
:key="i"
|
||||
>
|
||||
<page-hash-known-card
|
||||
class="ma-2"
|
||||
:hash="element"
|
||||
@image-clicked="showDialogImage(element)"
|
||||
@matches-clicked="showDialogMatches(element)"
|
||||
@updated="pageHashUpdated"
|
||||
/>
|
||||
</v-slide-x-transition>
|
||||
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-pagination
|
||||
v-if="totalPages > 1"
|
||||
v-model="page"
|
||||
:total-visible="paginationVisible"
|
||||
:length="totalPages"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-dialog v-model="dialogImage">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-img
|
||||
@click="dialogImage = false"
|
||||
contain
|
||||
:src="pageHashKnownThumbnailUrl(dialogImagePageHash)"
|
||||
style="cursor: zoom-out;"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog
|
||||
v-model="dialogMatches"
|
||||
scrollable
|
||||
>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<page-hash-matches-table
|
||||
:hash="dialogMatchesPageHash"
|
||||
class="my-2"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn @click="dialogMatches = false" text>{{ $t('common.close') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import {PageHashDto, PageHashKnownDto} from '@/types/komga-pagehashes'
|
||||
import {pageHashKnownThumbnailUrl} from '@/functions/urls'
|
||||
import PageHashKnownCard from '@/components/PageHashKnownCard.vue'
|
||||
import {PageHashAction} from '@/types/enum-pagehashes'
|
||||
import PageHashMatchesTable from '@/components/PageHashMatchesTable.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SettingsDuplicatePagesKnown',
|
||||
components: {PageHashKnownCard, PageHashMatchesTable},
|
||||
data: function () {
|
||||
return {
|
||||
pageHashKnownThumbnailUrl,
|
||||
elements: [] as PageHashKnownDto[],
|
||||
totalElements: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
sortActive: {key: 'deleteSize', order: 'desc'} as SortActive,
|
||||
filterActive: [PageHashAction.DELETE_AUTO, PageHashAction.DELETE_MANUAL],
|
||||
dialogImage: false,
|
||||
dialogMatches: false,
|
||||
dialogImagePageHash: {} as PageHashKnownDto,
|
||||
dialogMatchesPageHash: {} as PageHashDto,
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadData(this.page, this.sortActive, this.filterActive)
|
||||
},
|
||||
watch: {
|
||||
page(val) {
|
||||
this.loadData(val, this.sortActive, this.filterActive)
|
||||
},
|
||||
sortActive(val) {
|
||||
this.loadData(this.page, val, this.filterActive)
|
||||
},
|
||||
filterActive(val) {
|
||||
this.loadData(this.page, this.sortActive, val)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
selectOptions(): object[] {
|
||||
return Object.keys(PageHashAction).map(x => ({
|
||||
text: this.$t(`enums.page_hash_action.${x.valueOf()}`),
|
||||
value: x.valueOf(),
|
||||
}))
|
||||
},
|
||||
sortOptions(): SortOption[] {
|
||||
return [
|
||||
{name: this.$t('duplicate_pages.filter.delete_size').toString(), key: 'deleteSize'},
|
||||
{name: this.$t('duplicate_pages.filter.size').toString(), key: 'fileSize'},
|
||||
{name: this.$t('duplicate_pages.filter.delete_count').toString(), key: 'deleteCount'},
|
||||
]
|
||||
},
|
||||
paginationVisible(): number {
|
||||
switch (this.$vuetify.breakpoint.name) {
|
||||
case 'xs':
|
||||
return 5
|
||||
case 'sm':
|
||||
case 'md':
|
||||
return 10
|
||||
case 'lg':
|
||||
case 'xl':
|
||||
default:
|
||||
return 15
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async loadData(page: number, sort: SortActive, actions: string[]) {
|
||||
const pageRequest = {
|
||||
page: page - 1,
|
||||
sort: [`${sort.key},${sort.order}`],
|
||||
} as PageRequest
|
||||
|
||||
const elementsPage = await this.$komgaPageHashes.getKnownHashes(actions, pageRequest)
|
||||
this.totalElements = elementsPage.totalElements
|
||||
this.totalPages = elementsPage.totalPages
|
||||
this.elements = elementsPage.content
|
||||
},
|
||||
setSort(key: string) {
|
||||
if (this.sortActive.key === key) {
|
||||
if (this.sortActive.order === 'desc') {
|
||||
this.sortActive = {key: key, order: 'asc'}
|
||||
} else {
|
||||
this.sortActive = {key: key, order: 'desc'}
|
||||
}
|
||||
} else {
|
||||
this.sortActive = {key: key, order: 'desc'}
|
||||
}
|
||||
},
|
||||
showDialogImage(pageHash: PageHashKnownDto) {
|
||||
this.dialogImagePageHash = pageHash
|
||||
this.dialogImage = true
|
||||
},
|
||||
showDialogMatches(pageHash: PageHashDto) {
|
||||
this.dialogMatchesPageHash = pageHash
|
||||
this.dialogMatches = true
|
||||
},
|
||||
pageHashUpdated(updated: PageHashKnownDto) {
|
||||
this.elements.find(x => {
|
||||
if (x.hash === updated.hash && x.mediaType === updated.mediaType && x.size === updated.size) {
|
||||
x.action = updated.action
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
|
@ -36,54 +36,19 @@
|
|||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-card
|
||||
<v-slide-x-transition
|
||||
v-for="(element, i) in elements"
|
||||
:key="i"
|
||||
class="ma-2"
|
||||
>
|
||||
<v-container fluid>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-img
|
||||
width="200"
|
||||
height="300"
|
||||
contain
|
||||
@click="showDialogImage(element)"
|
||||
:src="pageHashUnknownThumbnailUrl(element, 500)"
|
||||
style="cursor: zoom-in"
|
||||
/>
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-card-text style="min-width: 200px">
|
||||
<div>{{ element.mediaType }}</div>
|
||||
<div>{{ element.size || $t('duplicate_pages.unknown_size') }}</div>
|
||||
|
||||
<v-btn
|
||||
@click="showDialogMatches(element)"
|
||||
outlined
|
||||
rounded
|
||||
class="my-4"
|
||||
>
|
||||
{{ $tc('duplicate_pages.matches_n', element.matchCount) }}
|
||||
</v-btn>
|
||||
|
||||
<div
|
||||
v-if="element.totalSize"
|
||||
style="max-width: 100px"
|
||||
>{{ $t('duplicate_pages.delete_to_save', {size: element.totalSize}) }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn text disabled>{{ $t('duplicate_pages.action_ignore') }}</v-btn>
|
||||
<v-btn text disabled>{{ $t('duplicate_pages.action_delete_manual') }}</v-btn>
|
||||
<v-btn text disabled>{{ $t('duplicate_pages.action_delete_auto') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
<page-hash-unknown-card
|
||||
v-show="!hiddenElements.includes(element)"
|
||||
class="ma-2"
|
||||
:hash="element"
|
||||
@image-clicked="showDialogImage(element)"
|
||||
@matches-clicked="showDialogMatches(element)"
|
||||
@created="pageHashCreated(element)"
|
||||
/>
|
||||
</v-slide-x-transition>
|
||||
|
||||
</v-row>
|
||||
|
||||
|
|
@ -132,16 +97,18 @@
|
|||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import {PageHashUnknownDto} from '@/types/komga-pagehashes'
|
||||
import {PageHashDto, PageHashUnknownDto} from '@/types/komga-pagehashes'
|
||||
import {pageHashUnknownThumbnailUrl} from '@/functions/urls'
|
||||
import PageHashMatchesTable from '@/components/PageHashMatchesTable.vue'
|
||||
import PageHashUnknownCard from '@/components/PageHashUnknownCard.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SettingsDuplicatePages',
|
||||
components: {PageHashMatchesTable},
|
||||
name: 'SettingsDuplicatePagesUnknown',
|
||||
components: {PageHashUnknownCard, PageHashMatchesTable},
|
||||
data: function () {
|
||||
return {
|
||||
elements: [] as PageHashUnknownDto[],
|
||||
hiddenElements: [] as PageHashUnknownDto[],
|
||||
totalElements: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
|
|
@ -149,7 +116,7 @@ export default Vue.extend({
|
|||
dialogImage: false,
|
||||
dialogMatches: false,
|
||||
dialogImagePageHash: {} as PageHashUnknownDto,
|
||||
dialogMatchesPageHash: {} as PageHashUnknownDto,
|
||||
dialogMatchesPageHash: {} as PageHashDto,
|
||||
pageHashUnknownThumbnailUrl,
|
||||
}
|
||||
},
|
||||
|
|
@ -197,6 +164,7 @@ export default Vue.extend({
|
|||
this.totalElements = itemsPage.totalElements
|
||||
this.totalPages = itemsPage.totalPages
|
||||
this.elements = itemsPage.content
|
||||
if (this.page > this.totalPages) this.page = this.totalPages
|
||||
},
|
||||
setSort(key: string) {
|
||||
if (this.sortActive.key === key) {
|
||||
|
|
@ -213,10 +181,16 @@ export default Vue.extend({
|
|||
this.dialogImagePageHash = pageHash
|
||||
this.dialogImage = true
|
||||
},
|
||||
showDialogMatches(pageHash: PageHashUnknownDto) {
|
||||
showDialogMatches(pageHash: PageHashDto) {
|
||||
this.dialogMatchesPageHash = pageHash
|
||||
this.dialogMatches = true
|
||||
},
|
||||
pageHashCreated(pageHash: PageHashUnknownDto) {
|
||||
this.hiddenElements.push(pageHash)
|
||||
if (this.elements.every(x => this.hiddenElements.includes(x))) {
|
||||
this.loadData(this.page, this.sortActive)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-tabs>
|
||||
<v-tabs grow>
|
||||
<v-tab :to="{name: 'settings-analysis'}">
|
||||
<v-badge
|
||||
dot
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
CREATE TABLE PAGE_HASH
|
||||
(
|
||||
HASH varchar NOT NULL,
|
||||
MEDIA_TYPE varchar NOT NULL,
|
||||
SIZE int8 NULL,
|
||||
ACTION varchar NOT NULL,
|
||||
DELETE_COUNT int NOT NULL default 0,
|
||||
CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (HASH, MEDIA_TYPE, SIZE)
|
||||
);
|
||||
|
||||
CREATE TABLE PAGE_HASH_THUMBNAIL
|
||||
(
|
||||
HASH varchar NOT NULL,
|
||||
MEDIA_TYPE varchar NOT NULL,
|
||||
SIZE int8 NULL,
|
||||
THUMBNAIL blob NOT NULL,
|
||||
PRIMARY KEY (HASH, MEDIA_TYPE, SIZE)
|
||||
);
|
||||
|
||||
DELETE
|
||||
FROM MEDIA_PAGE
|
||||
WHERE BOOK_ID IN (
|
||||
SELECT DISTINCT m.BOOK_ID
|
||||
FROM MEDIA m
|
||||
LEFT JOIN MEDIA_PAGE MP on m.BOOK_ID = MP.BOOK_ID
|
||||
WHERE mp.FILE_HASH <> ''
|
||||
AND m.MEDIA_TYPE <> 'application/zip'
|
||||
);
|
||||
|
|
@ -9,9 +9,8 @@ import org.gotson.komga.domain.model.Library
|
|||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.service.BookConverter
|
||||
import org.gotson.komga.infrastructure.configuration.KomgaProperties
|
||||
import org.gotson.komga.domain.service.PageHashLifecycle
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_SUB_TYPE
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
|
||||
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_TYPE
|
||||
|
|
@ -31,9 +30,8 @@ class TaskReceiver(
|
|||
connectionFactory: ConnectionFactory,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val bookRepository: BookRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val bookConverter: BookConverter,
|
||||
private val komgaProperties: KomgaProperties,
|
||||
private val pageHashLifecycle: PageHashLifecycle,
|
||||
) {
|
||||
|
||||
private val jmsTemplates = (0..9).associateWith {
|
||||
|
|
@ -76,8 +74,8 @@ class TaskReceiver(
|
|||
|
||||
fun hashBookPagesWithMissingHash(library: Library) {
|
||||
if (library.hashPages)
|
||||
mediaRepository.findAllBookIdsByLibraryIdAndWithMissingPageHash(library.id, komgaProperties.pageHashing).forEach {
|
||||
submitTask(Task.HashBookPages(it, LOWEST_PRIORITY, bookRepository.getSeriesIdOrNull(it) ?: ""))
|
||||
pageHashLifecycle.getBookAndSeriesIdsWithMissingPageHash(library).forEach {
|
||||
submitTask(Task.HashBookPages(it.first, LOWEST_PRIORITY, it.second))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,9 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class PageHash(
|
||||
open class PageHash(
|
||||
val hash: String,
|
||||
val mediaType: String,
|
||||
val size: Long? = null,
|
||||
val action: Action,
|
||||
val deleteCount: Int = 0,
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = createdDate,
|
||||
) : Auditable {
|
||||
enum class Action {
|
||||
DELETE_AUTO,
|
||||
DELETE_MANUAL,
|
||||
IGNORE,
|
||||
}
|
||||
size: Long? = null,
|
||||
) {
|
||||
val size: Long? = if (size != null && size < 0) null else size
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class PageHashKnown(
|
||||
hash: String,
|
||||
mediaType: String,
|
||||
size: Long? = null,
|
||||
val action: Action,
|
||||
val deleteCount: Int = 0,
|
||||
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = createdDate,
|
||||
) : Auditable, PageHash(hash, mediaType, size) {
|
||||
enum class Action {
|
||||
DELETE_AUTO,
|
||||
DELETE_MANUAL,
|
||||
IGNORE,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
data class PageHashUnknown(
|
||||
val hash: String,
|
||||
val mediaType: String,
|
||||
val size: Long? = null,
|
||||
class PageHashUnknown(
|
||||
hash: String,
|
||||
mediaType: String,
|
||||
size: Long? = null,
|
||||
val matchCount: Int = 0,
|
||||
)
|
||||
) : PageHash(hash, mediaType, size)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import org.gotson.komga.domain.model.Media
|
|||
interface MediaRepository {
|
||||
fun findById(bookId: String): Media
|
||||
|
||||
fun findAllBookIdsByLibraryIdAndWithMissingPageHash(libraryId: String, pageHashing: Int): Collection<String>
|
||||
fun findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(libraryId: String, mediaTypes: Collection<String>, pageHashing: Int): Collection<Pair<String, String>>
|
||||
|
||||
fun getPagesSize(bookId: String): Int
|
||||
fun getPagesSizes(bookIds: Collection<String>): Collection<Pair<String, Int>>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
package org.gotson.komga.domain.persistence
|
||||
|
||||
import org.gotson.komga.domain.model.PageHash
|
||||
import org.gotson.komga.domain.model.PageHashKnown
|
||||
import org.gotson.komga.domain.model.PageHashMatch
|
||||
import org.gotson.komga.domain.model.PageHashUnknown
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
|
||||
interface PageHashRepository {
|
||||
fun findAllKnown(actions: List<PageHash.Action>?, pageable: Pageable): Page<PageHash>
|
||||
fun findKnown(pageHash: PageHash): PageHashKnown?
|
||||
fun findAllKnown(actions: List<PageHashKnown.Action>?, pageable: Pageable): Page<PageHashKnown>
|
||||
fun findAllUnknown(pageable: Pageable): Page<PageHashUnknown>
|
||||
|
||||
fun findMatchesByHash(pageHash: PageHashUnknown, pageable: Pageable): Page<PageHashMatch>
|
||||
fun findMatchesByHash(pageHash: PageHash, pageable: Pageable): Page<PageHashMatch>
|
||||
|
||||
fun getKnownThumbnail(hash: String): ByteArray?
|
||||
fun getKnownThumbnail(pageHash: PageHash): ByteArray?
|
||||
|
||||
fun insert(pageHash: PageHashKnown, thumbnail: ByteArray?)
|
||||
fun update(pageHash: PageHashKnown)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,53 @@
|
|||
package org.gotson.komga.domain.service
|
||||
|
||||
import org.gotson.komga.domain.model.BookPageContent
|
||||
import org.gotson.komga.domain.model.PageHashUnknown
|
||||
import org.gotson.komga.domain.model.Library
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.domain.model.PageHash
|
||||
import org.gotson.komga.domain.model.PageHashKnown
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.PageHashRepository
|
||||
import org.gotson.komga.infrastructure.configuration.KomgaProperties
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class PageHashLifecycle(
|
||||
private val pageHashRepository: PageHashRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val bookLifecycle: BookLifecycle,
|
||||
private val bookRepository: BookRepository,
|
||||
private val komgaProperties: KomgaProperties,
|
||||
) {
|
||||
|
||||
fun getPage(hash: PageHashUnknown, resizeTo: Int? = null): BookPageContent? {
|
||||
val match = pageHashRepository.findMatchesByHash(hash, Pageable.ofSize(1)).firstOrNull() ?: return null
|
||||
private val hashableMediaTypes = listOf(MediaType.ZIP.value)
|
||||
|
||||
/**
|
||||
* @return a Collection of Pair of BookId/SeriesId
|
||||
*/
|
||||
fun getBookAndSeriesIdsWithMissingPageHash(library: Library): Collection<Pair<String, String>> =
|
||||
mediaRepository.findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(library.id, hashableMediaTypes, komgaProperties.pageHashing)
|
||||
|
||||
fun getPage(pageHash: PageHash, resizeTo: Int? = null): BookPageContent? {
|
||||
val match = pageHashRepository.findMatchesByHash(pageHash, Pageable.ofSize(1)).firstOrNull() ?: return null
|
||||
val book = bookRepository.findByIdOrNull(match.bookId) ?: return null
|
||||
|
||||
return bookLifecycle.getBookPage(book, match.pageNumber, resizeTo = resizeTo)
|
||||
}
|
||||
|
||||
fun createOrUpdate(pageHash: PageHashKnown) {
|
||||
if (pageHash.action == PageHashKnown.Action.DELETE_AUTO && pageHash.size == null) throw IllegalArgumentException("cannot create PageHash without size and Action.DELETE_AUTO")
|
||||
|
||||
val existing = pageHashRepository.findKnown(pageHash)
|
||||
if (existing == null) {
|
||||
pageHashRepository.insert(pageHash, getPage(pageHash, 500)?.content)
|
||||
} else {
|
||||
pageHashRepository.update(pageHash)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(pageHash: PageHashKnown) {
|
||||
if (pageHash.action == PageHashKnown.Action.DELETE_AUTO && pageHash.size == null) throw IllegalArgumentException("cannot create PageHash without size and Action.DELETE_AUTO")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,23 +31,23 @@ class MediaDao(
|
|||
override fun findById(bookId: String): Media =
|
||||
find(dsl, bookId)
|
||||
|
||||
override fun findAllBookIdsByLibraryIdAndWithMissingPageHash(libraryId: String, pageHashing: Int): Collection<String> {
|
||||
override fun findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(libraryId: String, mediaTypes: Collection<String>, pageHashing: Int): Collection<Pair<String, String>> {
|
||||
val pagesCount = DSL.count(p.BOOK_ID)
|
||||
val hashedCount = DSL.sum(DSL.`when`(p.FILE_HASH.eq(""), 0).otherwise(1)).cast(Int::class.java)
|
||||
val neededHash = pageHashing * 2
|
||||
val neededHashForBook = DSL.`when`(pagesCount.lt(neededHash), pagesCount).otherwise(neededHash)
|
||||
|
||||
val r = dsl.select(b.ID)
|
||||
return dsl.select(b.ID, b.SERIES_ID)
|
||||
.from(b)
|
||||
.leftJoin(p).on(b.ID.eq(p.BOOK_ID))
|
||||
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
|
||||
.where(b.LIBRARY_ID.eq(libraryId))
|
||||
.and(m.STATUS.eq(Media.Status.READY.name))
|
||||
.groupBy(b.ID)
|
||||
.and(m.MEDIA_TYPE.`in`(mediaTypes))
|
||||
.groupBy(b.ID, b.SERIES_ID)
|
||||
.having(hashedCount.lt(neededHashForBook))
|
||||
.fetch()
|
||||
|
||||
return r.getValues(b.ID)
|
||||
.map { Pair(it.value1(), it.value2()) }
|
||||
}
|
||||
|
||||
override fun getPagesSize(bookId: String): Int =
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package org.gotson.komga.infrastructure.jooq
|
||||
|
||||
import org.gotson.komga.domain.model.PageHash
|
||||
import org.gotson.komga.domain.model.PageHashKnown
|
||||
import org.gotson.komga.domain.model.PageHashMatch
|
||||
import org.gotson.komga.domain.model.PageHashUnknown
|
||||
import org.gotson.komga.domain.persistence.PageHashRepository
|
||||
import org.gotson.komga.jooq.Tables
|
||||
import org.gotson.komga.jooq.tables.records.PageHashRecord
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.impl.DSL
|
||||
import org.springframework.data.domain.Page
|
||||
|
|
@ -13,7 +15,10 @@ import org.springframework.data.domain.PageRequest
|
|||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.net.URL
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
@Component
|
||||
class PageHashDao(
|
||||
|
|
@ -22,8 +27,18 @@ class PageHashDao(
|
|||
|
||||
private val p = Tables.MEDIA_PAGE
|
||||
private val b = Tables.BOOK
|
||||
private val ph = Tables.PAGE_HASH
|
||||
private val pht = Tables.PAGE_HASH_THUMBNAIL
|
||||
|
||||
private val sorts = mapOf(
|
||||
private val sortsKnown = mapOf(
|
||||
"hash" to ph.HASH,
|
||||
"mediatype" to ph.MEDIA_TYPE,
|
||||
"fileSize" to ph.SIZE,
|
||||
"deleteCount" to ph.DELETE_COUNT,
|
||||
"deleteSize" to ph.SIZE * ph.DELETE_COUNT,
|
||||
)
|
||||
|
||||
private val sortsUnknown = mapOf(
|
||||
"hash" to p.FILE_HASH,
|
||||
"mediatype" to p.MEDIA_TYPE,
|
||||
"fileSize" to p.FILE_SIZE,
|
||||
|
|
@ -34,8 +49,37 @@ class PageHashDao(
|
|||
"pageNumber" to p.NUMBER,
|
||||
)
|
||||
|
||||
override fun findAllKnown(actions: List<PageHash.Action>?, pageable: Pageable): Page<PageHash> {
|
||||
TODO("Not yet implemented")
|
||||
override fun findKnown(pageHash: PageHash): PageHashKnown? =
|
||||
dsl.selectFrom(ph)
|
||||
.where(ph.HASH.eq(pageHash.hash))
|
||||
.and(ph.MEDIA_TYPE.eq(pageHash.mediaType))
|
||||
.apply {
|
||||
if (pageHash.size == null) and(ph.SIZE.isNull)
|
||||
else and(ph.SIZE.eq(pageHash.size))
|
||||
}
|
||||
.fetchOneInto(ph)
|
||||
?.toDomain()
|
||||
|
||||
override fun findAllKnown(actions: List<PageHashKnown.Action>?, pageable: Pageable): Page<PageHashKnown> {
|
||||
val query = dsl.selectFrom(ph)
|
||||
.apply { actions?.let { where(ph.ACTION.`in`(actions)) } }
|
||||
|
||||
val count = dsl.fetchCount(query)
|
||||
|
||||
val orderBy = pageable.sort.toOrderBy(sortsKnown)
|
||||
val items = query
|
||||
.orderBy(orderBy)
|
||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||
.fetch()
|
||||
.map { it.toDomain() }
|
||||
|
||||
val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted()
|
||||
return PageImpl(
|
||||
items,
|
||||
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
|
||||
else PageRequest.of(0, maxOf(count, 20), pageSort),
|
||||
count.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun findAllUnknown(pageable: Pageable): Page<PageHashUnknown> {
|
||||
|
|
@ -49,12 +93,25 @@ class PageHashDao(
|
|||
)
|
||||
.from(p)
|
||||
.where(p.FILE_HASH.ne(""))
|
||||
.and(
|
||||
DSL.notExists(
|
||||
dsl.selectOne()
|
||||
.from(ph)
|
||||
.where(ph.HASH.eq(p.FILE_HASH))
|
||||
.and(ph.MEDIA_TYPE.eq(p.MEDIA_TYPE))
|
||||
.and(
|
||||
ph.SIZE.eq(p.FILE_SIZE).or(
|
||||
ph.SIZE.isNull.and(p.FILE_SIZE.isNull),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(p.FILE_HASH, p.MEDIA_TYPE, p.FILE_SIZE)
|
||||
.having(DSL.count(p.BOOK_ID).gt(1))
|
||||
|
||||
val count = dsl.fetchCount(query)
|
||||
|
||||
val orderBy = pageable.sort.toOrderBy(sorts)
|
||||
val orderBy = pageable.sort.toOrderBy(sortsUnknown)
|
||||
val items = query
|
||||
.orderBy(orderBy)
|
||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||
|
|
@ -71,7 +128,7 @@ class PageHashDao(
|
|||
)
|
||||
}
|
||||
|
||||
override fun findMatchesByHash(pageHash: PageHashUnknown, pageable: Pageable): Page<PageHashMatch> {
|
||||
override fun findMatchesByHash(pageHash: PageHash, pageable: Pageable): Page<PageHashMatch> {
|
||||
val query = dsl.select(p.BOOK_ID, b.URL, p.NUMBER, p.FILE_NAME)
|
||||
.from(p)
|
||||
.leftJoin(b).on(p.BOOK_ID.eq(b.ID))
|
||||
|
|
@ -84,7 +141,7 @@ class PageHashDao(
|
|||
|
||||
val count = dsl.fetchCount(query)
|
||||
|
||||
val orderBy = pageable.sort.toOrderBy(sorts)
|
||||
val orderBy = pageable.sort.toOrderBy(sortsUnknown)
|
||||
val items = query
|
||||
.orderBy(orderBy)
|
||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||
|
|
@ -106,7 +163,57 @@ class PageHashDao(
|
|||
)
|
||||
}
|
||||
|
||||
override fun getKnownThumbnail(hash: String): ByteArray? {
|
||||
TODO("Not yet implemented")
|
||||
override fun getKnownThumbnail(pageHash: PageHash): ByteArray? =
|
||||
dsl.select(pht.THUMBNAIL)
|
||||
.from(pht)
|
||||
.where(pht.HASH.eq(pageHash.hash))
|
||||
.and(pht.MEDIA_TYPE.eq(pageHash.mediaType))
|
||||
.apply {
|
||||
if (pageHash.size == null) and(pht.SIZE.isNull)
|
||||
else and(pht.SIZE.eq(pageHash.size))
|
||||
}
|
||||
.fetchOne()?.value1()
|
||||
|
||||
@Transactional
|
||||
override fun insert(pageHash: PageHashKnown, thumbnail: ByteArray?) {
|
||||
dsl.insertInto(ph)
|
||||
.set(ph.HASH, pageHash.hash)
|
||||
.set(ph.MEDIA_TYPE, pageHash.mediaType)
|
||||
.set(ph.SIZE, pageHash.size)
|
||||
.set(ph.ACTION, pageHash.action.name)
|
||||
.execute()
|
||||
|
||||
if (thumbnail != null) {
|
||||
dsl.insertInto(pht)
|
||||
.set(pht.HASH, pageHash.hash)
|
||||
.set(pht.MEDIA_TYPE, pageHash.mediaType)
|
||||
.set(pht.SIZE, pageHash.size)
|
||||
.set(pht.THUMBNAIL, thumbnail)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(pageHash: PageHashKnown) {
|
||||
dsl.update(ph)
|
||||
.set(ph.ACTION, pageHash.action.name)
|
||||
.set(ph.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
|
||||
.where(ph.HASH.eq(pageHash.hash))
|
||||
.and(ph.MEDIA_TYPE.eq(pageHash.mediaType))
|
||||
.apply {
|
||||
if (pageHash.size == null) and(ph.SIZE.isNull)
|
||||
else and(ph.SIZE.eq(pageHash.size))
|
||||
}
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
private fun PageHashRecord.toDomain() =
|
||||
PageHashKnown(
|
||||
hash = hash,
|
||||
mediaType = mediaType,
|
||||
size = size,
|
||||
deleteCount = deleteCount,
|
||||
action = PageHashKnown.Action.valueOf(action),
|
||||
createdDate = createdDate.toCurrentTimeZone(),
|
||||
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import io.swagger.v3.oas.annotations.media.Content
|
|||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import org.gotson.komga.domain.model.PageHash
|
||||
import org.gotson.komga.domain.model.PageHashUnknown
|
||||
import org.gotson.komga.domain.model.PageHashKnown
|
||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||
import org.gotson.komga.domain.persistence.PageHashRepository
|
||||
import org.gotson.komga.domain.service.PageHashLifecycle
|
||||
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
|
||||
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
|
||||
import org.gotson.komga.interfaces.api.rest.dto.PageHashDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.PageHashCreationDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.PageHashKnownDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.PageHashMatchDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.PageHashUnknownDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.toDto
|
||||
|
|
@ -24,11 +25,13 @@ import org.springframework.security.access.prepost.PreAuthorize
|
|||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import javax.validation.Valid
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1/page-hashes", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
|
|
@ -40,16 +43,21 @@ class PageHashController(
|
|||
|
||||
@GetMapping
|
||||
@PageableAsQueryParam
|
||||
fun getPageHashes(
|
||||
@RequestParam(name = "action", required = false) actions: List<PageHash.Action>?,
|
||||
fun getKnownPageHashes(
|
||||
@RequestParam(name = "action", required = false) actions: List<PageHashKnown.Action>?,
|
||||
@Parameter(hidden = true) page: Pageable,
|
||||
): Page<PageHashDto> =
|
||||
): Page<PageHashKnownDto> =
|
||||
pageHashRepository.findAllKnown(actions, page).map { it.toDto() }
|
||||
|
||||
@GetMapping("/{hash}/thumbnail", produces = [MediaType.IMAGE_JPEG_VALUE])
|
||||
@GetMapping("/{pageHash}/thumbnail", produces = [MediaType.IMAGE_JPEG_VALUE])
|
||||
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
|
||||
fun getPageHashThumbnail(@PathVariable hash: String): ByteArray =
|
||||
pageHashRepository.getKnownThumbnail(hash) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
fun getKnownPageHashThumbnail(
|
||||
@PathVariable pageHash: String,
|
||||
@RequestParam("media_type") mediaType: String,
|
||||
@RequestParam("file_size") size: Long,
|
||||
): ByteArray =
|
||||
pageHashRepository.getKnownThumbnail(PageHash(pageHash, mediaType, size))
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@GetMapping("/unknown")
|
||||
@PageableAsQueryParam
|
||||
|
|
@ -67,11 +75,7 @@ class PageHashController(
|
|||
@Parameter(hidden = true) page: Pageable,
|
||||
): Page<PageHashMatchDto> =
|
||||
pageHashRepository.findMatchesByHash(
|
||||
PageHashUnknown(
|
||||
hash = pageHash,
|
||||
mediaType = mediaType,
|
||||
size = if (size < 0) null else size,
|
||||
),
|
||||
PageHash(pageHash, mediaType, size),
|
||||
page,
|
||||
).map { it.toDto() }
|
||||
|
||||
|
|
@ -84,11 +88,7 @@ class PageHashController(
|
|||
@RequestParam("resize") resize: Int? = null,
|
||||
): ResponseEntity<ByteArray> =
|
||||
pageHashLifecycle.getPage(
|
||||
PageHashUnknown(
|
||||
hash = pageHash,
|
||||
mediaType = mediaType,
|
||||
size = if (size < 0) null else size,
|
||||
),
|
||||
PageHash(pageHash, mediaType, size),
|
||||
resize,
|
||||
)?.let {
|
||||
ResponseEntity.ok()
|
||||
|
|
@ -98,7 +98,20 @@ class PageHashController(
|
|||
|
||||
@PutMapping
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
fun updatePageHash() {
|
||||
TODO()
|
||||
fun createKnownPageHash(
|
||||
@Valid @RequestBody pageHash: PageHashCreationDto,
|
||||
) {
|
||||
try {
|
||||
pageHashLifecycle.createOrUpdate(
|
||||
PageHashKnown(
|
||||
hash = pageHash.hash,
|
||||
mediaType = pageHash.mediaType,
|
||||
size = pageHash.size,
|
||||
action = pageHash.action,
|
||||
),
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package org.gotson.komga.interfaces.api.rest.dto
|
||||
|
||||
import org.gotson.komga.domain.model.PageHashKnown
|
||||
import javax.validation.constraints.NotBlank
|
||||
|
||||
data class PageHashCreationDto(
|
||||
@get:NotBlank val hash: String,
|
||||
@get:NotBlank val mediaType: String,
|
||||
val size: Long? = null,
|
||||
val action: PageHashKnown.Action,
|
||||
)
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
package org.gotson.komga.interfaces.api.rest.dto
|
||||
|
||||
import org.gotson.komga.domain.model.PageHash
|
||||
import org.gotson.komga.domain.model.PageHashKnown
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class PageHashDto(
|
||||
data class PageHashKnownDto(
|
||||
val hash: String,
|
||||
val mediaType: String,
|
||||
val size: Long?,
|
||||
val action: PageHash.Action,
|
||||
val action: PageHashKnown.Action,
|
||||
val deleteCount: Int,
|
||||
|
||||
val created: LocalDateTime,
|
||||
val lastModified: LocalDateTime,
|
||||
)
|
||||
|
||||
fun PageHash.toDto() = PageHashDto(
|
||||
fun PageHashKnown.toDto() = PageHashKnownDto(
|
||||
hash = hash,
|
||||
mediaType = mediaType,
|
||||
size = size,
|
||||
|
|
@ -1,20 +1,17 @@
|
|||
package org.gotson.komga.interfaces.api.rest.dto
|
||||
|
||||
import com.jakewharton.byteunits.BinaryByteUnit
|
||||
import org.gotson.komga.domain.model.PageHashUnknown
|
||||
|
||||
data class PageHashUnknownDto(
|
||||
val hash: String,
|
||||
val mediaType: String,
|
||||
val sizeBytes: Long?,
|
||||
val size: String? = sizeBytes?.let { BinaryByteUnit.format(it) },
|
||||
val totalSize: String ? = sizeBytes?.let { BinaryByteUnit.format(it * matchCount) },
|
||||
val size: Long?,
|
||||
val matchCount: Int,
|
||||
)
|
||||
|
||||
fun PageHashUnknown.toDto() = PageHashUnknownDto(
|
||||
hash = hash,
|
||||
mediaType = mediaType,
|
||||
sizeBytes = size,
|
||||
size = size,
|
||||
matchCount = matchCount,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PageHashTest {
|
||||
|
||||
@Test
|
||||
fun `given negative size when creating a PageHash then its size is null`() {
|
||||
val pageHash = PageHash("abc", "image/jpeg", -5)
|
||||
|
||||
assertThat(pageHash.size).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given null size when creating a PageHash then its size is null`() {
|
||||
val pageHash = PageHash("abc", "image/jpeg", null)
|
||||
|
||||
assertThat(pageHash.size).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given size when creating a PageHash then its size is not null`() {
|
||||
val pageHash = PageHash("abc", "image/jpeg", 5)
|
||||
|
||||
assertThat(pageHash.size).isNotNull
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package org.gotson.komga.domain.service
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.catchThrowable
|
||||
import org.gotson.komga.domain.model.PageHashKnown
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
class PageHashLifecycleTest(
|
||||
@Autowired private val pageHashLifecycle: PageHashLifecycle,
|
||||
) {
|
||||
|
||||
@Test
|
||||
fun `given a page hash without size and action DELETE_AUTO when creating it then IllegalArgumentException is thrown`() {
|
||||
// given
|
||||
val pageHash = PageHashKnown(
|
||||
hash = "abcdef",
|
||||
mediaType = "image/jpeg",
|
||||
size = null,
|
||||
action = PageHashKnown.Action.DELETE_AUTO,
|
||||
)
|
||||
|
||||
// when
|
||||
val thrown = catchThrowable { pageHashLifecycle.createOrUpdate(pageHash) }
|
||||
|
||||
// then
|
||||
assertThat(thrown).isExactlyInstanceOf(IllegalArgumentException::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import org.assertj.core.api.Assertions.catchThrowable
|
|||
import org.gotson.komga.domain.model.BookPage
|
||||
import org.gotson.komga.domain.model.Dimension
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
import org.gotson.komga.domain.model.makeLibrary
|
||||
import org.gotson.komga.domain.model.makeSeries
|
||||
|
|
@ -213,19 +214,40 @@ class MediaDaoTest(
|
|||
mediaType = "image/jpeg",
|
||||
),
|
||||
),
|
||||
mediaType = MediaType.ZIP.value,
|
||||
bookId = book.id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
|
||||
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
|
||||
val found = mediaDao.findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.value), komgaProperties.pageHashing)
|
||||
|
||||
assertThat(found)
|
||||
.hasSize(1)
|
||||
.containsOnly(book.id)
|
||||
.containsOnly(Pair(book.id, book.seriesId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given media with no pages hashed when finding for missing page hash then it is returned`() {
|
||||
fun `given non-convertible media not hashed when finding for missing page hash then it is returned`() {
|
||||
val media = Media(
|
||||
status = Media.Status.READY,
|
||||
pages = listOf(
|
||||
BookPage(
|
||||
fileName = "1.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
),
|
||||
),
|
||||
mediaType = MediaType.RAR_4.value,
|
||||
bookId = book.id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
|
||||
val found = mediaDao.findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.value), komgaProperties.pageHashing)
|
||||
|
||||
assertThat(found).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given media with no pages hashed when finding for missing page hash then it is not returned`() {
|
||||
val media = Media(
|
||||
status = Media.Status.READY,
|
||||
pages = (1..12).map {
|
||||
|
|
@ -234,15 +256,16 @@ class MediaDaoTest(
|
|||
mediaType = "image/jpeg",
|
||||
)
|
||||
},
|
||||
mediaType = MediaType.ZIP.value,
|
||||
bookId = book.id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
|
||||
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
|
||||
val found = mediaDao.findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.value), komgaProperties.pageHashing)
|
||||
|
||||
assertThat(found)
|
||||
.hasSize(1)
|
||||
.containsOnly(book.id)
|
||||
.containsOnly(Pair(book.id, book.seriesId))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -256,11 +279,12 @@ class MediaDaoTest(
|
|||
fileHash = "hashed",
|
||||
),
|
||||
),
|
||||
mediaType = MediaType.ZIP.value,
|
||||
bookId = book.id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
|
||||
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
|
||||
val found = mediaDao.findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.value), komgaProperties.pageHashing)
|
||||
|
||||
assertThat(found).isEmpty()
|
||||
}
|
||||
|
|
@ -276,11 +300,12 @@ class MediaDaoTest(
|
|||
fileHash = if (it <= 3 || it >= 9) "hashed" else "",
|
||||
)
|
||||
},
|
||||
mediaType = MediaType.ZIP.value,
|
||||
bookId = book.id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
|
||||
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
|
||||
val found = mediaDao.findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.value), komgaProperties.pageHashing)
|
||||
|
||||
assertThat(found).isEmpty()
|
||||
}
|
||||
|
|
@ -296,11 +321,12 @@ class MediaDaoTest(
|
|||
fileHash = "hashed",
|
||||
)
|
||||
},
|
||||
mediaType = MediaType.ZIP.value,
|
||||
bookId = book.id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
|
||||
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
|
||||
val found = mediaDao.findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.value), komgaProperties.pageHashing)
|
||||
|
||||
assertThat(found).isEmpty()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
package org.gotson.komga.infrastructure.jooq
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.BookPage
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.PageHashKnown
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
import org.gotson.komga.domain.model.makeLibrary
|
||||
import org.gotson.komga.domain.model.makeSeries
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
class PageHashDaoTest(
|
||||
@Autowired private val pageHashDao: PageHashDao,
|
||||
@Autowired private val mediaDao: MediaDao,
|
||||
@Autowired private val bookRepository: BookRepository,
|
||||
@Autowired private val seriesRepository: SeriesRepository,
|
||||
@Autowired private val libraryRepository: LibraryRepository,
|
||||
) {
|
||||
private val library = makeLibrary()
|
||||
private val series = makeSeries("Series", libraryId = library.id)
|
||||
private val books = listOf(
|
||||
makeBook("Book", libraryId = library.id, seriesId = series.id),
|
||||
makeBook("Book2", libraryId = library.id, seriesId = series.id),
|
||||
)
|
||||
|
||||
@BeforeAll
|
||||
fun setup() {
|
||||
libraryRepository.insert(library)
|
||||
seriesRepository.insert(series)
|
||||
bookRepository.insert(books)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun deleteMedia() {
|
||||
bookRepository.findAll().forEach {
|
||||
mediaDao.delete(it.id)
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun tearDown() {
|
||||
bookRepository.deleteAll()
|
||||
seriesRepository.deleteAll()
|
||||
libraryRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Known {
|
||||
@Test
|
||||
fun `given a known page hash when inserting then it is persisted`() {
|
||||
val now = LocalDateTime.now()
|
||||
val pageHash = PageHashKnown(
|
||||
hash = "hashed",
|
||||
mediaType = "image/jpeg",
|
||||
size = 10,
|
||||
action = PageHashKnown.Action.IGNORE,
|
||||
)
|
||||
|
||||
pageHashDao.insert(pageHash, null)
|
||||
val known = pageHashDao.findKnown(pageHash)!!
|
||||
|
||||
assertThat(known.hash).isEqualTo(pageHash.hash)
|
||||
assertThat(known.mediaType).isEqualTo(pageHash.mediaType)
|
||||
assertThat(known.size).isEqualTo(pageHash.size)
|
||||
assertThat(known.action).isEqualTo(pageHash.action)
|
||||
assertThat(known.createdDate).isCloseTo(now, offset)
|
||||
assertThat(known.lastModifiedDate).isCloseTo(now, offset)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Unknown {
|
||||
@Test
|
||||
fun `given known hashes when finding unknown then known are not included`() {
|
||||
// given
|
||||
pageHashDao.insert(
|
||||
PageHashKnown(
|
||||
hash = "hash-1",
|
||||
mediaType = "image/jpeg",
|
||||
size = 1,
|
||||
action = PageHashKnown.Action.IGNORE,
|
||||
),
|
||||
null,
|
||||
)
|
||||
|
||||
pageHashDao.insert(
|
||||
PageHashKnown(
|
||||
hash = "hash-2",
|
||||
mediaType = "image/jpeg",
|
||||
size = null,
|
||||
action = PageHashKnown.Action.IGNORE,
|
||||
),
|
||||
null,
|
||||
)
|
||||
|
||||
val media = Media(
|
||||
status = Media.Status.READY,
|
||||
mediaType = "application/zip",
|
||||
pages = (1..10).map {
|
||||
BookPage(
|
||||
fileName = "$it.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
fileHash = "hash-$it",
|
||||
fileSize = it.toLong(),
|
||||
)
|
||||
},
|
||||
files = listOf("ComicInfo.xml"),
|
||||
comment = "comment",
|
||||
bookId = books.first().id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
mediaDao.insert(media.copy(bookId = books.last().id))
|
||||
|
||||
// when
|
||||
val unknown = pageHashDao.findAllUnknown(Pageable.unpaged())
|
||||
|
||||
// then
|
||||
assertThat(unknown).hasSize(9)
|
||||
assertThat(unknown.map { it.hash })
|
||||
.doesNotContain("hash-1")
|
||||
.containsExactlyInAnyOrderElementsOf((2..10).map { "hash-$it" })
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue