diff --git a/.github/workflows/build-unified-image.yml b/.github/workflows/build-unified-image.yml index 35f8bb9..9b20421 100644 --- a/.github/workflows/build-unified-image.yml +++ b/.github/workflows/build-unified-image.yml @@ -2,8 +2,6 @@ name: Build and Publish Unified Docker Image on: push: - branches: - - main tags: - 'v*' workflow_dispatch: @@ -62,6 +60,7 @@ jobs: - name: Capture version information id: version run: | + echo "app_version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT echo "git_commit=$(git rev-parse --short=7 HEAD)" >> $GITHUB_OUTPUT echo "build_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> $GITHUB_OUTPUT @@ -77,6 +76,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max build-args: | + APP_VERSION=${{ steps.version.outputs.app_version }} GIT_COMMIT=${{ steps.version.outputs.git_commit }} BUILD_DATE=${{ steps.version.outputs.build_date }} @@ -110,12 +110,12 @@ jobs: -d '{ "embeds": [{ "title": "📦 Docker Image Published", - "description": "A new version of **ReadMeABook** has been built and published to GitHub Container Registry.", + "description": "**ReadMeABook v${{ steps.version.outputs.app_version }}** has been built and published to GitHub Container Registry.", "color": 5763719, "fields": [ { - "name": "🏷️ Image Tag", - "value": "`sha-${{ steps.version.outputs.git_commit }}`", + "name": "🔖 Version", + "value": "`v${{ steps.version.outputs.app_version }}`", "inline": true }, { @@ -130,12 +130,12 @@ jobs: }, { "name": "📥 Pull Command", - "value": "```docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.git_commit }}```", + "value": "```docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest```", "inline": false } ], "footer": { - "text": "ReadMeABook CI/CD • Built with GitHub Actions" + "text": "ReadMeABook v${{ steps.version.outputs.app_version }} • Built with GitHub Actions" }, "timestamp": "${{ steps.version.outputs.build_date }}" }] diff --git a/README.md b/README.md index fe34745..c7f7995 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ docker compose up -d Open http://localhost:3030 and follow the setup wizard. +**Important:** Your download client (qBittorrent/SABnzbd) and RMAB must see files at the same path. See the [Volume Mapping Guide](documentation/deployment/volume-mapping.md) if downloads aren't being detected. + ## Screenshots image diff --git a/dockerfile.unified b/dockerfile.unified index 3d786ca..c78cba5 100644 --- a/dockerfile.unified +++ b/dockerfile.unified @@ -6,6 +6,7 @@ FROM node:20-bookworm AS base # Re-declare build arguments after FROM (ARGs before FROM are not available after) +ARG APP_VERSION=unknown ARG GIT_COMMIT=unknown ARG BUILD_DATE=unknown @@ -51,9 +52,11 @@ ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy?schema=public" RUN npx prisma generate # Set version environment variables for build and runtime +ENV NEXT_PUBLIC_APP_VERSION=${APP_VERSION} ENV NEXT_PUBLIC_GIT_COMMIT=${GIT_COMMIT} ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE} -ENV APP_VERSION=${GIT_COMMIT} +ENV APP_VERSION=${APP_VERSION} +ENV GIT_COMMIT=${GIT_COMMIT} ENV BUILD_DATE=${BUILD_DATE} # Build Next.js application diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 194b1e1..bec406e 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -101,6 +101,7 @@ - **Docker Compose setup (multi-container)** → [deployment/docker.md](deployment/docker.md) - **Unified container (all-in-one)** → [deployment/unified.md](deployment/unified.md) - **Environment variables, volumes** → [deployment/docker.md](deployment/docker.md) +- **Volume mapping (download clients)** → [deployment/volume-mapping.md](deployment/volume-mapping.md) - **Database setup (Prisma), migrations** → [deployment/docker.md](deployment/docker.md) ## Testing @@ -132,6 +133,8 @@ **"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure) **"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one) **"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md) +**"Why can't RMAB find my downloaded files?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md) +**"How do I set up volume mapping for qBittorrent/SABnzbd?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md) **"OAuth redirects to localhost / PUBLIC_URL not working"** → [backend/services/environment.md](backend/services/environment.md) **"What environment variables do I need?"** → [backend/services/environment.md](backend/services/environment.md) **"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md) diff --git a/documentation/deployment/volume-mapping.md b/documentation/deployment/volume-mapping.md new file mode 100644 index 0000000..2f776ea --- /dev/null +++ b/documentation/deployment/volume-mapping.md @@ -0,0 +1,138 @@ +# Volume Mapping Guide (Download Clients) + +**Status:** Reference | qBittorrent + SABnzbd path alignment with RMAB + +## The Golden Rule + +Both your download client and RMAB must see files at the **SAME path**. + +If qBittorrent saves to `/downloads/audiobook.m4b`, RMAB must also see it at `/downloads/audiobook.m4b` — not `/data/downloads/audiobook.m4b` or any other path. + +--- + +## Docker Compose Setup + +```yaml +services: + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + volumes: + - /path/on/host/downloads:/downloads # Download location + # ... other settings + + readmeabook: + image: ghcr.io/kikootwo/readmeabook:latest + volumes: + - /path/on/host/downloads:/downloads # SAME path as qBittorrent! + # ... other settings +``` + +**Key Points:** +- **Left side** (`/path/on/host`): Your actual server paths — can be different per container +- **Right side** (`/downloads`): Container paths — **MUST BE IDENTICAL** between download client and RMAB + +--- + +## RMAB Settings Configuration + +In the setup wizard or admin settings: + +| Setting | Value | Notes | +|---------|-------|-------| +| Download Directory | `/downloads` | Must match download client's save path | + +--- + +## Common Mistakes + +### Wrong: Different container paths + +```yaml +qbittorrent: + volumes: + - /host/downloads:/data/torrents # qBittorrent sees /data/torrents + +readmeabook: + volumes: + - /host/downloads:/downloads # RMAB sees /downloads +``` + +**Result:** RMAB can't find files — paths don't match inside containers. + +### Correct: Identical container paths + +```yaml +qbittorrent: + volumes: + - /host/downloads:/downloads # Both see /downloads + +readmeabook: + volumes: + - /host/downloads:/downloads # Both see /downloads +``` + +--- + +## Verification Checklist + +1. **Check download client settings:** + - qBittorrent: Web UI → Options → Downloads → Default Save Path + - SABnzbd: Config → Folders → Completed Download Folder + - Should be `/downloads` (or your mapped path) + +2. **Check RMAB settings:** + - Download Directory: Should be within the mapped volume, e.g., `/downloads/RMAB` + +3. **Test download:** + - Request an audiobook + - Check RMAB logs for path information + - Look for `organizePath` in logs — should show a valid path + +--- + +## Quick Example + +**Scenario:** Downloads on `/mnt/storage/downloads` + +```yaml +services: + qbittorrent: + volumes: + - /mnt/storage/downloads:/downloads + + readmeabook: + volumes: + - /mnt/storage/downloads:/downloads +``` + +**RMAB Settings:** +- Download Directory: `/downloads/RMAB` + +**qBittorrent Settings:** +- Default Save Path: `/downloads` + +Files will be found correctly because all paths align. + +--- + +## Remote Path Mapping (Advanced) + +**Note:** 99% of users don't need this. If your download client and RMAB run on the same machine or have access to the same file system (a NAS for example) with matching volume mounts (as shown above), skip this section. + +### When You Need It + +Remote path mapping is required when: +- Download client runs on a **totally remote** machine (separate from RMAB) +- Download client runs on the **host** while RMAB runs in Docker +- Download client and RMAB have **different volume mount points** that can't be aligned + +### How It Works + +When enabled, RMAB translates paths reported by the download client: + +``` +Download client reports: /data/torrents/audiobook.m4b (remotePath) +RMAB translates to: /downloads/audiobook.m4b (localPath) +``` + +--- \ No newline at end of file diff --git a/package.json b/package.json index cf966c1..cf6c8b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "readmeabook", - "version": "0.1.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index 051023f..1beea83 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -6,18 +6,14 @@ import { NextResponse } from 'next/server'; export async function GET() { - const gitCommit = process.env.APP_VERSION || 'unknown'; + const appVersion = process.env.APP_VERSION || 'unknown'; + const gitCommit = process.env.GIT_COMMIT || 'unknown'; const buildDate = process.env.BUILD_DATE || 'unknown'; - // Get short commit hash (first 7 characters) - const shortCommit = gitCommit !== 'unknown' && gitCommit.length >= 7 - ? gitCommit.substring(0, 7) - : gitCommit; - return NextResponse.json({ - version: `v.${shortCommit}`, + version: appVersion !== 'unknown' ? `v${appVersion}` : 'vDEV', + fullVersion: appVersion, commit: gitCommit, - shortCommit, buildDate, }); } diff --git a/src/components/ui/VersionBadge.tsx b/src/components/ui/VersionBadge.tsx index a290183..4ee0200 100644 --- a/src/components/ui/VersionBadge.tsx +++ b/src/components/ui/VersionBadge.tsx @@ -9,27 +9,35 @@ import React, { useEffect, useState } from 'react'; export function VersionBadge() { const [version, setVersion] = useState(null); + const [commit, setCommit] = useState(null); useEffect(() => { // Try to get version from build-time env var first (instant, no API call) - const buildTimeVersion = process.env.NEXT_PUBLIC_GIT_COMMIT; + const buildTimeVersion = process.env.NEXT_PUBLIC_APP_VERSION; if (buildTimeVersion && buildTimeVersion !== 'unknown') { - // Get short commit hash (first 7 characters) - const shortCommit = buildTimeVersion.length >= 7 - ? buildTimeVersion.substring(0, 7) - : buildTimeVersion; - setVersion(`v.${shortCommit}`); + setVersion(`v${buildTimeVersion}`); + // Also get commit for tooltip if available + const buildTimeCommit = process.env.NEXT_PUBLIC_GIT_COMMIT; + if (buildTimeCommit && buildTimeCommit !== 'unknown') { + const shortCommit = buildTimeCommit.length >= 7 + ? buildTimeCommit.substring(0, 7) + : buildTimeCommit; + setCommit(shortCommit); + } } else { // Fallback to API call if build-time env var is not available fetch('/api/version') .then((res) => res.json()) .then((data) => { setVersion(data.version); + if (data.commit && data.commit !== 'unknown') { + setCommit(data.commit.substring(0, 7)); + } }) .catch((error) => { console.error('Failed to fetch version:', error); - setVersion('v.dev'); + setVersion('vDEV'); }); } }, []); @@ -38,10 +46,12 @@ export function VersionBadge() { return null; } + const tooltipText = commit ? `${version} (${commit})` : version; + return (
{version} diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 398cd16..f2afe9d 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -215,7 +215,10 @@ export class AudibleService { logger.info(` Fetching page ${page}/${maxPages}...`); const response = await this.fetchWithRetry('/adblbestsellers', { - params: page > 1 ? { page } : {}, + params: { + ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects + ...(page > 1 ? { page } : {}), + }, }); const $ = cheerio.load(response.data); @@ -307,7 +310,10 @@ export class AudibleService { logger.info(` Fetching page ${page}/${maxPages}...`); const response = await this.fetchWithRetry('/newreleases', { - params: page > 1 ? { page } : {}, + params: { + ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects + ...(page > 1 ? { page } : {}), + }, }); const $ = cheerio.load(response.data); @@ -392,6 +398,7 @@ export class AudibleService { const response = await this.fetchWithRetry('/search', { params: { + ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects keywords: query, page, }, @@ -572,7 +579,11 @@ export class AudibleService { */ private async scrapeAudibleDetails(asin: string): Promise { try { - const response = await this.fetchWithRetry(`/pd/${asin}`); + const response = await this.fetchWithRetry(`/pd/${asin}`, { + params: { + ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects + }, + }); const $ = cheerio.load(response.data); // Initialize result object diff --git a/tests/api/system.routes.test.ts b/tests/api/system.routes.test.ts index fdbb1eb..92179ec 100644 --- a/tests/api/system.routes.test.ts +++ b/tests/api/system.routes.test.ts @@ -57,16 +57,17 @@ describe('System routes', () => { }); it('returns version info from environment', async () => { - process.env.APP_VERSION = 'abcdef123456'; + process.env.APP_VERSION = '1.0.0'; + process.env.GIT_COMMIT = 'abcdef123456'; process.env.BUILD_DATE = '2025-01-01'; const { GET } = await import('@/app/api/version/route'); const response = await GET(); const payload = await response.json(); - expect(payload.shortCommit).toBe('abcdef1'); + expect(payload.version).toBe('v1.0.0'); + expect(payload.fullVersion).toBe('1.0.0'); + expect(payload.commit).toBe('abcdef123456'); expect(payload.buildDate).toBe('2025-01-01'); }); }); - - diff --git a/tests/components/ui/VersionBadge.test.tsx b/tests/components/ui/VersionBadge.test.tsx index e2cbb0b..93e6568 100644 --- a/tests/components/ui/VersionBadge.test.tsx +++ b/tests/components/ui/VersionBadge.test.tsx @@ -9,11 +9,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { VersionBadge } from '@/components/ui/VersionBadge'; +const originalVersion = process.env.NEXT_PUBLIC_APP_VERSION; const originalCommit = process.env.NEXT_PUBLIC_GIT_COMMIT; describe('VersionBadge', () => { afterEach(() => { vi.unstubAllGlobals(); + if (originalVersion === undefined) { + delete process.env.NEXT_PUBLIC_APP_VERSION; + } else { + process.env.NEXT_PUBLIC_APP_VERSION = originalVersion; + } if (originalCommit === undefined) { delete process.env.NEXT_PUBLIC_GIT_COMMIT; } else { @@ -21,32 +27,33 @@ describe('VersionBadge', () => { } }); - it('renders short version from build-time commit', async () => { + it('renders semantic version from build-time env var', async () => { + process.env.NEXT_PUBLIC_APP_VERSION = '1.0.0'; process.env.NEXT_PUBLIC_GIT_COMMIT = 'abcdef1234'; const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); render(); - expect(await screen.findByText('v.abcdef1')).toBeInTheDocument(); + expect(await screen.findByText('v1.0.0')).toBeInTheDocument(); expect(fetchMock).not.toHaveBeenCalled(); }); - it('falls back to API when build-time commit is unavailable', async () => { - process.env.NEXT_PUBLIC_GIT_COMMIT = 'unknown'; + it('falls back to API when build-time version is unavailable', async () => { + process.env.NEXT_PUBLIC_APP_VERSION = 'unknown'; const fetchMock = vi.fn().mockResolvedValue({ - json: async () => ({ version: 'v.1.2.3' }), + json: async () => ({ version: 'v1.2.3', commit: 'abc1234' }), }); vi.stubGlobal('fetch', fetchMock); render(); - expect(await screen.findByText('v.1.2.3')).toBeInTheDocument(); + expect(await screen.findByText('v1.2.3')).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith('/api/version'); }); it('shows dev version when API fetch fails', async () => { - process.env.NEXT_PUBLIC_GIT_COMMIT = 'unknown'; + process.env.NEXT_PUBLIC_APP_VERSION = 'unknown'; const fetchMock = vi.fn().mockRejectedValue(new Error('down')); const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); vi.stubGlobal('fetch', fetchMock); @@ -54,7 +61,7 @@ describe('VersionBadge', () => { render(); await waitFor(() => { - expect(screen.getByText('v.dev')).toBeInTheDocument(); + expect(screen.getByText('vDEV')).toBeInTheDocument(); }); expect(errorMock).toHaveBeenCalledWith('Failed to fetch version:', expect.any(Error)); });