diff --git a/.github/workflows/close_blank_issues.yaml b/.github/workflows/close_blank_issues.yaml new file mode 100644 index 000000000..7190546a0 --- /dev/null +++ b/.github/workflows/close_blank_issues.yaml @@ -0,0 +1,42 @@ +name: Close Issues not using a template + +on: + issues: + types: + - opened + +permissions: + issues: write + +jobs: + close_issue: + runs-on: ubuntu-latest + + steps: + - name: Check issue headings + uses: actions/github-script@v7 + with: + script: | + const issueBody = context.payload.issue.body || ""; + + // Match Markdown headings (e.g., # Heading, ## Heading) + const headingRegex = /^(#{1,6})\s.+/gm; + const headings = [...issueBody.matchAll(headingRegex)]; + + if (headings.length < 3) { + // Post a comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!" + }); + + // Close the issue + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + state: "closed" + }); + } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8d43311b7..809563018 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -43,11 +43,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -60,7 +60,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -73,6 +73,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/component-tests.yml b/.github/workflows/component-tests.yml new file mode 100644 index 000000000..fcc2c2138 --- /dev/null +++ b/.github/workflows/component-tests.yml @@ -0,0 +1,48 @@ +name: Run Component Tests + +on: + workflow_dispatch: + inputs: + ref: + description: 'Branch/Tag/SHA to test' + required: true + pull_request: + paths: + - 'client/**' + - '.github/workflows/component-tests.yml' + push: + paths: + - 'client/**' + - '.github/workflows/component-tests.yml' + +jobs: + run-component-tests: + name: Run Component Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout (push/pull request) + uses: actions/checkout@v4 + if: github.event_name != 'workflow_dispatch' + + - name: Checkout (workflow_dispatch) + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + if: github.event_name == 'workflow_dispatch' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: | + cd client + npm ci + + - name: Run tests + run: | + cd client + npm test diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 846a55639..fdb57fbc5 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,5 +1,4 @@ --- - name: Build and Push Docker Image on: @@ -11,7 +10,7 @@ on: required: true default: 'latest' push: - branches: [main,master] + branches: [main, master] tags: - 'v*.*.*' # Only build when files in these directories have been changed @@ -23,16 +22,16 @@ on: jobs: build: - if: "!contains(github.event.head_commit.message, 'skip ci')" - runs-on: ubuntu-20.04 + if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }} + runs-on: ubuntu-24.04 steps: - name: Check out - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Docker meta id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf tags: | @@ -40,13 +39,13 @@ jobs: type=semver,pattern={{version}} - name: Setup QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Cache Docker layers - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -54,20 +53,20 @@ jobs: ${{ runner.os }}-buildx- - name: Login to Dockerhub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Login to ghcr - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_PASSWORD }} - name: Build image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/i18n-integration.yml b/.github/workflows/i18n-integration.yml index fc8441546..8b3a4678f 100644 --- a/.github/workflows/i18n-integration.yml +++ b/.github/workflows/i18n-integration.yml @@ -20,7 +20,8 @@ jobs: - name: Set up node uses: actions/setup-node@v4 with: - node-version: '20' + node-version: 20 + cache: 'npm' # The only argument is the `directory`, which is where the i18n files are # stored. diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 580c0f500..18c1d2dae 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -18,14 +18,15 @@ jobs: name: build and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: setup nade - uses: actions/setup-node@v3 + - name: setup node + uses: actions/setup-node@v4 with: node-version: 20 + cache: 'npm' - - name: install pkg (using yao-pkg fork for targetting node20) + - name: install pkg (using yao-pkg fork for targeting node20) run: npm install -g @yao-pkg/pkg - name: get client dependencies diff --git a/.github/workflows/lint-openapi.yml b/.github/workflows/lint-openapi.yml index 817e94b97..ec08ecb36 100644 --- a/.github/workflows/lint-openapi.yml +++ b/.github/workflows/lint-openapi.yml @@ -18,15 +18,22 @@ jobs: # Check out the repository - name: Checkout uses: actions/checkout@v4 + # Set up node to run the javascript - name: Set up node uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + # Install Redocly CLI - name: Install Redocly CLI run: npm install -g @redocly/cli@latest + # Perform linting for exploded spec - name: Run linting for exploded spec run: redocly lint docs/root.yaml --format=github-actions + # Perform linting for bundled spec - name: Run linting for bundled spec run: redocly lint docs/openapi.json --format=github-actions diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 695696c62..91a22c716 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -29,6 +29,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 + cache: 'npm' - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index d375bae08..12ebec1c2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ sw.* .DS_STORE .idea/* tailwind.compiled.css +tailwind.config.js diff --git a/Dockerfile b/Dockerfile index 0d4629f84..816bdd3c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,32 @@ +ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3" +ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so" + ### STAGE 0: Build client ### -FROM node:20-alpine AS build +FROM node:20-alpine AS build-client + WORKDIR /client COPY /client /client RUN npm ci && npm cache clean --force RUN npm run generate ### STAGE 1: Build server ### -FROM node:20-alpine +FROM node:20-alpine AS build-server + +ARG NUSQLITE3_DIR +ARG TARGETPLATFORM ENV NODE_ENV=production -RUN apk update && \ - apk add --no-cache --update \ +RUN apk add --no-cache --update \ curl \ - tzdata \ - ffmpeg \ make \ python3 \ g++ \ - tini \ unzip -COPY --from=build /client/dist /client/dist -COPY index.js package* / -COPY server server - -ARG TARGETPLATFORM - -ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3" -ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so" +WORKDIR /server +COPY index.js package* /server +COPY /server /server/server RUN case "$TARGETPLATFORM" in \ "linux/amd64") \ @@ -42,9 +40,34 @@ RUN case "$TARGETPLATFORM" in \ RUN npm ci --only=production -RUN apk del make python3 g++ +### STAGE 2: Create minimal runtime image ### +FROM node:20-alpine + +ARG NUSQLITE3_DIR +ARG NUSQLITE3_PATH + +# Install only runtime dependencies +RUN apk add --no-cache --update \ + tzdata \ + ffmpeg \ + tini + +WORKDIR /app + +# Copy compiled frontend and server from build stages +COPY --from=build-client /client/dist /app/client/dist +COPY --from=build-server /server /app +COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH} EXPOSE 80 +ENV PORT=80 +ENV NODE_ENV=production +ENV CONFIG_PATH="/config" +ENV METADATA_PATH="/metadata" +ENV SOURCE="docker" +ENV NUSQLITE3_DIR=${NUSQLITE3_DIR} +ENV NUSQLITE3_PATH=${NUSQLITE3_PATH} + ENTRYPOINT ["tini", "--"] CMD ["node", "index.js"] diff --git a/build/debian/DEBIAN/preinst b/build/debian/DEBIAN/preinst index e30bc490c..241a47010 100644 --- a/build/debian/DEBIAN/preinst +++ b/build/debian/DEBIAN/preinst @@ -22,7 +22,7 @@ add_user() { declare -r descr="${4:-No description}" declare -r shell="${5:-/bin/false}" - if ! getent passwd | grep -q "^$user:"; then + if ! getent passwd "$user" 2>&1 >/dev/null; then echo "Creating system user: $user in $group with $descr and shell $shell" useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user fi @@ -39,7 +39,7 @@ add_group() { declare -r gid_flags="--gid $gid" fi - if ! getent group | grep -q "^$group:" ; then + if ! getent group "$group" 2>&1 >/dev/null; then echo "Creating system group: $group" groupadd $gid_flags --system $group fi diff --git a/client/assets/app.css b/client/assets/app.css index 7daf74ff4..36b3b4bad 100644 --- a/client/assets/app.css +++ b/client/assets/app.css @@ -5,7 +5,7 @@ @import './absicons.css'; :root { - --bookshelf-texture-img: url(/textures/wood_default.jpg); + --bookshelf-texture-img: url(~static/textures/wood_default.jpg); --bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); } @@ -92,11 +92,10 @@ } /* Firefox */ -input[type=number] { +input[type='number'] { -moz-appearance: textfield; } - .tracksTable { border-collapse: collapse; width: 100%; @@ -177,6 +176,10 @@ input[type=number] { box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; } +.box-shadow-progressbar { + box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5); +} + .shadow-height { height: calc(100% - 4px); } @@ -204,7 +207,6 @@ Bookshelf Label color: #fce3a6; } - .cover-bg { width: calc(100% + 40px); height: calc(100% + 40px); @@ -247,4 +249,4 @@ Bookshelf Label .abs-btn:disabled::before { background-color: rgba(0, 0, 0, 0.2); -} \ No newline at end of file +} diff --git a/client/assets/defaultStyles.css b/client/assets/defaultStyles.css index 027ccdf23..e0ca79e29 100644 --- a/client/assets/defaultStyles.css +++ b/client/assets/defaultStyles.css @@ -52,4 +52,17 @@ text-indent: 0px !important; text-align: start !important; text-align-last: start !important; -} \ No newline at end of file +} + +.default-style.less-spacing p { + margin-block-start: 0; +} + +.default-style.less-spacing ul { + margin-block-start: 0; +} + +.default-style.less-spacing ol { + margin-block-start: 0; +} + diff --git a/client/assets/tailwind.css b/client/assets/tailwind.css index bd6213e1d..7883f32ff 100644 --- a/client/assets/tailwind.css +++ b/client/assets/tailwind.css @@ -1,3 +1,85 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; \ No newline at end of file +@import 'tailwindcss'; + +/* + The default border color has changed to `currentColor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } + + [role='button'], + button { + cursor: pointer; + } +} + +@theme { + --spacing-0\.5e: 0.125em; + --spacing-1e: 0.25em; + --spacing-1\.5e: 0.375em; + --spacing-2e: 0.5em; + --spacing-2\.5e: 0.625em; + --spacing-3e: 0.75em; + --spacing-3\.5e: 0.875em; + --spacing-4e: 1em; + --spacing-5e: 1.25em; + --spacing-6e: 1.5em; + --spacing-7e: 1.75em; + --spacing-8e: 2em; + --spacing-9e: 2.25em; + --spacing-10e: 2.5em; + --spacing-11e: 2.75em; + --spacing-12e: 3em; + --spacing-14e: 3.5em; + --spacing-16e: 4em; + --spacing-20e: 5em; + --spacing-24e: 6em; + --spacing-28e: 7em; + --spacing-32e: 8em; + --spacing-36e: 9em; + --spacing-40e: 10em; + --spacing-44e: 11em; + --spacing-48e: 12em; + --spacing-52e: 13em; + --spacing-56e: 14em; + --spacing-60e: 15em; + --spacing-64e: 16em; + --spacing-72e: 18em; + --spacing-80e: 20em; + --spacing-96e: 24em; + + --color-bg: #373838; + --color-primary: #232323; + --color-accent: #1ad691; + --color-error: #ff5252; + --color-info: #2196f3; + --color-success: #4caf50; + --color-warning: #fb8c00; + --color-darkgreen: rgb(34, 127, 35); + --color-black-50: #bbbbbb; + --color-black-100: #666666; + --color-black-200: #555555; + --color-black-300: #444444; + --color-black-400: #333333; + --color-black-500: #222222; + --color-black-600: #111111; + --color-black-700: #101010; + + --font-sans: 'Source Sans Pro'; + --font-mono: 'Ubuntu Mono'; + + --text-xxs: 0.625rem; + --text-1\.5xl: 1.375rem; + --text-2\.5xl: 1.6875rem; + --text-4\.5xl: 2.625rem; +} diff --git a/client/assets/trix.css b/client/assets/trix.css index 8f88c61f1..7432b25f4 100644 --- a/client/assets/trix.css +++ b/client/assets/trix.css @@ -446,7 +446,7 @@ trix-editor .attachment__metadata .attachment__size { } .trix-content { - line-height: 1.5; + line-height: inherit; } .trix-content * { @@ -455,6 +455,13 @@ trix-editor .attachment__metadata .attachment__size { padding: 0; } +.trix-content p { + box-sizing: border-box; + margin-top: 0; + margin-bottom: 0.5em; + padding: 0; +} + .trix-content h1 { font-size: 1.2em; line-height: 1.2; @@ -560,4 +567,4 @@ trix-editor .attachment__metadata .attachment__size { .trix-content .attachment-gallery.attachment-gallery--4 .attachment { flex-basis: 50%; max-width: 50%; -} \ No newline at end of file +} diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index bb452526d..f74134041 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -13,10 +13,10 @@ -
+
- cast + cast
@@ -42,7 +42,7 @@ - + @@ -53,8 +53,8 @@

