diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..bd5be46 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,167 @@ +name: build + +on: + schedule: + - cron: '23 5 * * *' + workflow_dispatch: + inputs: + force: + description: Rebuild even if the latest upstream release was already built + type: boolean + default: false + +permissions: + contents: write + packages: write + issues: write + actions: write + +concurrency: + group: build + cancel-in-progress: false + +env: + UPSTREAM_REPO: seerr-team/seerr + PR_NUMBER: '1855' + IMAGE: ghcr.io/${{ github.repository }} + GH_TOKEN: ${{ github.token }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Stop if PR 1855 was merged or closed upstream + run: | + pr="$(gh api "repos/$UPSTREAM_REPO/pulls/$PR_NUMBER")" + merged="$(jq -r .merged <<<"$pr")" + state="$(jq -r .state <<<"$pr")" + if [ "$merged" = "true" ]; then + title="Upstream PR #$PR_NUMBER was MERGED — time to switch back to the official image" + body="The hide-requested feature was merged into seerr upstream. + + **What to do now:** + 1. Wait for the next seerr release that includes it (check https://github.com/$UPSTREAM_REPO/releases — the release notes will mention hiding requested media, or PR #$PR_NUMBER). + 2. In Unraid, change the container repository back to \`ghcr.io/seerr-team/seerr:latest\`. + 3. In seerr Settings → General, enable **Hide Requested Media**. + 4. Archive or delete this repo. + + Until that release ships, your current image keeps working — it already contains this feature. This workflow has disabled itself and will not build again." + elif [ "$state" = "closed" ]; then + title="Upstream PR #$PR_NUMBER was CLOSED without merging" + body="The hide-requested PR was closed upstream without being merged. Your current image keeps working, but no new upstream releases will be picked up until you decide what to do (find a replacement PR and update PR_NUMBER in build.yml, or retire this repo). This workflow has disabled itself." + else + echo "PR #$PR_NUMBER is still open — continuing." + exit 0 + fi + existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$title\" in:title" --json number --jq length)" + if [ "$existing" = "0" ]; then + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body" + fi + gh workflow disable build.yml --repo "$GITHUB_REPOSITORY" || true + echo "::error::$title" + exit 1 + + - name: Determine latest upstream release + id: rel + env: + FORCE: ${{ inputs.force }} + run: | + tag="$(gh api "repos/$UPSTREAM_REPO/releases/latest" --jq .tag_name)" + if ! grep -qE '^v[0-9][A-Za-z0-9._-]*$' <<<"$tag"; then + echo "::error::Upstream release tag has unexpected format: $tag" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + last="$(cat .last-built 2>/dev/null || echo none)" + if [ "$tag" = "$last" ] && [ "$FORCE" != "true" ]; then + echo "Already built $tag — nothing to do." + echo "build=false" >> "$GITHUB_OUTPUT" + else + echo "New upstream release $tag (last built: $last)." + echo "build=true" >> "$GITHUB_OUTPUT" + fi + + - name: Keepalive commit if repo is going stale + if: steps.rel.outputs.build == 'false' + run: | + age_days=$(( ( $(date +%s) - $(git log -1 --format=%ct) ) / 86400 )) + if [ "$age_days" -ge 45 ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit --allow-empty -m "chore: keepalive so the schedule is not auto-disabled" + git push + fi + + - name: Fetch upstream source at release tag + if: steps.rel.outputs.build == 'true' + env: + TAG: ${{ steps.rel.outputs.tag }} + run: git clone --depth 1 --branch "$TAG" "https://github.com/$UPSTREAM_REPO.git" upstream + + - name: Apply vendored patches + if: steps.rel.outputs.build == 'true' + env: + TAG: ${{ steps.rel.outputs.tag }} + run: | + cd upstream + for patch in ../patches/*.diff; do + if ! git apply --check "$patch"; then + title="Vendored patch $(basename "$patch") no longer applies to $TAG" + existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$title\" in:title" --json number --jq length)" + if [ "$existing" = "0" ]; then + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" \ + --body "The vendored patch conflicts with the new upstream release. Your current image keeps working, but it is now behind upstream. Re-download the updated diff from its source PR, review it, replace the file in patches/, and commit — see patches/README.md." + fi + echo "::error::$title" + exit 1 + fi + git apply "$patch" + echo "Applied $(basename "$patch")" + done + + - name: Set up Docker Buildx + if: steps.rel.outputs.build == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: steps.rel.outputs.build == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build and push image + if: steps.rel.outputs.build == 'true' + uses: docker/build-push-action@v6 + with: + context: upstream + push: true + platforms: linux/amd64 + build-args: COMMIT_TAG=${{ steps.rel.outputs.tag }}-pr1855 + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ steps.rel.outputs.tag }}-pr1855 + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=seerr ${{ steps.rel.outputs.tag }} + PR 1855 (hide requested media) + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Record built tag + if: steps.rel.outputs.build == 'true' + env: + TAG: ${{ steps.rel.outputs.tag }} + run: | + echo "$TAG" > .last-built + if git diff --quiet -- .last-built; then + echo "Forced rebuild of $TAG — .last-built unchanged, nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .last-built + git commit -m "chore: built $TAG" + git push diff --git a/.last-built b/.last-built new file mode 100644 index 0000000..621e94f --- /dev/null +++ b/.last-built @@ -0,0 +1 @@ +none diff --git a/README.md b/README.md index b7f0017..e6c9ec4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,101 @@ -# seerr-hide-requested +# seerr + Hide Requested Media -Setup PR incoming — see the open pull request for the full build pipeline and docs. +**[seerr](https://github.com/seerr-team/seerr), with the ability to hide already-requested titles from Discover — available today, before it lands upstream.** + +```text +ghcr.io/danieldjupvik/seerr:latest +``` + +Seerr has long had a *Hide Available Media* setting, but nothing to hide titles you've already **requested** — so Discover pages fill up with things you've already asked for. That feature has been requested for years across [Overseerr #3681](https://github.com/sct/overseerr/issues/3681), Jellyseerr, and [seerr #1048](https://github.com/seerr-team/seerr/issues/1048), and an implementation exists in [PR #1855](https://github.com/seerr-team/seerr/pull/1855) — it just hasn't been merged yet. + +This repo publishes the **latest official seerr release with that PR applied**, plus two small reviewed fixes to it (documented in [patches/](patches/README.md)). Nothing else is changed. You get a new **Hide Requested Media** checkbox in *Settings → General*, right next to *Hide Available Media*: + +- Titles with an **active** request (pending or approved-and-processing) disappear from Discover pages and sliders; declined requests don't hide anything +- They stay visible in **search**, so you can still find them and see their request status +- Global admin setting, off by default — same behavior the upstream PR will ship, with its review findings already fixed + +## Quick start + +The image is a **drop-in replacement** for `ghcr.io/seerr-team/seerr:latest` — same app, same ports, same config format. Switching in either direction is just changing the image name; your existing config and database are untouched. + +### Docker Compose + +```yaml +services: + seerr: + image: ghcr.io/danieldjupvik/seerr:latest + container_name: seerr + environment: + - TZ=Europe/Oslo + ports: + - 5055:5055 + volumes: + - ./config:/app/config + restart: unless-stopped +``` + +### Docker CLI + +```bash +docker run -d \ + --name seerr \ + -e TZ=Europe/Oslo \ + -p 5055:5055 \ + -v /path/to/config:/app/config \ + --restart unless-stopped \ + ghcr.io/danieldjupvik/seerr:latest +``` + +### Unraid + +Edit your existing seerr container and change the **Repository** field to `ghcr.io/danieldjupvik/seerr:latest`. Everything else stays the same. + +### Enable the feature + +After starting: **Settings → General → Hide Requested Media** → check it, save. + +## Tags + +| Tag | Meaning | +| --- | --- | +| `latest` | Newest upstream release + the patch (mirrors upstream's `latest`) | +| `vX.Y.Z-pr1855` | Pinned build of a specific upstream release, for rollback | + +## How it stays up to date + +A [scheduled GitHub Actions workflow](.github/workflows/build.yml) runs daily: + +1. Checks that [PR #1855](https://github.com/seerr-team/seerr/pull/1855) is still open upstream. +2. Checks for a new upstream release. If there's nothing new, it exits — no rebuild, no image churn. +3. On a new release: clones upstream at the release tag, applies the **vendored, reviewed patches** (the PR diff pinned to a specific PR commit, plus small reviewed fixes — see [patches/README.md](patches/README.md)), builds with upstream's own unmodified Dockerfile, and pushes. Typically live within 24 hours of an upstream release. + +There is no fork being maintained here. Every build starts from pristine upstream source; the only delta is the vendored patches, applied at build time. The patch content is never fetched from the network during a build — it can only change through a reviewed commit in this repo. + +## Can I trust this image? + +Reasonable question for any third-party image. You don't have to take anyone's word: + +- **Everything is built in public.** Every image comes from a GitHub Actions run in this repo — the [full build logs](../../actions) show exactly which upstream tag was cloned and which diff was applied. There are no manual pushes and no secrets involved beyond the repo's own `GITHUB_TOKEN`. +- **The only changes are pinned, vendored diffs** ([patches/](patches/)): the public upstream PR written by [@0xSysR3ll](https://github.com/0xSysR3ll), verbatim, plus a small documented fixup. You can read every line here, and diff the PR copy against the upstream PR yourself. Builds never pull patch content from the network, so an update to the upstream PR branch cannot silently change this image. +- **Don't want to trust it anyway? Run your own.** Fork this repo, enable Actions, and you'll build and publish the identical image into your own GHCR namespace in ~20 minutes. + +## Limitations + +- **`linux/amd64` only.** No ARM builds (keeps CI fast; open an issue if you'd genuinely use one). +- **Up to 24h behind upstream releases** (daily schedule). +- **Global setting, not per-user** — same as the upstream PR and the existing Hide Available Media setting. +- **If a new upstream release conflicts with the patch**, no image is published for it: the pipeline fails loudly and the previous version stays available until the vendored diff is updated (reviewed, then committed). You keep a working seerr; you're just temporarily behind. +- **Not affiliated with the seerr team.** If you hit a seerr bug while on this image, please reproduce it on the official image before reporting it upstream — don't send the maintainers chasing ghosts from a patched build. + +## What happens when the PR merges upstream? + +This repo retires itself. The daily workflow detects the merge, opens an issue here with switch-back instructions, and disables itself. At that point: wait for the next official release containing the feature, change your image back to `ghcr.io/seerr-team/seerr:latest`, and keep the checkbox — it'll be the real one. Your config carries over cleanly, including the setting itself. + +If you want this to happen sooner, go 👍 [PR #1855](https://github.com/seerr-team/seerr/pull/1855) and [issue #1048](https://github.com/seerr-team/seerr/issues/1048). + +## Credits + +- The [seerr team](https://github.com/seerr-team) — the actual application (formerly Jellyseerr/Overseerr) +- [@0xSysR3ll](https://github.com/0xSysR3ll) — author of the hide-requested feature in PR #1855 + +This repo is just plumbing that connects the two a little earlier than the release cycle would. diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..f51eb43 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,21 @@ +# Vendored patches + +Applied by the build workflow on top of the upstream release source, in lexicographic filename order. + +| File | Source | Pinned at | +| --- | --- | --- | +| `pr1855.diff` | [seerr-team/seerr#1855](https://github.com/seerr-team/seerr/pull/1855), verbatim | head `cfeeb8ce8416f9775e503cfa8a03845d8f9f56f4` (2026-06-18) | +| `pr1855.fixups.diff` | This repo (review findings on the PR code) | — | + +## What the fixups change + +Two issues found in review of the PR code, fixed server-side in `Media.getRelatedMedia`: + +1. **Only active requests hide a title.** The PR hides any title with request rows, but declined request rows persist forever — a declined title would never reappear in Discover. The join now excludes `DECLINED` and `COMPLETED` requests, matching how the rest of seerr defines active requests. +2. **Minimal payload instead of full request rows.** The PR's `leftJoinAndSelect` serialized entire `MediaRequest` rows into every discover/search/related-media response. The join now selects only `id` and `status` — all the frontend needs for its length check. + +Both fixes live in one server-side join, so the PR's frontend code works unmodified. + +## Updating a patch + +To pick up a rebased/updated upstream PR: re-download the diff, **review it**, replace `pr1855.diff`, re-verify the fixups still apply on top, and commit — the content shipped into the image only ever changes through a reviewed commit in this repo. diff --git a/patches/pr1855.diff b/patches/pr1855.diff new file mode 100644 index 0000000..96906d6 --- /dev/null +++ b/patches/pr1855.diff @@ -0,0 +1,267 @@ +diff --git a/docs/using-seerr/settings/general.md b/docs/using-seerr/settings/general.md +index 18fd32765f..798c136cc8 100644 +--- a/docs/using-seerr/settings/general.md ++++ b/docs/using-seerr/settings/general.md +@@ -84,6 +84,14 @@ When enabled, media that has been blocklisted will not appear on the "Discover" + + This setting is **disabled** by default. + ++## Hide Requested Media ++ ++When enabled, media that has been requested (but not yet available) will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages. ++ ++Requested media will still appear in search results, however, so it is possible to locate and view hidden items by searching for them by title. ++ ++This setting is **disabled** by default. ++ + ## Allow Partial Series Requests + + When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons. +diff --git a/seerr-api.yml b/seerr-api.yml +index 99ef16cc3c..eb86e360fd 100644 +--- a/seerr-api.yml ++++ b/seerr-api.yml +@@ -236,6 +236,9 @@ components: + hideAvailable: + type: boolean + example: false ++ hideRequested: ++ type: boolean ++ example: false + partialRequestsEnabled: + type: boolean + example: false +diff --git a/server/entity/Media.ts b/server/entity/Media.ts +index 8737ad0e04..5314123570 100644 +--- a/server/entity/Media.ts ++++ b/server/entity/Media.ts +@@ -53,7 +53,8 @@ class Media { + 'watchlist', + 'media.id= watchlist.media and watchlist.requestedBy = :userId', + { userId: user?.id } +- ) //, ++ ) ++ .leftJoinAndSelect('media.requests', 'requests') + .where(' media.tmdbId in (:...finalIds)', { finalIds }) + .getMany(); + +diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts +index ea08d4e61d..cfa72e4856 100644 +--- a/server/interfaces/api/settingsInterfaces.ts ++++ b/server/interfaces/api/settingsInterfaces.ts +@@ -31,6 +31,7 @@ export interface PublicSettingsResponse { + applicationUrl: string; + hideAvailable: boolean; + hideBlocklisted: boolean; ++ hideRequested: boolean; + localLogin: boolean; + mediaServerLogin: boolean; + movie4kEnabled: boolean; +diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts +index 7057cf2b01..5ab7220a2d 100644 +--- a/server/lib/settings/index.ts ++++ b/server/lib/settings/index.ts +@@ -134,6 +134,7 @@ export interface MainSettings { + }; + hideAvailable: boolean; + hideBlocklisted: boolean; ++ hideRequested: boolean; + localLogin: boolean; + mediaServerLogin: boolean; + newPlexLogin: boolean; +@@ -184,6 +185,7 @@ interface FullPublicSettings extends PublicSettings { + applicationUrl: string; + hideAvailable: boolean; + hideBlocklisted: boolean; ++ hideRequested: boolean; + localLogin: boolean; + mediaServerLogin: boolean; + movie4kEnabled: boolean; +@@ -394,6 +396,7 @@ class Settings { + }, + hideAvailable: false, + hideBlocklisted: false, ++ hideRequested: false, + localLogin: true, + mediaServerLogin: true, + newPlexLogin: true, +@@ -678,6 +681,7 @@ class Settings { + applicationUrl: this.data.main.applicationUrl, + hideAvailable: this.data.main.hideAvailable, + hideBlocklisted: this.data.main.hideBlocklisted, ++ hideRequested: this.data.main.hideRequested, + localLogin: this.data.main.localLogin, + mediaServerLogin: this.data.main.mediaServerLogin, + jellyfinExternalHost: this.data.jellyfin.externalHostname, +diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx +index 4418c12314..e7e09cbfe0 100644 +--- a/src/components/MediaSlider/index.tsx ++++ b/src/components/MediaSlider/index.tsx +@@ -82,6 +82,21 @@ const MediaSlider = ({ + ); + } + ++ if (settings.currentSettings.hideRequested) { ++ titles = titles.filter((i) => { ++ if (i.mediaType !== 'movie' && i.mediaType !== 'tv') { ++ return true; ++ } ++ ++ return ( ++ i.mediaInfo?.status === MediaStatus.AVAILABLE || ++ i.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE || ++ !i.mediaInfo?.requests || ++ i.mediaInfo.requests.length === 0 ++ ); ++ }); ++ } ++ + useEffect(() => { + if ( + titles.length < 24 && +diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx +index 96d41ced7b..40743a0a8d 100644 +--- a/src/components/Search/index.tsx ++++ b/src/components/Search/index.tsx +@@ -34,7 +34,7 @@ const Search = () => { + { + query: router.query.query, + }, +- { hideAvailable: false, hideBlocklisted: false } ++ { hideAvailable: false, hideBlocklisted: false, hideRequested: false } + ); + + if (error) { +diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx +index 4944c2349f..c564b4cc68 100644 +--- a/src/components/Settings/SettingsMain/index.tsx ++++ b/src/components/Settings/SettingsMain/index.tsx +@@ -56,6 +56,9 @@ const messages = defineMessages('components.Settings.SettingsMain', { + hideAvailable: 'Hide Available Media', + hideAvailableTip: + 'Hide available media from the discover pages but not search results', ++ hideRequested: 'Hide Requested Media', ++ hideRequestedTip: ++ 'Hide media that has been requested from the discover pages but not search results', + cacheImages: 'Enable Image Caching', + cacheImagesTip: + 'Cache externally sourced images (requires a significant amount of disk space)', +@@ -165,6 +168,7 @@ const SettingsMain = () => { + applicationUrl: data?.applicationUrl, + hideAvailable: data?.hideAvailable, + hideBlocklisted: data?.hideBlocklisted, ++ hideRequested: data?.hideRequested, + locale: data?.locale ?? 'en', + discoverRegion: data?.discoverRegion, + originalLanguage: data?.originalLanguage, +@@ -185,6 +189,7 @@ const SettingsMain = () => { + applicationUrl: values.applicationUrl, + hideAvailable: values.hideAvailable, + hideBlocklisted: values.hideBlocklisted, ++ hideRequested: values.hideRequested, + locale: values.locale, + discoverRegion: values.discoverRegion, + streamingRegion: values.streamingRegion, +@@ -489,6 +494,26 @@ const SettingsMain = () => { + /> + + ++