diff --git a/README_images/downloads.png b/README_images/downloads.png
index 6be3e2e..ff8d8f1 100644
Binary files a/README_images/downloads.png and b/README_images/downloads.png differ
diff --git a/README_images/homescreen.png b/README_images/homescreen.png
index 2d6ed1a..d24a6e3 100644
Binary files a/README_images/homescreen.png and b/README_images/homescreen.png differ
diff --git a/README_images/multi-source.png b/README_images/multi-source.png
index 3d39eaf..5737212 100644
Binary files a/README_images/multi-source.png and b/README_images/multi-source.png differ
diff --git a/README_images/search-results.png b/README_images/search-results.png
index b96043c..a9d2c6d 100644
Binary files a/README_images/search-results.png and b/README_images/search-results.png differ
diff --git a/node_modules/.bin/baseline-browser-mapping b/node_modules/.bin/baseline-browser-mapping
deleted file mode 120000
index d296188..0000000
--- a/node_modules/.bin/baseline-browser-mapping
+++ /dev/null
@@ -1 +0,0 @@
-../baseline-browser-mapping/dist/cli.js
\ No newline at end of file
diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json
deleted file mode 100644
index 5732cfd..0000000
--- a/node_modules/.package-lock.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "shelfmark",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "node_modules/baseline-browser-mapping": {
- "version": "2.9.19",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
- "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.js"
- }
- }
- }
-}
diff --git a/node_modules/baseline-browser-mapping/LICENSE.txt b/node_modules/baseline-browser-mapping/LICENSE.txt
deleted file mode 100644
index 261eeb9..0000000
--- a/node_modules/baseline-browser-mapping/LICENSE.txt
+++ /dev/null
@@ -1,201 +0,0 @@
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
diff --git a/node_modules/baseline-browser-mapping/README.md b/node_modules/baseline-browser-mapping/README.md
deleted file mode 100644
index 3011363..0000000
--- a/node_modules/baseline-browser-mapping/README.md
+++ /dev/null
@@ -1,463 +0,0 @@
-# [`baseline-browser-mapping`](https://github.com/web-platform-dx/web-features/packages/baseline-browser-mapping)
-
-By the [W3C WebDX Community Group](https://www.w3.org/community/webdx/) and contributors.
-
-`baseline-browser-mapping` provides:
-
-- An `Array` of browsers compatible with Baseline Widely available and Baseline year feature sets via the [`getCompatibleVersions()` function](#get-baseline-widely-available-browser-versions-or-baseline-year-browser-versions).
-- An `Array`, `Object` or `CSV` as a string describing the Baseline feature set support of all browser versions included in the module's data set via the [`getAllVersions()` function](#get-data-for-all-browser-versions).
-
-You can use `baseline-browser-mapping` to help you determine minimum browser version support for your chosen Baseline feature set; or to analyse the level of support for different Baseline feature sets in your site's traffic by joining the data with your analytics data.
-
-## Install for local development
-
-To install the package, run:
-
-`npm install --save-dev baseline-browser-mapping`
-
-`baseline-browser-mapping` depends on `web-features` and `@mdn/browser-compat-data` for core browser version selection, but the data is pre-packaged and minified. This package checks for updates to those modules and the supported [downstream browsers](#downstream-browsers) on a daily basis and is updated frequently. Consider adding a script to your `package.json` to update `baseline-browser-mapping` and using it as part of your build process to ensure your data is as up to date as possible:
-
-```javascript
-"scripts": [
- "refresh-baseline-browser-mapping": "npm i --save-dev baseline-browser-mapping@latest"
-]
-```
-
-The minimum supported NodeJS version for `baseline-browser-mapping` is v8 in alignment with `browserslist`. For NodeJS versions earlier than v13.2, the [`require('baseline-browser-mapping')`](https://nodejs.org/api/modules.html#requireid) syntax should be used to import the module.
-
-## Keeping `baseline-browser-mapping` up to date
-
-If you are only using this module to generate minimum browser versions for Baseline Widely available or Baseline year feature sets, you don't need to update this module frequently, as the backward looking data is reasonably stable.
-
-However, if you are targeting Newly available, using the [`getAllVersions()`](#get-data-for-all-browser-versions) function or heavily relying on the data for downstream browsers, you should update this module more frequently. If you target a feature cut off date within the last two months and your installed version of `baseline-browser-mapping` has data that is more than 2 months old, you will receive a console warning advising you to update to the latest version when you call `getCompatibleVersions()` or `getAllVersions()`.
-
-If you want to suppress these warnings you can use the `suppressWarnings: true` option in the configuration object passed to `getCompatibleVersions()` or `getAllVersions()`. Alternatively, you can use the `BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true` environment variable when running your build process. This module also respects the `BROWSERSLIST_IGNORE_OLD_DATA=true` environment variable. Environment variables can also be provided in a `.env` file from Node 20 onwards; however, this module does not load .env files automatically to avoid conflicts with other libraries with different requirements. You will need to use `process.loadEnvFile()` or a library like `dotenv` to load .env files before `baseline-browser-mapping` is called.
-
-If you want to ensure [reproducible builds](https://www.wikiwand.com/en/articles/Reproducible_builds), we strongly recommend using the `widelyAvailableOnDate` option to fix the Widely available date on a per build basis to ensure dependent tools provide the same output and you do not produce data staleness warnings. If you are using [`browserslist`](https://github.com/browserslist/browserslist) to target Baseline Widely available, consider automatically updating your `browserslist` configuration in `package.json` or `.browserslistrc` to `baseline widely available on {YYYY-MM-DD}` as part of your build process to ensure the same or sufficiently similar list of minimum browsers is reproduced for historical builds.
-
-## Importing `baseline-browser-mapping`
-
-This module exposes two functions: `getCompatibleVersions()` and `getAllVersions()`, both which can be imported directly from `baseline-browser-mapping`:
-
-```javascript
-import {
- getCompatibleVersions,
- getAllVersions,
-} from "baseline-browser-mapping";
-```
-
-If you want to load the script and data directly in a web page without hosting it yourself, consider using a CDN:
-
-```html
-
-```
-
-## Get Baseline Widely available browser versions or Baseline year browser versions
-
-To get the current list of minimum browser versions compatible with Baseline Widely available features from the core browser set, call the `getCompatibleVersions()` function:
-
-```javascript
-getCompatibleVersions();
-```
-
-Executed on 7th March 2025, the above code returns the following browser versions:
-
-```javascript
-[
- { browser: "chrome", version: "105", release_date: "2022-09-02" },
- {
- browser: "chrome_android",
- version: "105",
- release_date: "2022-09-02",
- },
- { browser: "edge", version: "105", release_date: "2022-09-02" },
- { browser: "firefox", version: "104", release_date: "2022-08-23" },
- {
- browser: "firefox_android",
- version: "104",
- release_date: "2022-08-23",
- },
- { browser: "safari", version: "15.6", release_date: "2022-09-02" },
- {
- browser: "safari_ios",
- version: "15.6",
- release_date: "2022-09-02",
- },
-];
-```
-
-> [!NOTE]
-> The minimum versions of each browser are not necessarily the final release before the Widely available cutoff date of `TODAY - 30 MONTHS`. Some earlier versions will have supported the full Widely available feature set.
-
-### `getCompatibleVersions()` configuration options
-
-`getCompatibleVersions()` accepts an `Object` as an argument with configuration options. The defaults are as follows:
-
-```javascript
-{
- targetYear: undefined,
- widelyAvailableOnDate: undefined,
- includeDownstreamBrowsers: false,
- listAllCompatibleVersions: false,
- suppressWarnings: false
-}
-```
-
-#### `targetYear`
-
-The `targetYear` option returns the minimum browser versions compatible with all **Baseline Newly available** features at the end of the specified calendar year. For example, calling:
-
-```javascript
-getCompatibleVersions({
- targetYear: 2020,
-});
-```
-
-Returns the following versions:
-
-```javascript
-[
- { browser: "chrome", version: "87", release_date: "2020-11-19" },
- {
- browser: "chrome_android",
- version: "87",
- release_date: "2020-11-19",
- },
- { browser: "edge", version: "87", release_date: "2020-11-19" },
- { browser: "firefox", version: "83", release_date: "2020-11-17" },
- {
- browser: "firefox_android",
- version: "83",
- release_date: "2020-11-17",
- },
- { browser: "safari", version: "14", release_date: "2020-09-16" },
- { browser: "safari_ios", version: "14", release_date: "2020-09-16" },
-];
-```
-
-> [!NOTE]
-> The minimum version of each browser is not necessarily the final version released in that calendar year. In the above example, Firefox 84 was the final version released in 2020; however Firefox 83 supported all of the features that were interoperable at the end of 2020.
-> [!WARNING]
-> You cannot use `targetYear` and `widelyAavailableDate` together. Please only use one of these options at a time.
-
-#### `widelyAvailableOnDate`
-
-The `widelyAvailableOnDate` option returns the minimum versions compatible with Baseline Widely available on a specified date in the format `YYYY-MM-DD`:
-
-```javascript
-getCompatibleVersions({
- widelyAvailableOnDate: `2023-04-05`,
-});
-```
-
-> [!TIP]
-> This option is useful if you provide a versioned library that targets Baseline Widely available on each version's release date and you need to provide a statement on minimum supported browser versions in your documentation.
-
-#### `includeDownstreamBrowsers`
-
-Setting `includeDownstreamBrowsers` to `true` will include browsers outside of the Baseline core browser set where it is possible to map those browsers to an upstream Chromium or Gecko version:
-
-```javascript
-getCompatibleVersions({
- includeDownstreamBrowsers: true,
-});
-```
-
-For more information on downstream browsers, see [the section on downstream browsers](#downstream-browsers) below.
-
-#### `includeKaiOS`
-
-KaiOS is an operating system and app framework based on the Gecko engine from Firefox. KaiOS is based on the Gecko engine and feature support can be derived from the upstream Gecko version that each KaiOS version implements. However KaiOS requires other considerations beyond feature compatibility to ensure a good user experience as it runs on device types that do not have either mouse and keyboard or touch screen input in the way that all the other browsers supported by this module do.
-
-```javascript
-getCompatibleVersions({
- includeDownstreamBrowsers: true,
- includeKaiOS: true,
-});
-```
-
-> [!NOTE]
-> Including KaiOS requires you to include all downstream browsers using the `includeDownstreamBrowsers` option.
-
-#### `listAllCompatibleVersions`
-
-Setting `listAllCompatibleVersions` to true will include the minimum versions of each compatible browser, and all the subsequent versions:
-
-```javascript
-getCompatibleVersions({
- listAllCompatibleVersions: true,
-});
-```
-
-#### `suppressWarnings`
-
-Setting `suppressWarnings` to `true` will suppress the console warning about old data:
-
-```javascript
-getCompatibleVersions({
- suppressWarnings: true,
-});
-```
-
-## Get data for all browser versions
-
-You may want to obtain data on all the browser versions available in this module for use in an analytics solution or dashboard. To get details of each browser version's level of Baseline support, call the `getAllVersions()` function:
-
-```javascript
-import { getAllVersions } from "baseline-browser-mapping";
-
-getAllVersions();
-```
-
-By default, this function returns an `Array` of `Objects` and excludes downstream browsers:
-
-```javascript
-[
- ...
- {
- browser: "firefox_android", // Browser name
- version: "125", // Browser version
- release_date: "2024-04-16", // Release date
- year: 2023, // Baseline year feature set the version supports
- wa_compatible: true // Whether the browser version supports Widely available
- },
- ...
-]
-```
-
-For browser versions in `@mdn/browser-compat-data` that were released before Baseline can be defined, i.e. Baseline 2015, the `year` property is always the string: `"pre_baseline"`.
-
-### Understanding which browsers support Newly available features
-
-You may want to understand which recent browser versions support all Newly available features. You can replace the `wa_compatible` property with a `supports` property using the `useSupport` option:
-
-```javascript
-getAllVersions({
- useSupports: true,
-});
-```
-
-The `supports` property is optional and has two possible values:
-
-- `widely` for browser versions that support all Widely available features.
-- `newly` for browser versions that support all Newly available features.
-
-Browser versions that do not support Widely or Newly available will not include the `support` property in the `array` or `object` outputs, and in the CSV output, the `support` column will contain an empty string. Browser versions that support all Newly available features also support all Widely available features.
-
-### `getAllVersions()` Configuration options
-
-`getAllVersions()` accepts an `Object` as an argument with configuration options. The defaults are as follows:
-
-```javascript
-{
- includeDownstreamBrowsers: false,
- outputFormat: "array",
- suppressWarnings: false
-}
-```
-
-#### `includeDownstreamBrowsers` (in `getAllVersions()` output)
-
-As with `getCompatibleVersions()`, you can set `includeDownstreamBrowsers` to `true` to include the Chromium and Gecko downstream browsers [listed below](#list-of-downstream-browsers).
-
-```javascript
-getAllVersions({
- includeDownstreamBrowsers: true,
-});
-```
-
-Downstream browsers include the same properties as core browsers, as well as the `engine`they use and `engine_version`, for example:
-
-```javascript
-[
- ...
- {
- browser: "samsunginternet_android",
- version: "27.0",
- release_date: "2024-11-06",
- engine: "Blink",
- engine_version: "125",
- year: 2023,
- supports: "widely"
- },
- ...
-]
-```
-
-#### `includeKaiOS` (in `getAllVersions()` output)
-
-As with `getCompatibleVersions()` you can include KaiOS in your output. The same requirement to have `includeDownstreamBrowsers: true` applies.
-
-```javascript
-getAllVersions({
- includeDownstreamBrowsers: true,
- includeKaiOS: true,
-});
-```
-
-#### `suppressWarnings` (in `getAllVersions()` output)
-
-As with `getCompatibleVersions()`, you can set `suppressWarnings` to `true` to suppress the console warning about old data:
-
-```javascript
-getAllVersions({
- suppressWarnings: true,
-});
-```
-
-#### `outputFormat`
-
-By default, this function returns an `Array` of `Objects` which can be manipulated in Javascript or output to JSON.
-
-To return an `Object` that nests keys , set `outputFormat` to `object`:
-
-```javascript
-getAllVersions({
- outputFormat: "object",
-});
-```
-
-In thise case, `getAllVersions()` returns a nested object with the browser [IDs listed below](#list-of-downstream-browsers) as keys, and versions as keys within them:
-
-```javascript
-{
- "chrome": {
- "53": {
- "year": 2016,
- "release_date": "2016-09-07"
- },
- ...
-}
-```
-
-Downstream browsers will include extra fields for `engine` and `engine_versions`
-
-```javascript
-{
- ...
- "webview_android": {
- "53": {
- "year": 2016,
- "release_date": "2016-09-07",
- "engine": "Blink",
- "engine_version": "53"
- },
- ...
-}
-```
-
-To return a `String` in CSV format, set `outputFormat` to `csv`:
-
-```javascript
-getAllVersions({
- outputFormat: "csv",
-});
-```
-
-`getAllVersions` returns a `String` with a header row and comma-separated values for each browser version that you can write to a file or pass to another service. Core browsers will have "NULL" as the value for their `engine` and `engine_version`:
-
-```csv
-"browser","version","year","supports","release_date","engine","engine_version"
-...
-"chrome","24","pre_baseline","","2013-01-10","NULL","NULL"
-...
-"chrome","53","2016","","2016-09-07","NULL","NULL"
-...
-"firefox","135","2024","widely","2025-02-04","NULL","NULL"
-"firefox","136","2024","newly","2025-03-04","NULL","NULL"
-...
-"ya_android","20.12","2020","year_only","2020-12-20","Blink","87"
-...
-```
-
-> [!NOTE]
-> The above example uses `"includeDownstreamBrowsers": true`
-
-### Static resources
-
-The outputs of `getAllVersions()` are available as JSON or CSV files generated on a daily basis and hosted on GitHub pages:
-
-- Core browsers only
- - [Array](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_array.json)
- - [Object](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_object.json)
- - [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions.csv)
-- Core browsers only, with `supports` property
- - [Array](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_array_with_supports.json)
- - [Object](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_object_with_supports.json)
- - [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_with_supports.csv)
-- Including downstream browsers
- - [Array](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_array.json)
- - [Object](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_object.json)
- - [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions.csv)
-- Including downstream browsers with `supports` property
- - [Array](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_array_with_supports.json)
- - [Object](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_object_with_supports.json)
- - [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_with_supports.csv)
-
-These files are updated on a daily basis.
-
-## CLI
-
-`baseline-browser-mapping` includes a command line interface that exposes the same data and options as the `getCompatibleVersions()` function. To learn more about using the CLI, run:
-
-```sh
-npx baseline-browser-mapping --help
-```
-
-## Downstream browsers
-
-### Limitations
-
-The browser versions in this module come from two different sources:
-
-- MDN's `browser-compat-data` module.
-- Parsed user agent strings provided by [useragents.io](https://useragents.io/)
-
-MDN `browser-compat-data` is an authoritative source of information for the browsers it contains. The release dates for the Baseline core browser set and the mapping of downstream browsers to Chromium versions should be considered accurate.
-
-Browser mappings from useragents.io are provided on a best effort basis. They assume that browser vendors are accurately stating the Chromium version they have implemented. The initial set of version mappings was derived from a bulk export in November 2024. This version was iterated over with a Regex match looking for a major Chrome version and a corresponding version of the browser in question, e.g.:
-
-`Mozilla/5.0 (Linux; U; Android 10; en-US; STK-L21 Build/HUAWEISTK-L21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/100.0.4896.58 UCBrowser/13.8.2.1324 Mobile Safari/537.36`
-
-Shows UC Browser Mobile 13.8 implementing Chromium 100, and:
-
-`Mozilla/5.0 (Linux; arm_64; Android 11; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.123 YaBrowser/24.10.2.123.00 SA/3 Mobile Safari/537.36`
-
-Shows Yandex Browser Mobile 24.10 implementing Chromium 128. The Chromium version from this string is mapped to the corresponding Chrome version from MDN `browser-compat-data`.
-
-> [!NOTE]
-> Where possible, approximate release dates have been included based on useragents.io "first seen" data. useragents.io does not have "first seen" dates prior to June 2020. However, these browsers' Baseline compatibility is determined by their Chromium or Gecko version, so their release dates are more informative than critical.
-
-This data is updated on a daily basis using a [script](https://github.com/web-platform-dx/web-features/tree/main/scripts/refresh-downstream.ts) triggered by a GitHub [action](https://github.com/web-platform-dx/web-features/tree/main/.github/workflows/refresh_downstream.yml). Useragents.io provides a private API for this module which exposes the last 7 days of newly seen user agents for the currently tracked browsers. If a new major version of one of the tracked browsers is encountered with a Chromium version that meets or exceeds the previous latest version of that browser, it is added to the [src/data/downstream-browsers.json](src/data/downstream-browsers.json) file with the date it was first seen by useragents.io as its release date.
-
-KaiOS is an exception - its upstream version mappings are handled separately from the other browsers because they happen very infrequently.
-
-### List of downstream browsers
-
-| Browser | ID | Core | Source |
-| --------------------- | ------------------------- | ------- | ------------------------- |
-| Chrome | `chrome` | `true` | MDN `browser-compat-data` |
-| Chrome for Android | `chrome_android` | `true` | MDN `browser-compat-data` |
-| Edge | `edge` | `true` | MDN `browser-compat-data` |
-| Firefox | `firefox` | `true` | MDN `browser-compat-data` |
-| Firefox for Android | `firefox_android` | `true` | MDN `browser-compat-data` |
-| Safari | `safari` | `true` | MDN `browser-compat-data` |
-| Safari on iOS | `safari_ios` | `true` | MDN `browser-compat-data` |
-| Opera | `opera` | `false` | MDN `browser-compat-data` |
-| Opera Android | `opera_android` | `false` | MDN `browser-compat-data` |
-| Samsung Internet | `samsunginternet_android` | `false` | MDN `browser-compat-data` |
-| WebView Android | `webview_android` | `false` | MDN `browser-compat-data` |
-| QQ Browser Mobile | `qq_android` | `false` | useragents.io |
-| UC Browser Mobile | `uc_android` | `false` | useragents.io |
-| Yandex Browser Mobile | `ya_android` | `false` | useragents.io |
-| KaiOS | `kai_os` | `false` | Manual |
-| Facebook for Android | `facebook_android` | `false` | useragents.io |
-| Instagram for Android | `instagram_android` | `false` | useragents.io |
-
-> [!NOTE]
-> All the non-core browsers currently included implement Chromium or Gecko. Their inclusion in any of the above methods is based on the Baseline feature set supported by the Chromium or Gecko version they implement, not their release date.
diff --git a/node_modules/baseline-browser-mapping/package.json b/node_modules/baseline-browser-mapping/package.json
deleted file mode 100644
index 7dcb17b..0000000
--- a/node_modules/baseline-browser-mapping/package.json
+++ /dev/null
@@ -1,64 +0,0 @@
-{
- "name": "baseline-browser-mapping",
- "main": "./dist/index.cjs",
- "version": "2.9.19",
- "description": "A library for obtaining browser versions with their maximum supported Baseline feature set and Widely Available status.",
- "exports": {
- ".": {
- "require": "./dist/index.cjs",
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- },
- "./legacy": {
- "require": "./dist/index.cjs",
- "types": "./dist/index.d.ts"
- }
- },
- "jsdelivr": "./dist/index.js",
- "files": [
- "dist/*",
- "!dist/scripts/*",
- "LICENSE.txt",
- "README.md"
- ],
- "types": "./dist/index.d.ts",
- "type": "module",
- "bin": {
- "baseline-browser-mapping": "dist/cli.js"
- },
- "scripts": {
- "fix-cli-permissions": "output=$(npx baseline-browser-mapping 2>&1); path=$(printf '%s\n' \"$output\" | sed -n 's/^.*: \\(.*\\): Permission denied$/\\1/p; t; s/^\\(.*\\): Permission denied$/\\1/p'); if [ -n \"$path\" ]; then echo \"Permission denied for: $path\"; echo \"Removing $path ...\"; rm -rf \"$path\"; else echo \"$output\"; fi",
- "test:format": "npx prettier --check .",
- "test:lint": "npx eslint .",
- "test:jasmine": "npx jasmine",
- "test:jasmine-browser": "npx jasmine-browser-runner runSpecs --config ./spec/support/jasmine-browser.js",
- "test": "npm run build && npm run fix-cli-permissions && npm run test:format && npm run test:lint && npm run test:jasmine && npm run test:jasmine-browser",
- "build": "rm -rf dist; npx prettier . --write; rollup -c; rm -rf ./dist/scripts/expose-data.d.ts ./dist/cli.d.ts",
- "refresh-downstream": "npx tsx scripts/refresh-downstream.ts",
- "refresh-static": "npx tsx scripts/refresh-static.ts",
- "update-data-file": "npx tsx scripts/update-data-file.ts; npx prettier ./src/data/data.js --write",
- "update-data-dependencies": "npm i @mdn/browser-compat-data@latest web-features@latest -D",
- "check-data-changes": "git diff --name-only | grep -q '^src/data/data.js$' && echo 'changes-available=TRUE' || echo 'changes-available=FALSE'"
- },
- "license": "Apache-2.0",
- "devDependencies": {
- "@mdn/browser-compat-data": "^7.2.5",
- "@rollup/plugin-terser": "^0.4.4",
- "@rollup/plugin-typescript": "^12.1.3",
- "@types/node": "^22.15.17",
- "eslint-plugin-new-with-error": "^5.0.0",
- "jasmine": "^5.8.0",
- "jasmine-browser-runner": "^3.0.0",
- "jasmine-spec-reporter": "^7.0.0",
- "prettier": "^3.5.3",
- "rollup": "^4.44.0",
- "tslib": "^2.8.1",
- "typescript": "^5.7.2",
- "typescript-eslint": "^8.35.0",
- "web-features": "^3.14.0"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/web-platform-dx/baseline-browser-mapping.git"
- }
-}
diff --git a/shelfmark/bypass/external_bypasser.py b/shelfmark/bypass/external_bypasser.py
index cb0b0f1..1d98781 100644
--- a/shelfmark/bypass/external_bypasser.py
+++ b/shelfmark/bypass/external_bypasser.py
@@ -11,6 +11,7 @@ from shelfmark.bypass import BypassCancelledException
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
+from shelfmark.download.network import get_ssl_verify
if TYPE_CHECKING:
from shelfmark.download import network
@@ -46,7 +47,8 @@ def _fetch_via_bypasser(target_url: str) -> Optional[str]:
f"{bypasser_url}{bypasser_path}",
headers={"Content-Type": "application/json"},
json={"cmd": "request.get", "url": target_url, "maxTimeout": bypasser_timeout},
- timeout=(CONNECT_TIMEOUT, read_timeout)
+ timeout=(CONNECT_TIMEOUT, read_timeout),
+ verify=get_ssl_verify(bypasser_url),
)
response.raise_for_status()
result = response.json()
diff --git a/shelfmark/bypass/internal_bypasser.py b/shelfmark/bypass/internal_bypasser.py
index 8e6ac21..4e94a65 100644
--- a/shelfmark/bypass/internal_bypasser.py
+++ b/shelfmark/bypass/internal_bypasser.py
@@ -23,7 +23,7 @@ from shelfmark.config.settings import RECORDING_DIR
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.download import network
-from shelfmark.download.network import get_proxies
+from shelfmark.download.network import get_proxies, get_ssl_verify
logger = setup_logger(__name__)
@@ -931,7 +931,7 @@ def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]:
headers['User-Agent'] = stored_ua
logger.debug(f"Trying request with cached cookies: {url}")
- response = requests.get(url, cookies=cookies, headers=headers, proxies=get_proxies(url), timeout=(5, 10))
+ response = requests.get(url, cookies=cookies, headers=headers, proxies=get_proxies(url), timeout=(5, 10), verify=get_ssl_verify(url))
if response.status_code == 200:
logger.debug("Cached cookies worked, skipped Chrome bypass")
return response.text
diff --git a/shelfmark/config/security_handlers.py b/shelfmark/config/security_handlers.py
index 0ade312..69ef82c 100644
--- a/shelfmark/config/security_handlers.py
+++ b/shelfmark/config/security_handlers.py
@@ -5,6 +5,7 @@ from typing import Any, Callable
from shelfmark.core.utils import normalize_http_url
from shelfmark.core.user_db import UserDB
+from shelfmark.download.network import get_ssl_verify
_OIDC_LOCKOUT_MESSAGE = "A local admin account with a password is required before enabling OIDC. Use the 'Go to Users' button above to create one. This ensures you can still sign in if your identity provider is unavailable."
@@ -59,7 +60,7 @@ def test_oidc_connection(
if not discovery_url:
return {"success": False, "message": "Discovery URL is not configured."}
- response = requests.get(discovery_url, timeout=10)
+ response = requests.get(discovery_url, timeout=10, verify=get_ssl_verify(discovery_url))
response.raise_for_status()
document = response.json()
diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py
index 951f446..2755202 100644
--- a/shelfmark/config/settings.py
+++ b/shelfmark/config/settings.py
@@ -473,6 +473,17 @@ def network_settings():
tor_overrides_network = tor_enabled # Only override when Tor is actually active
return [
+ SelectField(
+ key="CERTIFICATE_VALIDATION",
+ label="Certificate Validation",
+ description="Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr).",
+ options=[
+ {"value": "enabled", "label": "Enabled (Recommended)"},
+ {"value": "disabled_local", "label": "Disabled for Local Addresses"},
+ {"value": "disabled", "label": "Disabled"},
+ ],
+ default="enabled",
+ ),
SelectField(
key="CUSTOM_DNS",
label="DNS Provider",
diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py
index ce1a8f2..903bb49 100644
--- a/shelfmark/core/activity_routes.py
+++ b/shelfmark/core/activity_routes.py
@@ -63,6 +63,30 @@ def _emit_activity_event(ws_manager: Any | None, *, room: str, payload: dict[str
logger.warning("Failed to emit activity_update event: %s", exc)
+def _list_admin_user_ids(user_db: UserDB) -> list[int]:
+ admin_ids: set[int] = set()
+ try:
+ users = user_db.list_users()
+ except Exception as exc:
+ logger.warning("Failed to list users while resolving admin dismissal scope: %s", exc)
+ return []
+
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ role = str(user.get("role") or "").strip().lower()
+ if role != "admin":
+ continue
+ try:
+ user_id = int(user.get("id"))
+ except (TypeError, ValueError):
+ continue
+ if user_id > 0:
+ admin_ids.add(user_id)
+
+ return sorted(admin_ids)
+
+
def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int | None) -> list[dict[str, Any]]:
if is_admin:
request_rows = user_db.list_requests()
@@ -302,9 +326,16 @@ def register_activity_routes(
emit_request_updates(updated_requests)
request_rows = _list_visible_requests(user_db, is_admin=is_admin, db_user_id=db_user_id)
- if not is_admin and db_user_id is not None:
+ if viewer_db_user_id is not None:
+ owner_user_scope = None if is_admin else db_user_id
+ if not is_admin and owner_user_scope is None:
+ owner_user_scope = viewer_db_user_id
try:
- terminal_rows = activity_service.get_undismissed_terminal_downloads(db_user_id, limit=200)
+ terminal_rows = activity_service.get_undismissed_terminal_downloads(
+ viewer_db_user_id,
+ owner_user_id=owner_user_scope,
+ limit=200,
+ )
_merge_terminal_snapshot_backfill(status=status, terminal_rows=terminal_rows)
except Exception as exc:
logger.warning("Failed to merge terminal snapshot backfill rows: %s", exc)
@@ -361,26 +392,42 @@ def register_activity_routes(
logger.warning("Failed to resolve activity snapshot id for dismiss payload: %s", exc)
activity_log_id = None
+ item_type = str(data.get("item_type") or "").strip().lower()
+ target_user_ids = [db_user_id]
+ if bool(session.get("is_admin")) and item_type == "request":
+ admin_ids = _list_admin_user_ids(user_db)
+ if db_user_id not in admin_ids:
+ admin_ids.append(db_user_id)
+ target_user_ids = sorted(set(admin_ids))
+
+ dismissal = None
try:
- dismissal = activity_service.dismiss_item(
- user_id=db_user_id,
- item_type=data.get("item_type"),
- item_key=data.get("item_key"),
- activity_log_id=activity_log_id,
- )
+ for target_user_id in target_user_ids:
+ target_dismissal = activity_service.dismiss_item(
+ user_id=target_user_id,
+ item_type=data.get("item_type"),
+ item_key=data.get("item_key"),
+ activity_log_id=activity_log_id,
+ )
+ if target_user_id == db_user_id:
+ dismissal = target_dismissal
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
- _emit_activity_event(
- ws_manager,
- room=f"user_{db_user_id}",
- payload={
- "kind": "dismiss",
- "user_id": db_user_id,
- "item_type": dismissal["item_type"],
- "item_key": dismissal["item_key"],
- },
- )
+ if dismissal is None:
+ return jsonify({"error": "Failed to persist dismissal"}), 500
+
+ for target_user_id in target_user_ids:
+ _emit_activity_event(
+ ws_manager,
+ room=f"user_{target_user_id}",
+ payload={
+ "kind": "dismiss",
+ "user_id": target_user_id,
+ "item_type": dismissal["item_type"],
+ "item_key": dismissal["item_key"],
+ },
+ )
return jsonify({"status": "dismissed", "item": dismissal})
@@ -427,20 +474,40 @@ def register_activity_routes(
normalized_payload["activity_log_id"] = activity_log_id
normalized_items.append(normalized_payload)
+ request_items = [
+ item
+ for item in normalized_items
+ if str(item.get("item_type") or "").strip().lower() == "request"
+ ]
+ actor_is_admin = bool(session.get("is_admin"))
+ target_user_ids = [db_user_id]
+ if actor_is_admin and request_items:
+ admin_ids = _list_admin_user_ids(user_db)
+ if db_user_id not in admin_ids:
+ admin_ids.append(db_user_id)
+ target_user_ids = sorted(set(admin_ids))
+
try:
dismissed_count = activity_service.dismiss_many(user_id=db_user_id, items=normalized_items)
+ if actor_is_admin and request_items:
+ for target_user_id in target_user_ids:
+ if target_user_id == db_user_id:
+ continue
+ activity_service.dismiss_many(user_id=target_user_id, items=request_items)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
- _emit_activity_event(
- ws_manager,
- room=f"user_{db_user_id}",
- payload={
- "kind": "dismiss_many",
- "user_id": db_user_id,
- "count": dismissed_count,
- },
- )
+ for target_user_id in target_user_ids:
+ target_count = dismissed_count if target_user_id == db_user_id else len(request_items)
+ _emit_activity_event(
+ ws_manager,
+ room=f"user_{target_user_id}",
+ payload={
+ "kind": "dismiss_many",
+ "user_id": target_user_id,
+ "count": target_count,
+ },
+ )
return jsonify({"status": "dismissed", "count": dismissed_count})
diff --git a/shelfmark/core/activity_service.py b/shelfmark/core/activity_service.py
index ebc0610..b97bed0 100644
--- a/shelfmark/core/activity_service.py
+++ b/shelfmark/core/activity_service.py
@@ -543,9 +543,25 @@ class ActivityService:
finally:
conn.close()
- def get_undismissed_terminal_downloads(self, user_id: int, *, limit: int = 200) -> list[dict[str, Any]]:
- """Return latest undismissed terminal download snapshots for one user."""
- normalized_user_id = self._coerce_positive_int(user_id, "user_id")
+ def get_undismissed_terminal_downloads(
+ self,
+ viewer_user_id: int,
+ *,
+ owner_user_id: int | None,
+ limit: int = 200,
+ ) -> list[dict[str, Any]]:
+ """Return latest undismissed terminal download snapshots for a viewer.
+
+ `viewer_user_id` controls which dismissals are applied.
+ `owner_user_id` scopes activity rows to one owner when provided; when
+ omitted, rows across all owners are considered.
+ """
+ normalized_viewer_user_id = self._coerce_positive_int(viewer_user_id, "viewer_user_id")
+ normalized_owner_user_id = (
+ self._coerce_positive_int(owner_user_id, "owner_user_id")
+ if owner_user_id is not None
+ else None
+ )
normalized_limit = max(1, min(int(limit), 500))
conn = self._connect()
@@ -568,14 +584,19 @@ class ActivityService:
ON d.user_id = ?
AND d.item_type = l.item_type
AND d.item_key = l.item_key
- WHERE l.user_id = ?
+ WHERE (? IS NULL OR l.user_id = ?)
AND l.item_type = 'download'
AND l.final_status IN ('complete', 'error', 'cancelled')
AND d.id IS NULL
ORDER BY l.terminal_at DESC, l.id DESC
LIMIT ?
""",
- (normalized_user_id, normalized_user_id, normalized_limit * 2),
+ (
+ normalized_viewer_user_id,
+ normalized_owner_user_id,
+ normalized_owner_user_id,
+ normalized_limit * 2,
+ ),
).fetchall()
payload: list[dict[str, Any]] = []
diff --git a/shelfmark/core/image_cache.py b/shelfmark/core/image_cache.py
index 681f58c..2ec41cf 100644
--- a/shelfmark/core/image_cache.py
+++ b/shelfmark/core/image_cache.py
@@ -11,6 +11,7 @@ from typing import Any, Dict, Optional, Tuple
import requests
from shelfmark.core.logger import setup_logger
+from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
@@ -482,6 +483,7 @@ class ImageCacheService:
timeout=(5, 10),
headers=FETCH_HEADERS,
stream=True,
+ verify=get_ssl_verify(url),
)
response.raise_for_status()
diff --git a/shelfmark/core/oidc_routes.py b/shelfmark/core/oidc_routes.py
index 4dc2740..2cd65ea 100644
--- a/shelfmark/core/oidc_routes.py
+++ b/shelfmark/core/oidc_routes.py
@@ -19,6 +19,7 @@ from shelfmark.core.oidc_auth import (
)
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
+from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
oauth = OAuth()
@@ -38,16 +39,6 @@ def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
return {}
-def _is_email_verified(claims: dict[str, Any]) -> bool:
- """Normalize provider-specific email_verified values into a strict boolean."""
- value = claims.get("email_verified", False)
- if isinstance(value, bool):
- return value
- if isinstance(value, str):
- return value.strip().lower() == "true"
- return False
-
-
def _has_username_or_email(claims: dict[str, Any]) -> bool:
"""Return True when claims include a usable username or email."""
for key in ("preferred_username", "email"):
@@ -90,6 +81,11 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
scopes.append(group_claim)
+ def _ssl_compliance_fix(session, **kwargs):
+ """Set session.verify based on the Certificate Validation setting."""
+ session.verify = get_ssl_verify(discovery_url)
+ return session
+
oauth._clients.pop("shelfmark_idp", None)
oauth.register(
name="shelfmark_idp",
@@ -100,6 +96,7 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
"scope": " ".join(scopes),
"code_challenge_method": "S256",
},
+ compliance_fix=_ssl_compliance_fix,
overwrite=True,
)
@@ -196,7 +193,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
if admin_group and use_admin_group:
is_admin = admin_group in groups
- allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
+ allow_email_link = bool(user_info.get("email"))
user = provision_oidc_user(
user_db,
user_info,
diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py
index 173b651..00d2b2f 100644
--- a/shelfmark/core/settings_registry.py
+++ b/shelfmark/core/settings_registry.py
@@ -1092,6 +1092,18 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
):
_apply_dns_settings(config_obj)
+ # Apply certificate validation changes live (network tab)
+ if (
+ config_obj is not None
+ and tab_name == "network"
+ and "CERTIFICATE_VALIDATION" in values_to_save
+ ):
+ try:
+ from shelfmark.download.network import _apply_ssl_warning_suppression
+ _apply_ssl_warning_suppression()
+ except Exception as e:
+ logger.warning(f"Failed to apply certificate validation setting: {e}")
+
# Apply AA mirror settings changes live (mirrors tab)
aa_keys = {"AA_BASE_URL", "AA_MIRROR_URLS", "AA_ADDITIONAL_URLS"}
if (
diff --git a/shelfmark/download/clients/deluge.py b/shelfmark/download/clients/deluge.py
index fa5c328..43e7be7 100644
--- a/shelfmark/download/clients/deluge.py
+++ b/shelfmark/download/clients/deluge.py
@@ -18,6 +18,7 @@ from urllib.parse import urlparse
import requests
from shelfmark.core.config import config
+from shelfmark.download.network import get_ssl_verify
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.clients import (
@@ -110,7 +111,7 @@ class DelugeClient(DownloadClient):
"params": list(params),
}
- response = self._session.post(self._rpc_url, json=payload, timeout=timeout)
+ response = self._session.post(self._rpc_url, json=payload, timeout=timeout, verify=get_ssl_verify(self._rpc_url))
response.raise_for_status()
data = response.json()
diff --git a/shelfmark/download/clients/nzbget.py b/shelfmark/download/clients/nzbget.py
index 30de99b..54a4528 100644
--- a/shelfmark/download/clients/nzbget.py
+++ b/shelfmark/download/clients/nzbget.py
@@ -12,6 +12,7 @@ import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
+from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
@@ -79,6 +80,7 @@ class NZBGetClient(DownloadClient):
headers={"Content-Type": "application/json"},
auth=(self.username, self.password),
timeout=30,
+ verify=get_ssl_verify(rpc_url),
)
response.raise_for_status()
@@ -135,7 +137,7 @@ class NZBGetClient(DownloadClient):
try:
# Fetch NZB content from the URL (handles Prowlarr proxy redirects)
logger.debug(f"Fetching NZB from: {url}")
- response = requests.get(url, timeout=30)
+ response = requests.get(url, timeout=30, verify=get_ssl_verify(url))
response.raise_for_status()
nzb_content = base64.b64encode(response.content).decode('ascii')
diff --git a/shelfmark/download/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py
index 05a5fa6..188351a 100644
--- a/shelfmark/download/clients/qbittorrent.py
+++ b/shelfmark/download/clients/qbittorrent.py
@@ -7,6 +7,7 @@ from typing import Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
+from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
@@ -136,6 +137,7 @@ class QBittorrentClient(DownloadClient):
host=self._base_url,
username=config.get("QBITTORRENT_USERNAME", ""),
password=config.get("QBITTORRENT_PASSWORD", ""),
+ VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(self._base_url),
)
self._category = config.get("QBITTORRENT_CATEGORY", "books")
self._download_dir = config.get("QBITTORRENT_DOWNLOAD_DIR", "")
diff --git a/shelfmark/download/clients/rtorrent.py b/shelfmark/download/clients/rtorrent.py
index b1ac53d..80eaed2 100644
--- a/shelfmark/download/clients/rtorrent.py
+++ b/shelfmark/download/clients/rtorrent.py
@@ -4,12 +4,14 @@ rTorrent download client for Prowlarr integration.
Uses xmlrpc to communicate with rTorrent's RPC interface.
"""
-from typing import Optional, Tuple
+import ssl
+from typing import Any, Optional, Tuple
from urllib.parse import urlparse
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
+from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
@@ -22,6 +24,21 @@ from shelfmark.download.clients.torrent_utils import (
logger = setup_logger(__name__)
+def _create_rtorrent_server_proxy(url: str) -> Any:
+ """Create an XML-RPC ServerProxy honoring certificate validation mode."""
+ from xmlrpc.client import SafeTransport, ServerProxy
+
+ verify = get_ssl_verify(url)
+ if url.startswith("https://") and not verify:
+ ssl_context = ssl.create_default_context()
+ ssl_context.check_hostname = False
+ ssl_context.verify_mode = ssl.CERT_NONE
+ transport = SafeTransport(context=ssl_context)
+ return ServerProxy(url, transport=transport)
+
+ return ServerProxy(url)
+
+
@register_client("torrent")
class RTorrentClient(DownloadClient):
"""rTorrent download client using xmlrpc."""
@@ -31,8 +48,6 @@ class RTorrentClient(DownloadClient):
def __init__(self):
"""Initialize rTorrent client with settings from config."""
- from xmlrpc.client import ServerProxy
-
raw_url = config.get("RTORRENT_URL", "")
if not raw_url:
raise ValueError("RTORRENT_URL is required")
@@ -50,7 +65,7 @@ class RTorrentClient(DownloadClient):
f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
)
- self._rpc = ServerProxy(self._base_url)
+ self._rpc = _create_rtorrent_server_proxy(self._base_url)
self._download_dir = config.get("RTORRENT_DOWNLOAD_DIR", "")
self._label = config.get("RTORRENT_LABEL", "")
diff --git a/shelfmark/download/clients/sabnzbd.py b/shelfmark/download/clients/sabnzbd.py
index 68f4c76..3b6df5c 100644
--- a/shelfmark/download/clients/sabnzbd.py
+++ b/shelfmark/download/clients/sabnzbd.py
@@ -12,6 +12,7 @@ import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
+from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
@@ -148,7 +149,7 @@ class SABnzbdClient(DownloadClient):
if params:
request_params.update(params)
- response = requests.get(api_url, params=request_params, timeout=30)
+ response = requests.get(api_url, params=request_params, timeout=30, verify=get_ssl_verify(api_url))
response.raise_for_status()
result = response.json()
@@ -177,7 +178,7 @@ class SABnzbdClient(DownloadClient):
}
files = {"name": (filename, nzb_content, "application/x-nzb")}
- response = requests.post(api_url, params=request_params, files=files, timeout=30)
+ response = requests.post(api_url, params=request_params, files=files, timeout=30, verify=get_ssl_verify(api_url))
response.raise_for_status()
result = response.json()
@@ -190,7 +191,7 @@ class SABnzbdClient(DownloadClient):
def _fetch_nzb_content(self, url: str) -> bytes:
"""Fetch NZB content, including Prowlarr auth headers when appropriate."""
headers = self._get_prowlarr_headers(url)
- response = requests.get(url, timeout=30, headers=headers)
+ response = requests.get(url, timeout=30, headers=headers, verify=get_ssl_verify(url))
response.raise_for_status()
return response.content
diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py
index b70c42a..2fad373 100644
--- a/shelfmark/download/clients/settings.py
+++ b/shelfmark/download/clients/settings.py
@@ -1,5 +1,6 @@
"""Shared download client settings registration."""
+from contextlib import contextmanager
from typing import Any, Dict, Optional
from shelfmark.core.settings_registry import (
@@ -12,10 +13,39 @@ from shelfmark.core.settings_registry import (
TagListField,
)
from shelfmark.core.utils import normalize_http_url
+from shelfmark.download.network import get_ssl_verify
# ==================== Test Connection Callbacks ====================
+@contextmanager
+def _transmission_session_verify_override(url: str):
+ """Ensure transmission-rpc constructor uses the configured TLS verify mode."""
+ verify = get_ssl_verify(url)
+ if verify:
+ yield
+ return
+
+ try:
+ import transmission_rpc.client as transmission_rpc_client
+ except Exception:
+ yield
+ return
+
+ original_session_factory = transmission_rpc_client.requests.Session
+
+ def _session_factory(*args: Any, **kwargs: Any) -> Any:
+ session = original_session_factory(*args, **kwargs)
+ session.verify = False
+ return session
+
+ transmission_rpc_client.requests.Session = _session_factory
+ try:
+ yield
+ finally:
+ transmission_rpc_client.requests.Session = original_session_factory
+
+
def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the qBittorrent connection using current form values."""
from shelfmark.core.config import config
@@ -36,7 +66,7 @@ def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None
if not url:
return {"success": False, "message": "qBittorrent URL is invalid"}
- client = Client(host=url, username=username, password=password)
+ client = Client(host=url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url))
client.auth_log_in()
api_version = client.app.web_api_version
return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"}
@@ -81,17 +111,25 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non
"protocol": protocol,
}
try:
- client = Client(**client_kwargs)
+ with _transmission_session_verify_override(url):
+ client = Client(**client_kwargs)
except TypeError as e:
if "protocol" not in str(e):
raise
client_kwargs.pop("protocol", None)
- client = Client(**client_kwargs)
+ with _transmission_session_verify_override(url):
+ client = Client(**client_kwargs)
if protocol == "https" and hasattr(client, "protocol"):
try:
setattr(client, "protocol", protocol)
except Exception:
pass
+
+ # Keep session verify aligned for subsequent calls beyond constructor bootstrap.
+ http_session = getattr(client, "_http_session", None)
+ if http_session is not None:
+ http_session.verify = get_ssl_verify(url)
+
session = client.get_session()
version = session.version
return {"success": True, "message": f"Connected to Transmission {version}"}
@@ -151,7 +189,7 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any:
payload = {"id": rpc_id, "method": method, "params": list(params)}
- resp = session.post(rpc_url, json=payload, timeout=15)
+ resp = session.post(rpc_url, json=payload, timeout=15, verify=get_ssl_verify(rpc_url))
resp.raise_for_status()
data = resp.json()
if data.get("error"):
@@ -214,8 +252,9 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the rTorrent connection using current form values."""
from shelfmark.core.config import config
+ import ssl
from urllib.parse import urlparse
- from xmlrpc.client import ServerProxy
+ from xmlrpc.client import SafeTransport, ServerProxy
current_values = current_values or {}
@@ -236,7 +275,16 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
parsed = urlparse(url)
url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
- rpc = ServerProxy(url.rstrip("/"))
+ rpc_url = url.rstrip("/")
+ verify = get_ssl_verify(rpc_url)
+ if rpc_url.startswith("https://") and not verify:
+ ssl_context = ssl.create_default_context()
+ ssl_context.check_hostname = False
+ ssl_context.verify_mode = ssl.CERT_NONE
+ rpc = ServerProxy(rpc_url, transport=SafeTransport(context=ssl_context))
+ else:
+ rpc = ServerProxy(rpc_url)
+
version = rpc.system.client_version()
return {"success": True, "message": f"Connected to rTorrent {version}"}
except Exception as e:
@@ -264,7 +312,7 @@ def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) ->
try:
rpc_url = f"{url.rstrip('/')}/jsonrpc"
payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1}
- response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30)
+ response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30, verify=get_ssl_verify(rpc_url))
response.raise_for_status()
result = response.json()
if "error" in result and result["error"]:
@@ -301,7 +349,7 @@ def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) ->
try:
api_url = f"{url.rstrip('/')}/api"
params = {"apikey": api_key, "mode": "version", "output": "json"}
- response = requests.get(api_url, params=params, timeout=30)
+ response = requests.get(api_url, params=params, timeout=30, verify=get_ssl_verify(api_url))
response.raise_for_status()
result = response.json()
version = result.get("version", "unknown")
diff --git a/shelfmark/download/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py
index 10c4653..eff4569 100644
--- a/shelfmark/download/clients/torrent_utils.py
+++ b/shelfmark/download/clients/torrent_utils.py
@@ -11,6 +11,7 @@ import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
+from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
@@ -88,7 +89,7 @@ def extract_torrent_info(
# Use allow_redirects=False to handle magnet link redirects manually
# Some indexers redirect download URLs to magnet links
- resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers)
+ resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers, verify=get_ssl_verify(url))
# Check if this is a redirect to a magnet link
if resp.status_code in (301, 302, 303, 307, 308):
@@ -103,7 +104,7 @@ def extract_torrent_info(
)
# Not a magnet redirect, follow it manually
logger.debug(f"Following redirect to: {redirect_url[:80]}...")
- resp = requests.get(redirect_url, timeout=30, headers=headers)
+ resp = requests.get(redirect_url, timeout=30, headers=headers, verify=get_ssl_verify(redirect_url))
resp.raise_for_status()
torrent_data = resp.content
diff --git a/shelfmark/download/clients/transmission.py b/shelfmark/download/clients/transmission.py
index ccd116e..9db3094 100644
--- a/shelfmark/download/clients/transmission.py
+++ b/shelfmark/download/clients/transmission.py
@@ -4,12 +4,14 @@ Transmission download client for Prowlarr integration.
Uses the transmission-rpc library to communicate with Transmission's RPC API.
"""
-from typing import Optional, Tuple
+from contextlib import contextmanager
+from typing import Any, Iterator, Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
+from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
@@ -23,6 +25,50 @@ from shelfmark.download.clients.torrent_utils import (
logger = setup_logger(__name__)
+@contextmanager
+def _transmission_session_verify_override(url: str) -> Iterator[None]:
+ """Temporarily override transmission-rpc's session factory when verify is disabled.
+
+ transmission-rpc performs an RPC call inside Client.__init__, so verify must be
+ set before the client is constructed.
+ """
+ verify = get_ssl_verify(url)
+ if verify:
+ yield
+ return
+
+ try:
+ import transmission_rpc.client as transmission_rpc_client
+ except Exception:
+ # If internals differ, gracefully fall back to default behavior.
+ yield
+ return
+
+ original_session_factory = transmission_rpc_client.requests.Session
+
+ def _session_factory(*args: Any, **kwargs: Any) -> Any:
+ session = original_session_factory(*args, **kwargs)
+ session.verify = False
+ return session
+
+ transmission_rpc_client.requests.Session = _session_factory
+ try:
+ yield
+ finally:
+ transmission_rpc_client.requests.Session = original_session_factory
+
+
+def _apply_transmission_ssl_verify(client: Any, url: str) -> None:
+ """Apply global certificate validation policy to transmission-rpc client."""
+ session = getattr(client, "_http_session", None)
+ if session is None:
+ return
+ try:
+ session.verify = get_ssl_verify(url)
+ except Exception as e:
+ logger.debug("Unable to apply Transmission TLS verify setting: %s", e)
+
+
@register_client("torrent")
class TransmissionClient(DownloadClient):
"""Transmission download client using transmission-rpc library."""
@@ -57,19 +103,22 @@ class TransmissionClient(DownloadClient):
"protocol": protocol,
}
try:
- self._client = Client(**client_kwargs)
+ with _transmission_session_verify_override(url):
+ self._client = Client(**client_kwargs)
except TypeError as e:
# Older transmission-rpc versions may not accept protocol as a kwarg.
if "protocol" not in str(e):
raise
client_kwargs.pop("protocol", None)
- self._client = Client(**client_kwargs)
+ with _transmission_session_verify_override(url):
+ self._client = Client(**client_kwargs)
# Some versions expose protocol as an attribute rather than kwarg.
if protocol == "https" and hasattr(self._client, "protocol"):
try:
setattr(self._client, "protocol", protocol)
except Exception:
pass
+ _apply_transmission_ssl_verify(self._client, url)
self._category = config.get("TRANSMISSION_CATEGORY", "books")
self._download_dir = config.get("TRANSMISSION_DOWNLOAD_DIR", "")
diff --git a/shelfmark/download/http.py b/shelfmark/download/http.py
index a31291d..3cf901e 100644
--- a/shelfmark/download/http.py
+++ b/shelfmark/download/http.py
@@ -11,7 +11,7 @@ import requests
from tqdm import tqdm
from shelfmark.download import network
-from shelfmark.download.network import get_proxies
+from shelfmark.download.network import get_proxies, get_ssl_verify
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
@@ -261,6 +261,7 @@ def html_get_page(
cookies=cookies,
headers=headers,
allow_redirects=allow_redirects,
+ verify=get_ssl_verify(current_url),
)
if is_aa_url and response.is_redirect:
@@ -403,7 +404,7 @@ def download_url(
logger.info(f"Downloading: {current_url} (attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})")
# Try with CF cookies/UA if available
cookies = _apply_cf_bypass(current_url, headers)
- response = requests.get(current_url, stream=True, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
+ response = requests.get(current_url, stream=True, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers, verify=get_ssl_verify(current_url))
response.raise_for_status()
if status_callback:
@@ -514,7 +515,7 @@ def _try_resume(
cookies = _apply_cf_bypass(url, resume_headers)
response = requests.get(
url, stream=True, proxies=get_proxies(url), timeout=REQUEST_TIMEOUT,
- headers=resume_headers, cookies=cookies
+ headers=resume_headers, cookies=cookies, verify=get_ssl_verify(url)
)
# Check resume support
diff --git a/shelfmark/download/network.py b/shelfmark/download/network.py
index ca3dbce..629c742 100644
--- a/shelfmark/download/network.py
+++ b/shelfmark/download/network.py
@@ -90,6 +90,59 @@ def get_proxies(url: str = "") -> dict:
return {}
+
+def get_ssl_verify(url: str = "") -> bool:
+ """Return the ``verify`` value for outbound requests based on the
+ CERTIFICATE_VALIDATION setting.
+
+ - ``enabled`` → always ``True``
+ - ``disabled_local`` → ``False`` for local/private addresses, ``True`` otherwise
+ - ``disabled`` → always ``False``
+ """
+ mode = app_config.get("CERTIFICATE_VALIDATION", "enabled")
+
+ if mode == "disabled":
+ return False
+
+ if mode == "disabled_local" and url:
+ try:
+ parsed = urllib.parse.urlparse(url)
+ hostname = parsed.hostname or ""
+ if hostname and _is_local_address(hostname):
+ return False
+ except Exception:
+ pass
+
+ return True
+
+
+_ssl_warnings_suppressed = False
+
+
+def _apply_ssl_warning_suppression() -> None:
+ """Suppress or restore urllib3 InsecureRequestWarning based on the
+ CERTIFICATE_VALIDATION setting.
+
+ Called once at init and again whenever the setting changes via the UI.
+ Only modifies warning filters when the mode is not 'enabled', so the
+ default case is a complete no-op (zero behavioural change for users who
+ never touch the setting).
+ """
+ global _ssl_warnings_suppressed # noqa: PLW0603
+ import urllib3
+
+ mode = app_config.get("CERTIFICATE_VALIDATION", "enabled")
+ if mode in ("disabled", "disabled_local"):
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+ _ssl_warnings_suppressed = True
+ logger.debug("SSL warnings suppressed (certificate validation: %s)", mode)
+ elif _ssl_warnings_suppressed:
+ import warnings
+ warnings.simplefilter("default", urllib3.exceptions.InsecureRequestWarning)
+ _ssl_warnings_suppressed = False
+ logger.debug("SSL warnings restored (certificate validation: enabled)")
+
+
# DNS state - authoritative values managed by this module
# Other modules should use get_dns_config() to read these
CUSTOM_DNS: List[str] = []
@@ -418,7 +471,8 @@ class DoHResolver:
self.base_url,
params=params,
proxies=get_proxies(self.base_url),
- timeout=10 # Increased from 5s to handle slow network conditions
+ timeout=10, # Increased from 5s to handle slow network conditions
+ verify=get_ssl_verify(self.base_url),
)
response.raise_for_status()
@@ -940,7 +994,7 @@ def _initialize_aa_state() -> None:
logger.debug(f"AA_BASE_URL: auto, checking available urls {_aa_urls}")
for i, url in enumerate(_aa_urls):
try:
- response = requests.get(url, proxies=get_proxies(url), timeout=3)
+ response = requests.get(url, proxies=get_proxies(url), timeout=3, verify=get_ssl_verify(url))
if response.status_code == 200:
_current_aa_url_index = i
_aa_base_url = url
@@ -1036,6 +1090,7 @@ def init(force: bool = False) -> None:
try:
init_dns(force=force)
init_aa(force=force)
+ _apply_ssl_warning_suppression()
# Only set flag AFTER work completes successfully
_initialized = True
except Exception:
diff --git a/shelfmark/metadata_providers/googlebooks.py b/shelfmark/metadata_providers/googlebooks.py
index ccc72da..aed3041 100644
--- a/shelfmark/metadata_providers/googlebooks.py
+++ b/shelfmark/metadata_providers/googlebooks.py
@@ -20,6 +20,7 @@ from shelfmark.core.settings_registry import (
HeadingField,
)
from shelfmark.core.config import config as app_config
+from shelfmark.download.network import get_ssl_verify
from shelfmark.metadata_providers import (
BookMetadata,
DisplayField,
@@ -233,7 +234,7 @@ class GoogleBooksProvider(MetadataProvider):
url = f"{GOOGLE_BOOKS_BASE_URL}{endpoint}"
try:
- response = self.session.get(url, params=params, timeout=15)
+ response = self.session.get(url, params=params, timeout=15, verify=get_ssl_verify(url))
response.raise_for_status()
return response.json()
diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py
index cce004f..2934ea8 100644
--- a/shelfmark/metadata_providers/hardcover.py
+++ b/shelfmark/metadata_providers/hardcover.py
@@ -16,6 +16,7 @@ from shelfmark.core.settings_registry import (
HeadingField,
)
from shelfmark.core.config import config as app_config
+from shelfmark.download.network import get_ssl_verify
from shelfmark.metadata_providers import (
BookMetadata,
DisplayField,
@@ -635,7 +636,8 @@ class HardcoverProvider(MetadataProvider):
response = self.session.post(
HARDCOVER_API_URL,
json={"query": query, "variables": variables},
- timeout=15
+ timeout=15,
+ verify=get_ssl_verify(HARDCOVER_API_URL),
)
response.raise_for_status()
diff --git a/shelfmark/metadata_providers/openlibrary.py b/shelfmark/metadata_providers/openlibrary.py
index 2cd8a74..2537d0a 100644
--- a/shelfmark/metadata_providers/openlibrary.py
+++ b/shelfmark/metadata_providers/openlibrary.py
@@ -10,6 +10,7 @@ import requests
from shelfmark.core.cache import cacheable
from shelfmark.core.logger import setup_logger
+from shelfmark.download.network import get_ssl_verify
from shelfmark.core.settings_registry import (
register_settings,
CheckboxField,
@@ -188,7 +189,8 @@ class OpenLibraryProvider(MetadataProvider):
response = self.session.get(
f"{OPENLIBRARY_BASE_URL}/search.json",
params=params,
- timeout=15
+ timeout=15,
+ verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
)
response.raise_for_status()
data = response.json()
@@ -229,7 +231,8 @@ class OpenLibraryProvider(MetadataProvider):
try:
response = self.session.get(
f"{OPENLIBRARY_BASE_URL}/works/{book_id}.json",
- timeout=15
+ timeout=15,
+ verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
)
response.raise_for_status()
work = response.json()
@@ -261,7 +264,8 @@ class OpenLibraryProvider(MetadataProvider):
# First try the ISBN API which returns edition data
response = self.session.get(
f"{OPENLIBRARY_BASE_URL}/isbn/{clean_isbn}.json",
- timeout=15
+ timeout=15,
+ verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
)
response.raise_for_status()
edition = response.json()
@@ -485,7 +489,8 @@ class OpenLibraryProvider(MetadataProvider):
try:
response = self.session.get(
f"{OPENLIBRARY_BASE_URL}{author_key}.json",
- timeout=10
+ timeout=10,
+ verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
)
response.raise_for_status()
author = response.json()
@@ -504,7 +509,8 @@ def _test_openlibrary_connection() -> Dict[str, Any]:
response = provider.session.get(
f"{OPENLIBRARY_BASE_URL}/search.json",
params={"q": "test", "limit": 1},
- timeout=10
+ timeout=10,
+ verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
)
response.raise_for_status()
data = response.json()
diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py
index ac17034..aea1e4e 100644
--- a/shelfmark/release_sources/__init__.py
+++ b/shelfmark/release_sources/__init__.py
@@ -113,6 +113,13 @@ class LeadingCellConfig:
uppercase: bool = False # Force uppercase for badge text
+@dataclass
+class SortOption:
+ """A sort option that appears in the sort dropdown without being tied to a column."""
+ label: str # Display label in the sort dropdown
+ sort_key: str # Field to sort by on the Release object
+
+
@dataclass
class SourceActionButton:
"""Action button configuration for a release source."""
@@ -131,6 +138,7 @@ class ReleaseColumnConfig:
default_indexers: Optional[List[str]] = None # For Prowlarr: indexers selected in settings (pre-selected in filter)
cache_ttl_seconds: Optional[int] = None # How long to cache results (default: 5 min)
supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language", "indexer"]
+ extra_sort_options: Optional[List[SortOption]] = None # Additional sort options not tied to a column
action_button: Optional[SourceActionButton] = None # Custom action button (replaces default expand search)
@@ -191,6 +199,13 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
if config.supported_filters is not None:
result["supported_filters"] = config.supported_filters
+ # Include extra sort options (sort entries not tied to a column)
+ if config.extra_sort_options:
+ result["extra_sort_options"] = [
+ {"label": opt.label, "sort_key": opt.sort_key}
+ for opt in config.extra_sort_options
+ ]
+
# Include action button if specified (replaces default expand search)
if config.action_button is not None:
result["action_button"] = {
diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py
index 7796a0f..fd23340 100644
--- a/shelfmark/release_sources/direct_download.py
+++ b/shelfmark/release_sources/direct_download.py
@@ -164,7 +164,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
filters_query = ""
- for value in filters.lang or config.BOOK_LANGUAGE:
+ for value in filters.lang if filters.lang is not None else config.BOOK_LANGUAGE:
if value != "all":
filters_query += f"&lang={quote(value)}"
@@ -704,6 +704,7 @@ def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None)
timeout=(5, 10),
allow_redirects=True,
proxies=network.get_proxies(link),
+ verify=network.get_ssl_verify(link),
)
if response.status_code != 200:
@@ -1172,8 +1173,7 @@ class DirectDownloadSource(ReleaseSource):
if isbn:
logger.debug(f"Searching direct_download: isbn='{isbn}', langs={lang_filter}")
filters = SearchFilters(isbn=[isbn])
- if lang_filter:
- filters.lang = lang_filter
+ filters.lang = lang_filter if lang_filter is not None else []
try:
results = search_books(isbn, filters)
if results:
@@ -1200,7 +1200,7 @@ class DirectDownloadSource(ReleaseSource):
continue
logger.debug(f"Searching direct_download: title_author='{query}', langs={langs}")
- filters = SearchFilters(lang=langs) if langs else SearchFilters()
+ filters = SearchFilters(lang=langs if langs is not None else [])
try:
for bi in search_books(query, filters):
if bi.id not in seen_ids:
diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py
index a0ca8d8..c43d1a8 100644
--- a/shelfmark/release_sources/prowlarr/api.py
+++ b/shelfmark/release_sources/prowlarr/api.py
@@ -6,6 +6,7 @@ import requests
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
+from shelfmark.download.network import get_ssl_verify
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
logger = setup_logger(__name__)
@@ -42,6 +43,7 @@ class ProwlarrClient:
params=params,
json=json_data,
timeout=self.timeout,
+ verify=get_ssl_verify(url),
)
if not response.ok:
@@ -193,6 +195,7 @@ class ProwlarrClient:
# Override the session default JSON accept header.
"Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8"
},
+ verify=get_ssl_verify(url),
)
if not response.ok:
try:
diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py
index 475bdba..cc2adba 100644
--- a/shelfmark/release_sources/prowlarr/source.py
+++ b/shelfmark/release_sources/prowlarr/source.py
@@ -22,6 +22,7 @@ from shelfmark.release_sources import (
ColumnColorHint,
LeadingCellConfig,
LeadingCellType,
+ SortOption,
)
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
from shelfmark.core.utils import normalize_http_url
@@ -467,6 +468,9 @@ class ProwlarrSource(ReleaseSource):
sort_key="size_bytes",
),
],
+ extra_sort_options=[
+ SortOption(label="Peers", sort_key="seeders"),
+ ],
grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px",
leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr
available_indexers=available_indexers,
diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx
index 9328dd7..15d7cd7 100644
--- a/src/frontend/src/App.tsx
+++ b/src/frontend/src/App.tsx
@@ -10,6 +10,7 @@ import {
ButtonStateInfo,
RequestPolicyMode,
CreateRequestPayload,
+ isMetadataBook,
} from './types';
import {
getBookInfo,
@@ -673,12 +674,6 @@ function App() {
}
};
- // Handle "Find Downloads" from DetailsModal
- const handleFindDownloads = (book: Book) => {
- setSelectedBook(null);
- setReleaseBook(book);
- };
-
const submitRequest = useCallback(
async (payload: CreateRequestPayload, successMessage: string): Promise => {
try {
@@ -1327,9 +1322,16 @@ function App() {
book={selectedBook}
onClose={() => setSelectedBook(null)}
onDownload={handleDownload}
- onFindDownloads={handleFindDownloads}
+ onFindDownloads={(book) => {
+ setSelectedBook(null);
+ void handleGetReleases(book);
+ }}
onSearchSeries={handleSearchSeries}
- buttonState={getDirectActionButtonState(selectedBook.id)}
+ buttonState={
+ isMetadataBook(selectedBook)
+ ? getUniversalActionButtonState(selectedBook.id)
+ : getDirectActionButtonState(selectedBook.id)
+ }
/>
)}
diff --git a/src/frontend/src/components/BookDownloadButton.tsx b/src/frontend/src/components/BookDownloadButton.tsx
index c1196cf..5e27972 100644
--- a/src/frontend/src/components/BookDownloadButton.tsx
+++ b/src/frontend/src/components/BookDownloadButton.tsx
@@ -69,6 +69,11 @@ export const BookDownloadButton = ({
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
+ const isRequestAction = buttonState.state === 'download' && buttonState.text === 'Request';
+ const iconVariantActionIconPath = isRequestAction
+ ? 'M12 4.5v15m7.5-7.5h-15'
+ : 'M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3';
+ const primaryActionIconPath = isRequestAction ? 'M12 4.5v15m7.5-7.5h-15' : 'M12 4v12m0 0l-4-4m4 4 4-4M6 20h12';
const primaryStateClasses =
isCompleted
@@ -198,10 +203,10 @@ export const BookDownloadButton = ({
return (
<>
>
);
@@ -221,7 +226,7 @@ export const BookDownloadButton = ({
>
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
)}
diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx
index 0e891a9..4fa3050 100644
--- a/src/frontend/src/components/DetailsModal.tsx
+++ b/src/frontend/src/components/DetailsModal.tsx
@@ -78,7 +78,10 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
const isMetadata = isMetadataBook(book);
-
+ const metadataActionText =
+ isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
+ ? 'Find Downloads'
+ : buttonState.text;
const publisherInfo = { label: 'Publisher', value: book.publisher || '-' };
// Build metadata grid based on mode
@@ -336,19 +339,21 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
)}
- {/* Action button - Find Downloads (Universal) or Download (Direct) */}
+ {/* Action button - mirrors search result action state/flow */}
diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx
index 18cb2b5..2bc2d83 100644
--- a/src/frontend/src/components/ReleaseModal.tsx
+++ b/src/frontend/src/components/ReleaseModal.tsx
@@ -24,7 +24,13 @@ import { ReleaseCell } from './ReleaseCell';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { getNestedValue } from '../utils/objectHelpers';
import { LanguageMultiSelect } from './LanguageMultiSelect';
-import { LANGUAGE_OPTION_ALL, LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues, releaseLanguageMatchesFilter, buildLanguageNormalizer } from '../utils/languageFilters';
+import {
+ LANGUAGE_OPTION_DEFAULT,
+ getLanguageFilterValues,
+ getReleaseSearchLanguageParams,
+ releaseLanguageMatchesFilter,
+ buildLanguageNormalizer,
+} from '../utils/languageFilters';
// Module-level cache for release search results
// Key format: `${provider}:${provider_id}:${source}:${contentType}`
@@ -1125,10 +1131,7 @@ export const ReleaseModal = ({
try {
// Resolve language codes for the API call (same logic as Apply button)
- const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
- const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
- ? undefined
- : langCodes;
+ const languagesParam = getReleaseSearchLanguageParams(languageFilter, bookLanguages, defaultLanguages);
// Pass indexer filter only if the source supports it (empty array = search all)
const supportsIndexerFilter = releasesBySource[activeTab]?.column_config?.supported_filters?.includes('indexer');
@@ -1289,6 +1292,21 @@ export const ReleaseModal = ({
return columnConfig.columns.filter(col => col.sortable) || [];
}, [columnConfig]);
+ // Build unified list of all sort options (from sortable columns + extra_sort_options)
+ const allSortOptions = useMemo(() => {
+ const fromColumns = sortableColumns.map(col => ({
+ label: col.label,
+ sortKey: col.sort_key || col.key,
+ defaultDirection: inferDefaultDirection(col.render_type) as 'asc' | 'desc',
+ }));
+ const fromExtra = (columnConfig.extra_sort_options || []).map(opt => ({
+ label: opt.label,
+ sortKey: opt.sort_key,
+ defaultDirection: 'desc' as const, // Extra sort options are typically numeric (e.g., peers)
+ }));
+ return [...fromColumns, ...fromExtra];
+ }, [sortableColumns, columnConfig.extra_sort_options]);
+
// Get current sort state for active tab (from state, localStorage, or default to null = best match)
const currentSort = useMemo((): SortState | null => {
// Check state first - explicit null means "Default" was selected
@@ -1299,17 +1317,17 @@ export const ReleaseModal = ({
const saved = getSavedSort(activeTab);
if (saved) {
// Verify the saved sort is still valid for this source
- const isValid = sortableColumns.some(col => (col.sort_key || col.key) === saved.key);
+ const isValid = allSortOptions.some(opt => opt.sortKey === saved.key);
if (isValid) {
return saved;
}
}
// Default to null (best-match sorting)
return null;
- }, [activeTab, sortBySource, sortableColumns]);
+ }, [activeTab, sortBySource, allSortOptions]);
// Handle sort change - null means "Default" (best title match), otherwise toggle direction or set new column
- const handleSortChange = useCallback((sortKey: string | null, column: ColumnSchema | null) => {
+ const handleSortChange = useCallback((sortKey: string | null, defaultDirection: 'asc' | 'desc') => {
if (sortKey === null) {
// "Default" selected - use best-match sorting
setSortBySource(prev => {
@@ -1330,16 +1348,16 @@ export const ReleaseModal = ({
let newState: SortState;
if (currentState && currentState.key === sortKey) {
- // Same column - toggle direction
+ // Same key - toggle direction
newState = {
key: sortKey,
direction: currentState.direction === 'asc' ? 'desc' : 'asc',
};
} else {
- // New column - use default direction for this column type
+ // New key - use provided default direction
newState = {
key: sortKey,
- direction: inferDefaultDirection(column!.render_type),
+ direction: defaultDirection,
};
}
@@ -1385,7 +1403,7 @@ export const ReleaseModal = ({
});
// Then, sort by explicit column, or default to book-title relevance with exact author boost
- if (currentSort && sortableColumns.length > 0) {
+ if (currentSort && allSortOptions.length > 0) {
filtered = sortReleases(filtered, currentSort.key, currentSort.direction);
} else {
const responseBook = releasesBySource[activeTab]?.book;
@@ -1395,7 +1413,7 @@ export const ReleaseModal = ({
}
return filtered;
- }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, sortableColumns, columnConfig, book]);
+ }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, allSortOptions, columnConfig, book]);
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
const displayFields = useMemo(() => {
@@ -1771,8 +1789,8 @@ export const ReleaseModal = ({
- {/* Sort dropdown - only show if source has sortable columns */}
- {sortableColumns.length > 0 && (
+ {/* Sort dropdown - only show if source has sort options */}
+ {allSortOptions.length > 0 && (
{
- handleSortChange(null, null);
+ handleSortChange(null, 'asc');
close();
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort
@@ -1815,16 +1833,15 @@ export const ReleaseModal = ({
)}
- {sortableColumns.map((col) => {
- const sortKey = col.sort_key || col.key;
- const isSelected = currentSort?.key === sortKey;
+ {allSortOptions.map((opt) => {
+ const isSelected = currentSort?.key === opt.sortKey;
const direction = isSelected ? currentSort?.direction : null;
return (