{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}

-
- +
+ play_arrow {{ $strings.ButtonPlay }} @@ -66,11 +66,11 @@ - + @@ -180,6 +180,15 @@ export default { action: 'rescan' }) + // The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440 + // + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains + if (this.selectedMediaItems.length <= 40) { + options.push({ + text: this.$strings.LabelDownload, + action: 'download' + }) + } + return options } }, @@ -215,6 +224,8 @@ export default { this.batchAutoMatchClick() } else if (action === 'rescan') { this.batchRescan() + } else if (action === 'download') { + this.batchDownload() } }, async batchRescan() { @@ -241,6 +252,11 @@ export default { } this.$store.commit('globals/setConfirmPrompt', payload) }, + async batchDownload() { + const libraryItemIds = this.selectedMediaItems.map((i) => i.id) + console.log('Downloading library items', libraryItemIds) + this.$downloadFile(`/api/libraries/${this.$store.state.libraries.currentLibraryId}/download?token=${this.$store.getters['user/getToken']}&ids=${libraryItemIds.join(',')}`) + }, async playSelectedItems() { this.$store.commit('setProcessingBatch', true) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 94b2e4bae..4bf8cfbbf 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -6,8 +6,8 @@

{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}

- {{ $strings.ButtonConfigureScanner }} - {{ $strings.ButtonScanLibrary }} + {{ $strings.ButtonConfigureScanner }} + {{ $strings.ButtonScanLibrary }}
@@ -217,6 +217,16 @@ export default { }) } + if (this.results.episodes?.length) { + shelves.push({ + id: 'episodes', + label: 'Episodes', + labelStringKey: 'LabelEpisodes', + type: 'episode', + entities: this.results.episodes.map((res) => res.libraryItem) + }) + } + if (this.results.series?.length) { shelves.push({ id: 'series', diff --git a/client/components/app/BookShelfRow.vue b/client/components/app/BookShelfRow.vue index 6e19b9dca..fac89a70b 100644 --- a/client/components/app/BookShelfRow.vue +++ b/client/components/app/BookShelfRow.vue @@ -36,7 +36,7 @@
-
+

{{ $strings[shelf.labelStringKey] }}

@@ -93,12 +93,13 @@ export default { editAuthor(author) { this.$store.commit('globals/showEditAuthorModal', author) }, - editItem(libraryItem) { + editItem(libraryItem, tab = 'details') { var itemIds = this.shelf.entities.map((e) => e.id) this.$store.commit('setBookshelfBookIds', itemIds) - this.$store.commit('showEditModal', libraryItem) + this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' }) }, editEpisode({ libraryItem, episode }) { + this.$store.commit('setEpisodeTableEpisodeIds', [episode.id]) this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index 74157b184..b7ecff624 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -1,48 +1,37 @@