feat: page hashing enhancement

only hash pages for cbz
delete non-cbz page hashes
store page hashes
This commit is contained in:
Gauthier Roebroeck 2022-02-07 12:30:29 +08:00
parent 368d0d5147
commit a96335dbee
34 changed files with 1189 additions and 165 deletions

View file

@ -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",

View file

@ -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",

View 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>

View file

@ -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

View 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>

View file

@ -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)
}

View file

@ -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}`
}

View file

@ -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",

View file

@ -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',

View file

@ -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}`
}

View file

@ -0,0 +1,5 @@
export enum PageHashAction {
DELETE_AUTO = 'DELETE_AUTO',
DELETE_MANUAL = 'DELETE_MANUAL',
IGNORE = 'IGNORE',
}

View file

@ -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,
}

View 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>

View 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>

View file

@ -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>

View file

@ -1,6 +1,6 @@
<template>
<div>
<v-tabs>
<v-tabs grow>
<v-tab :to="{name: 'settings-analysis'}">
<v-badge
dot

View file

@ -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'
);

View file

@ -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))
}
}

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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)

View file

@ -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>>

View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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 =

View file

@ -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(),
)

View file

@ -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)
}
}
}

View file

@ -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,
)

View file

@ -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,

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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" })
}
}
}