diff --git a/index.json b/index.json index c20e7cf..db7c908 100644 --- a/index.json +++ b/index.json @@ -109,5 +109,11 @@ "fileName": "lanraragi.js", "key": "lanraragi", "version": "1.1.0" + }, + { + "name": "Komga", + "fileName": "komga.js", + "key": "komga", + "version": "1.0.0" } ] diff --git a/komga.js b/komga.js new file mode 100644 index 0000000..744a6d8 --- /dev/null +++ b/komga.js @@ -0,0 +1,752 @@ +/** @type {import('./_venera_.js')} */ +class Komga extends ComicSource { + name = "Komga" + + key = "komga" + + version = "1.0.0" + + minAppVersion = "1.4.0" + + url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/komga.js" + + settings = { + base_url: { + title: "服务器地址", + type: "input", + default: "https://demo.komga.org", + validator: "^(https?:\\/\\/).+$" + }, + // default_username: { + // title: "默认账号", + // type: "input", + // default: "demo@komga.org" + // }, + // default_password: { + // title: "默认密码", + // type: "input", + // default: "komga-demo" + // } + } + + get baseUrl() { + let raw = this.loadSetting('base_url') + if (typeof raw !== 'string' || !raw.trim()) { + raw = this.settings.base_url.default + } + let value = raw.trim() + if (!/^https?:\/\//i.test(value)) { + value = `https://${value}` + } + return value.replace(/\/$/, '') + } + + get authToken() { + const stored = this.loadData('komga_auth') + if (stored) { + return stored + } + const username = this.loadSetting('default_username') + const password = this.loadSetting('default_password') + if (!username || !password) { + return null + } + const encoded = Convert.encodeBase64(Convert.encodeUtf8(`${username}:${password}`)) + return typeof encoded === 'string' ? encoded : Convert.decodeUtf8(encoded) + } + + get headers() { + const headers = { "Accept": "application/json" } + const token = this.authToken + if (token) headers["Authorization"] = `Basic ${token}` + return headers + } + + get imageHeaders() { + const token = this.authToken + return token ? { "Authorization": `Basic ${token}` } : {} + } + + async init() { + try { + await this.refreshReferenceData(false) + } catch (_) { + } + } + + account = { + login: async (account, pwd) => { + if (!account || !pwd) { + throw '账号或密码不能为空' + } + const basic = Convert.encodeBase64(Convert.encodeUtf8(`${account}:${pwd}`)) + const token = typeof basic === 'string' ? basic : Convert.decodeUtf8(basic) + const res = await Network.get( + this.buildUrl('/api/v2/users/me'), + { + "Accept": "application/json", + "Authorization": `Basic ${token}` + } + ) + if (res.status === 401) { + throw '账号或密码错误' + } + if (res.status !== 200) { + throw `登录失败: ${res.status}` + } + this.saveData('komga_auth', token) + this.saveData('komga_account_email', account) + await this.refreshReferenceData(true) + return account + }, + logout: () => { + this.deleteData('komga_auth') + this.deleteData('komga_account_email') + this.deleteData('komga_libraries') + this.deleteData('komga_tags') + this.deleteData('komga_genres') + this.deleteData('komga_languages') + this.deleteData('komga_collections') + this.deleteData('komga_meta_ts') + }, + registerWebsite: null + } + + explore = [ + { + title: "Komga", + type: "singlePageWithMultiPart", + load: async () => { + await this.refreshReferenceData(false) + const feeds = {} + const latest = await this.fetchSeriesList('/api/v1/series/latest', { size: 12, page: 0 }) + if (latest.comics.length) feeds["最新上架"] = latest.comics + const updated = await this.fetchSeriesList('/api/v1/series/updated', { size: 12, page: 0 }) + if (updated.comics.length) feeds["最近更新"] = updated.comics + const libraries = this.loadData('komga_libraries') + if (Array.isArray(libraries)) { + for (const library of libraries.slice(0, 4)) { + const list = await this.fetchSeriesList('/api/v1/series', { + page: 0, + size: 12, + sort: ['metadata.lastModified,desc'], + library_id: [library.id] + }) + if (list.comics.length) feeds[`书库 ${library.name}`] = list.comics + } + } + if (!Object.keys(feeds).length) { + throw '未找到可展示的数据,请确认已登录且服务器可用' + } + return feeds + } + } + ] + + category = { + title: "Komga", + parts: [ + { + name: "常用", + type: "dynamic", + loader: () => ( + [ + { + label: "all", + target: { + page: 'category', + attributes: { + category: 'all', + param: null, + }, + }, + } + ] + ) + }, + { + name: "书库", + type: "dynamic", + loader: () => { + const libraries = this.loadData('komga_libraries') + if (!Array.isArray(libraries) || !libraries.length) { + return [] + } + return libraries.map((library) => ({ + label: library.name, + target: { + page: 'category', + attributes: { + category: 'library', + param: library.id, + }, + }, + })) + } + }, + { + name: "合集", + type: "dynamic", + loader: () => { + const collections = this.loadData('komga_collections') + if (!Array.isArray(collections) || !collections.length) { + return [] + } + return collections.map((collection) => ({ + label: collection.name, + target: { + page: 'category', + attributes: { + category: 'collection', + param: collection.id, + }, + }, + })) + } + }, + { + name: "标签", + type: "dynamic", + loader: () => { + const tags = this.loadData('komga_tags') + if (!Array.isArray(tags) || !tags.length) { + return [] + } + return tags.map((tag) => ({ + label: tag, + target: { + page: 'category', + attributes: { + category: 'tag', + param: tag, + }, + }, + })) + } + }, + { + name: "语言", + type: "dynamic", + loader: () => { + const languages = this.loadData('komga_languages') + if (!Array.isArray(languages) || !languages.length) { + return [] + } + return languages.map((lang) => ({ + label: lang, + target: { + page: 'category', + attributes: { + category: 'language', + param: lang, + }, + }, + })) + } + }, + { + name: "题材", + type: "dynamic", + loader: () => { + const genres = this.loadData('komga_genres') + if (!Array.isArray(genres) || !genres.length) { + return [] + } + return genres.map((genre) => ({ + label: genre, + target: { + page: 'category', + attributes: { + category: 'genre', + param: genre, + }, + }, + })) + } + } + ], + enableRankingPage: false, + } + + categoryComics = { + load: async (category, param, options, page) => { + await this.refreshReferenceData(false) + const pageIndex = Math.max(0, (page || 1) - 1) + const defaultSort = category === 'all' ? 'created,desc' : 'metadata.lastModified,desc' + const sortValue = this.extractOption(options, 0, defaultSort) + const query = { + page: pageIndex, + size: 30, + sort: [sortValue] + } + if (category === 'all') { + // const list = await this.fetchBookList('/api/v1/books', query) + // return { + // comics: list.comics, + // maxPage: Math.max(1, list.totalPages) + // } + const list = await this.fetchSeriesList('/api/v1/series', query) + return { + comics: list.comics, + maxPage: Math.max(1, list.totalPages) + } + } + if (category === 'library' && param) { + query.library_id = [param] + const list = await this.fetchSeriesList('/api/v1/series', query) + return { + comics: list.comics, + maxPage: Math.max(1, list.totalPages) + } + } + if (category === 'collection' && param) { + const list = await this.fetchSeriesList(`/api/v1/collections/${param}/series`, query) + return { + comics: list.comics, + maxPage: Math.max(1, list.totalPages) + } + } + + + if (category === 'tag' && param) { + query.tag = [param] + const list = await this.fetchSeriesList('/api/v1/series', query) + return { + comics: list.comics, + maxPage: Math.max(1, list.totalPages) + } + } + + + if (category === 'language' && param){ + query.language = [param] + const list = await this.fetchSeriesList('/api/v1/series', query) + return { + comics: list.comics, + maxPage: Math.max(1, list.totalPages) + } + } + + // if (category === 'genre' && param) query.genre = [param] + query.genre = [param] + const list = await this.fetchSeriesList('/api/v1/series', query) + + return { + comics: list.comics, + maxPage: Math.max(1, list.totalPages) + } + }, + optionList: [ + { + options: [ + '*created,desc-添加时间(新→旧)', + 'created,asc-添加时间(旧→新)', + 'metadata.lastModified,desc-更新时间(新→旧)', + 'metadata.lastModified,asc-更新时间(旧→新)', + 'metadata.titleSort,asc-标题(A-Z)', + 'metadata.titleSort,desc-标题(Z-A)' + ], + notShowWhen: null, + showWhen: null + } + ] + } + + search = { + load: async (keyword, options, page) => { + const pageIndex = Math.max(0, (page || 1) - 1) + const sortValue = this.extractOption(options, 0, 'metadata.lastModified,desc') + const query = { + page: pageIndex, + size: 30, + sort: [sortValue] + } + let term = (keyword || '').trim() + const colonIdx = term.indexOf(':') + if (colonIdx > 0) { + const prefix = term.slice(0, colonIdx).toLowerCase() + const value = term.slice(colonIdx + 1).trim() + if (value) { + if (prefix === 'tag') query.tag = [value] + else if (prefix === 'author') query.author = [`${value},`] + else if (prefix === 'language') query.language = [value] + else if (prefix === 'genre') query.genre = [value] + else if (prefix === 'publisher') query.publisher = [value] + else query.search = value + } + term = '' + } + if (term) query.search = term + const list = await this.fetchSeriesList('/api/v1/series', query) + return { + comics: list.comics, + maxPage: Math.max(1, list.totalPages) + } + }, + optionList: [ + { + type: 'select', + options: [ + '*metadata.lastModified,desc-更新时间(新→旧)', + 'metadata.lastModified,asc-更新时间(旧→新)', + 'metadata.titleSort,asc-标题(A-Z)', + 'metadata.titleSort,desc-标题(Z-A)' + ], + label: '排序', + default: null + } + ] + } + + comic = { + loadInfo: async (id) => { + const bookId = this.extractBookId(id) + if (bookId) { + return await this.loadBookDetails(bookId) + } + const [series, booksPage] = await Promise.all([ + this.getJson(`/api/v1/series/${id}`), + this.getJson(`/api/v1/series/${id}/books`, { + unpaged: true, + sort: ['metadata.numberSort,asc'] + }) + ]) + const books = Array.isArray(booksPage?.content) ? booksPage.content : [] + const readable = books.filter((book) => this.isSupportedBook(book)) + readable.sort((a, b) => this.compareBooks(a, b)) + const chapters = new Map() + readable.forEach((book, index) => { + chapters.set(book.id, this.formatBookTitle(book, index)) + }) + const metadata = series?.metadata || {} + const summary = series?.booksMetadata?.summary || metadata.summary || '' + const authors = this.collectAuthors(series?.booksMetadata?.authors) + const genres = Array.isArray(metadata.genres) ? metadata.genres : [] + const tags = Array.isArray(series?.booksMetadata?.tags) ? series.booksMetadata.tags : [] + const description = summary || '暂无简介' + const tagSections = {} + if (authors.length) tagSections['作者'] = authors + if (genres.length) tagSections['类型'] = this.uniqueArray(genres) + if (tags.length) tagSections['标签'] = this.uniqueArray(tags) + if (!readable.length && books.length) { + tagSections['提示'] = ['该系列包含的项目暂不支持阅读'] + } + const info = new ComicDetails({ + title: metadata.title || series?.name || id, + subTitle: authors.slice(0, 3).join(', '), + cover: this.buildUrl(`/api/v1/series/${id}/thumbnail`), + description, + tags: tagSections, + chapters, + updateTime: this.formatDate(series?.lastModified), + uploadTime: this.formatDate(series?.created), + url: series?.url || this.buildUrl(`/series/${id}`) + }) + return info + }, + loadEp: async (comicId, epId) => { + let bookId = epId || comicId + if (typeof bookId === 'string' && bookId.startsWith('book:')) { + bookId = bookId.slice(5) + } + if (typeof comicId === 'string' && comicId.startsWith('book:') && !epId) { + bookId = comicId.slice(5) + } + const pages = await this.getJson(`/api/v1/books/${bookId}/pages`) + const list = Array.isArray(pages) ? pages : [] + list.sort((a, b) => (a?.number ?? 0) - (b?.number ?? 0)) + const zeroBased = list.some((page) => (page?.number ?? 1) === 0) + const images = list + .filter((page) => this.isPageRenderable(page)) + .map((page) => { + const number = page?.number ?? 0 + return this.buildUrl(`/api/v1/books/${bookId}/pages/${number}`, zeroBased ? { zero_based: true } : null) + }) + return { images } + }, + onImageLoad: (url) => { + return { + headers: this.imageHeaders + } + }, + onThumbnailLoad: () => { + return { + headers: this.imageHeaders + } + }, + onClickTag: (namespace, tag) => { + if (!tag) throw '无效的标签' + const ns = (namespace || '').toLowerCase() + if (ns === '作者') { + return { + action: 'search', + keyword: `author:${tag}`, + param: null, + } + } + if (ns === '类型' || ns === '标签') { + return { + action: 'category', + keyword: `genre:${tag}`, + param: `${tag}`, + } + } + return { + action: 'search', + keyword: tag, + param: null, + } + }, + enableTagsTranslate: false, + } + + async refreshReferenceData(force) { + const token = this.authToken + if (!token) { + this.saveData('komga_libraries', []) + this.saveData('komga_tags', []) + this.saveData('komga_genres', []) + this.saveData('komga_languages', []) + this.saveData('komga_collections', []) + return + } + const now = Date.now() + const last = this.loadData('komga_meta_ts') + if (!force && last && now - last < 5 * 60 * 1000) return + try { + const [libraries, tags, languages, collections, genres] = await Promise.all([ + this.getJson('/api/v1/libraries'), + this.getJson('/api/v1/tags/series'), + this.getJson('/api/v1/languages'), + this.getJson('/api/v1/collections', { unpaged: true, sort: ['name,asc'] }), + this.getJson('/api/v1/genres') + ]) + const libraryList = Array.isArray(libraries) ? libraries.filter((library) => library && library.id) : [] + const collectionPage = collections && typeof collections === 'object' ? collections : null + const collectionList = Array.isArray(collectionPage?.content) ? collectionPage.content : Array.isArray(collections) ? collections : [] + this.saveData('komga_libraries', libraryList) + this.saveData('komga_tags', Array.isArray(tags) ? tags : []) + this.saveData('komga_genres', Array.isArray(genres) ? genres : []) + this.saveData('komga_languages', Array.isArray(languages) ? languages : []) + this.saveData('komga_collections', collectionList) + this.saveData('komga_meta_ts', now) + } catch (error) { + this.saveData('komga_libraries', []) + this.saveData('komga_tags', []) + this.saveData('komga_genres', []) + this.saveData('komga_languages', []) + this.saveData('komga_collections', []) + if (String(error) === 'Login expired') throw error + } + } + + async fetchSeriesList(path, query) { + const data = await this.getJson(path, query) + const content = Array.isArray(data?.content) ? data.content : [] + const comics = content.map((item) => this.parseSeries(item)).filter(Boolean) + return { + comics, + totalPages: data?.totalPages ?? 1 + } + } + + async fetchBookList(path, query) { + const data = await this.getJson(path, query) + const content = Array.isArray(data?.content) ? data.content : [] + const comics = content.map((item) => this.parseBook(item)).filter(Boolean) + return { + comics, + totalPages: data?.totalPages ?? 1 + } + } + + parseBook(book) { + if (!book || !this.isSupportedBook(book)) return null + const metadata = book.metadata || {} + const title = metadata.title || book.name || book.id + const authors = this.collectAuthors(metadata.authors) + const tags = Array.isArray(metadata.tags) ? metadata.tags : [] + const description = metadata.summary || '' + const subtitleParts = [] + if (book.seriesTitle) subtitleParts.push(book.seriesTitle) + if (authors.length) subtitleParts.push(authors[0]) + return new Comic({ + id: `book:${book.id}`, + title, + subTitle: subtitleParts.join(' · '), + cover: this.buildUrl(`/api/v1/books/${book.id}/thumbnail`), + tags: this.uniqueArray(tags).slice(0, 12), + description, + }) + } + + extractBookId(id) { + if (typeof id !== 'string') return null + return id.startsWith('book:') ? id.slice(5) : null + } + + async loadBookDetails(bookId) { + const book = await this.getJson(`/api/v1/books/${bookId}`) + if (!book) throw '未找到该图书' + const metadata = book.metadata || {} + const authors = this.collectAuthors(metadata.authors) + const tags = this.uniqueArray(Array.isArray(metadata.tags) ? metadata.tags : []) + const description = metadata.summary || '暂无简介' + const tagSections = {} + if (authors.length) tagSections['作者'] = authors + if (tags.length) tagSections['标签'] = tags + if (book.seriesTitle) tagSections['系列'] = [book.seriesTitle] + if (!this.isSupportedBook(book)) tagSections['提示'] = ['该图书暂不支持阅读'] + const chapters = new Map() + const chapterTitle = metadata.title || book.name || '立即阅读' + chapters.set(book.id, chapterTitle) + return new ComicDetails({ + title: metadata.title || book.name || bookId, + subTitle: book.seriesTitle || authors.slice(0, 3).join(', '), + cover: this.buildUrl(`/api/v1/books/${bookId}/thumbnail`), + description, + tags: tagSections, + chapters, + updateTime: this.formatDate(book.lastModified), + uploadTime: this.formatDate(book.created), + url: book.url || this.buildUrl(`/books/${bookId}`) + }) + } + + parseSeries(series) { + if (!series) return null + const metadata = series.metadata || {} + const title = metadata.title || series.name || series.id + const authors = this.collectAuthors(series?.booksMetadata?.authors) + const tags = [] + if (Array.isArray(metadata.genres)) tags.push(...metadata.genres) + if (Array.isArray(series?.booksMetadata?.tags)) tags.push(...series.booksMetadata.tags) + const description = series?.booksMetadata?.summary || metadata.summary || '' + return new Comic({ + id: series.id, + title, + subTitle: authors.slice(0, 2).join(', '), + cover: this.buildUrl(`/api/v1/series/${series.id}/thumbnail`), + tags: this.uniqueArray(tags).slice(0, 12), + description, + }) + } + + collectAuthors(authors) { + if (!Array.isArray(authors)) return [] + return this.uniqueArray(authors.map((author) => author?.name).filter(Boolean)) + } + + uniqueArray(list) { + if (!Array.isArray(list)) return [] + const set = new Set() + const result = [] + for (const item of list) { + const value = typeof item === 'string' ? item.trim() : '' + if (!value) continue + const key = value.toLowerCase() + if (set.has(key)) continue + set.add(key) + result.push(value) + } + return result + } + + isSupportedBook(book) { + if (!book || !book.media) return false + const status = String(book.media.status || '').toUpperCase() + if (status && status !== 'READY') return false + const mediaType = String(book.media.mediaType || '').toLowerCase() + if (!mediaType) return false + if (mediaType.includes('epub') || mediaType.includes('pdf') || mediaType.includes('mobi')) return false + if ((book.media.pagesCount || 0) <= 0) return false + return true + } + + isPageRenderable(page) { + if (!page) return false + const mediaType = String(page.mediaType || '').toLowerCase() + if (!mediaType) return true + return mediaType.startsWith('image/') || mediaType.includes('jpeg') || mediaType.includes('png') || mediaType.includes('webp') + } + + compareBooks(a, b) { + const aSort = typeof a?.metadata?.numberSort === 'number' ? a.metadata.numberSort : NaN + const bSort = typeof b?.metadata?.numberSort === 'number' ? b.metadata.numberSort : NaN + if (!Number.isNaN(aSort) && !Number.isNaN(bSort)) return aSort - bSort + const aNumber = parseFloat(a?.metadata?.number) + const bNumber = parseFloat(b?.metadata?.number) + if (!Number.isNaN(aNumber) && !Number.isNaN(bNumber)) return aNumber - bNumber + return (a?.metadata?.title || a?.name || '').localeCompare(b?.metadata?.title || b?.name || '') + } + + formatBookTitle(book, index) { + const metadata = book?.metadata || {} + if (metadata.title) return metadata.title + if (metadata.number) return `第${metadata.number}卷` + if (book?.number != null) return `第${book.number}卷` + return `章节 ${index + 1}` + } + + extractOption(options, index, fallback) { + if (!Array.isArray(options) || options.length <= index) return fallback + let value = options[index] + if (typeof value !== 'string') return fallback + if (value.startsWith('*')) value = value.slice(1) + const idx = value.indexOf('-') + return idx > -1 ? value.slice(0, idx) : value + } + + async getJson(path, query) { + const res = await Network.get(this.buildUrl(path, query), this.headers) + this.ensureOk(res) + const text = res.body + if (!text) return null + return JSON.parse(text) + } + + ensureOk(res) { + if (!res) throw '请求失败' + if (res.status === 401 || res.status === 403) throw 'Login expired' + if (res.status < 200 || res.status >= 300) throw `请求失败: ${res.status}` + } + + buildUrl(path, query) { + let url = path + if (!/^https?:\/\//i.test(path)) { + url = `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}` + } + const qs = this.buildQuery(query) + return qs ? `${url}?${qs}` : url + } + + buildQuery(query) { + if (!query) return '' + const parts = [] + for (const key of Object.keys(query)) { + const value = query[key] + if (value === undefined || value === null) continue + if (Array.isArray(value)) { + for (const item of value) { + if (item === undefined || item === null) continue + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`) + } + } else { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + } + } + return parts.join('&') + } + + formatDate(value) { + if (!value) return null + try { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + return date.toISOString().split('T')[0] + } catch (_) { + return null + } + } +} +