Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,13 @@ daily downloads; a book whose file is already in the library is not downloaded
again.

Z-Library has no official API: Shelfloom uses the one its apps use, which can
change. Its domain moves too; set `SHELFLOOM_ZLIBRARY_DOMAIN` to one that
works from your server (`HTTPS_PROXY` is honoured if it needs a proxy).
change. Its domain moves too: **Settings → Z-Library** lists its published
domains together with the current list Z-Library's own API gives out, checks
which ones answer from your server, and lets you switch domain or add a new
one. Take new domains only from Z-Library's own
channels: lookalike domains exist to steal account passwords.
`SHELFLOOM_ZLIBRARY_DOMAIN` is only the starting choice (`HTTPS_PROXY` is
honoured if the server needs a proxy).

### KOReader Sync and the web reader

Expand Down Expand Up @@ -198,7 +203,7 @@ npm run lint # gofmt + go vet, eslint
| `HOMEPAGE_URL` | | Foyer address for the sidebar link |
| `SHELFLOOM_ZLIBRARY_EMAIL` | | Z-Library account email |
| `SHELFLOOM_ZLIBRARY_PASSWORD` | | Z-Library account password |
| `SHELFLOOM_ZLIBRARY_DOMAIN` | `z-library.ec` | Z-Library domain (or base URL) |
| `SHELFLOOM_ZLIBRARY_DOMAIN` | `z-library.hn` | Z-Library domain until one is picked in Settings |
| `TZ` | | Time zone stats are grouped in |

Databases from the earlier Python-based releases open as they are, as long as
Expand Down
2 changes: 1 addition & 1 deletion cmd/shelfloom/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func main() {
ScanInterval: seconds("SHELFLOOM_SCAN_INTERVAL", 300),
SerialCheckInterval: seconds("SHELFLOOM_SERIAL_CHECK_INTERVAL", 86400),
FoyerURL: os.Getenv("HOMEPAGE_URL"),
ZLibraryDomain: env("SHELFLOOM_ZLIBRARY_DOMAIN", "z-library.ec"),
ZLibraryDomain: env("SHELFLOOM_ZLIBRARY_DOMAIN", "z-library.hn"),
ZLibraryEmail: os.Getenv("SHELFLOOM_ZLIBRARY_EMAIL"),
ZLibraryPassword: os.Getenv("SHELFLOOM_ZLIBRARY_PASSWORD"),
},
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/ZLibraryModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('ZLibraryModal', () => {
if (u === '/api/zlibrary/status') {
body = {
enabled: true,
domain: 'z-library.ec',
domain: 'z-library.hn',
downloads_today: 3,
downloads_limit: 10,
error: null,
Expand Down
153 changes: 153 additions & 0 deletions frontend/src/__tests__/ZLibrarySettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ZLibrarySettings from '../components/settings/ZLibrarySettings'

describe('ZLibrarySettings', () => {
let fetchSpy: {
mockRestore: () => void
mock: { calls: [RequestInfo | URL, RequestInit | undefined][] }
}
let state: {
selected: string
default: string
enabled: boolean
domains: { domain: string; source: 'official' | 'zlibrary' | 'custom' }[]
fetched_from: string | null
fetched_at: number | null
}

beforeEach(() => {
state = {
selected: 'z-library.hn',
default: 'z-library.hn',
enabled: true,
domains: [
{ domain: 'z-library.sk', source: 'official' },
{ domain: 'z-library.hn', source: 'official' },
],
fetched_from: null,
fetched_at: null,
}
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((url, opts) => {
const u = url.toString()
const method = (opts?.method ?? 'GET').toUpperCase()
const sent = opts?.body ? JSON.parse(opts.body as string) : {}
let body: unknown = state
let status = 200
if (u.startsWith('/api/zlibrary/domains/check?')) {
const d = new URL(u, 'http://x').searchParams.get('domain')!
body =
d === 'z-library.sk'
? { domain: d, ok: false, ms: 0, error: 'behind a bot check' }
: { domain: d, ok: true, ms: 420, error: null }
} else if (u === '/api/zlibrary/domains/refresh') {
state = {
...state,
domains: [
...state.domains,
{ domain: 'articles.sk', source: 'zlibrary' },
],
fetched_from: 'z-library.hn',
fetched_at: 1791700000,
}
body = state
} else if (u === '/api/zlibrary/domain' && method === 'PUT') {
state = { ...state, selected: sent.domain }
body = state
} else if (u === '/api/zlibrary/domains' && method === 'POST') {
status = 201
state = {
...state,
domains: [
...state.domains,
{ domain: sent.domain, source: 'custom' },
],
}
body = state
} else if (
u.startsWith('/api/zlibrary/domains/') &&
method === 'DELETE'
) {
state = {
...state,
domains: state.domains.filter((d) => d.source !== 'custom'),
}
body = state
}
return Promise.resolve({
ok: true,
status,
json: async () => body,
}) as Promise<Response>
}) as unknown as typeof fetchSpy
})

afterEach(() => {
fetchSpy.mockRestore()
})

it('checks every domain and shows which one is in use', async () => {
render(<ZLibrarySettings />)
expect(
(await screen.findAllByText('Works · 420 ms')).length
).toBeGreaterThan(0)
expect(screen.getByText('behind a bot check')).toBeInTheDocument()
expect(
screen.getByRole('radio', { name: 'Use z-library.hn' })
).toHaveAttribute('aria-checked', 'true')
})

it('adds Z-Library’s own domain list and checks the new ones', async () => {
render(<ZLibrarySettings />)
expect(await screen.findByText('articles.sk')).toBeInTheDocument()
expect(screen.getByText('FROM Z-LIBRARY')).toBeInTheDocument()
expect(screen.getByTestId('zlibrary-fetched')).toHaveTextContent(
'from z-library.hn'
)
await waitFor(() =>
expect(
fetchSpy.mock.calls.some(([u]) =>
u.toString().includes('check?domain=articles.sk')
)
).toBe(true)
)
})

it('switches, adds and removes domains', async () => {
render(<ZLibrarySettings />)
fireEvent.click(
await screen.findByRole('radio', { name: 'Use z-library.sk' })
)
await waitFor(() =>
expect(
screen.getByRole('radio', { name: 'Use z-library.sk' })
).toHaveAttribute('aria-checked', 'true')
)

fireEvent.change(screen.getByLabelText('New domain'), {
target: { value: 'libb.example' },
})
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(await screen.findByText('libb.example')).toBeInTheDocument()
await waitFor(() =>
expect(
fetchSpy.mock.calls.some(([u]) =>
u.toString().includes('check?domain=libb.example')
)
).toBe(true)
)

fireEvent.click(screen.getByRole('button', { name: 'Remove libb.example' }))
await waitFor(() =>
expect(screen.queryByText('libb.example')).not.toBeInTheDocument()
)
})

it('says how to turn Z-Library on when no account is set', async () => {
state.enabled = false
render(<ZLibrarySettings />)
expect(
await screen.findByText(/Z-Library is off until an account is set/)
).toBeInTheDocument()
})
})
8 changes: 8 additions & 0 deletions frontend/src/components/library/ZLibraryModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ export default function ZLibraryModal({ onClose, onAdded }: Props) {
{error && (
<p className="text-xs text-red-400 border border-red-400/20 bg-red-400/5 px-3 py-2 normal-case">
{error}
{/domain|reach/i.test(error) && (
<>
{' '}
<Link to="/settings" className="font-semibold underline">
Change domain in Settings
</Link>
</>
)}
</p>
)}
{notice && (
Expand Down
Loading
Loading