Compare commits

...

36 Commits

Author SHA1 Message Date
maxid
651f0dd9a9 fix 2026-02-22 19:50:37 +01:00
Maximilian Dorninger
a643c9426d remove everything related to requests (#455)
This PR removes the requests feature. The functionality will be replaced
either by Seerr or by reimplementing it in a better way.
2026-02-22 19:46:47 +01:00
GokuPlay609
c2645000e5 fix: improve quality detection regex to match 2160p, UHD, FullHD and other keywords (#450)
## What

Two-line fix to the quality detection regex in
`media_manager/indexer/schemas.py`.

**UHD pattern**: `\b(4k)\b` → `\b(4k|2160p|uhd)\b`  
**FullHD pattern**: `\b(1080p)\b` → `\b(1080p|fullhd|full\s*hd)\b`

## Why

The UHD regex only matched the literal keyword `4k`. Torrent titles
containing `2160p` or `UHD` (but not `4k`) were classified as
`Quality.unknown` (value 5) instead of `Quality.uhd` (value 1). Since
sorting uses quality as the primary key, these 4K releases ended up at
the bottom of search results.

### Example

| Title | Before | After |
|---|---|---|
| `Movie.2013.4K.HDR.2160p.x265` |  `Quality.uhd` |  `Quality.uhd` |
| `Movie.2013.UHD.BluRay.2160p.HDR10.x265` |  `Quality.unknown` | 
`Quality.uhd` |
| `Movie.2013.2160p.WEBRip.DDP5.1.x264` |  `Quality.unknown` | 
`Quality.uhd` |

All patterns already use `re.IGNORECASE`, so case variants are handled.

Fixes #449

---------

Co-authored-by: GokuPlay609 <GokuPlay609@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: maxid <97409287+maxdorninger@users.noreply.github.com>
2026-02-22 16:25:36 +01:00
Maximilian Dorninger
b16f2dce92 migrate season files to episode files and drop legacy table (#454)
This pull request introduces a migration script to transition from
storing file information at the season level to the episode level in the
database.
2026-02-22 16:25:12 +01:00
natarelli22
d8a0ec66c3 Support for handling Single Episode Torrents (#331)
**Description**
As explained on #322, MediaManager currently only matches torrents that
represent full seasons or season packs.
As a result, valid episode-based releases — commonly returned by
indexers such as EZTV — are filtered out during scoring and never
considered for download.

Initial changes to the season parsing logic allow these torrents to be
discovered.
However, additional changes are required beyond season parsing to
properly support single-episode imports.

This PR is intended as a work-in-progress / RFC to discuss the required
changes and align on the correct approach before completing the
implementation.

**Things planned to do**
[X] Update Web UI to better display episode-level details
[ ] Update TV show import logic to handle single episode files, instead
of assuming full season files (to avoid integrity errors when episodes
are missing)
[ ] Create episode file tables to store episode-level data, similar to
season files
[ ] Implement fetching and downloading logic for single-episode torrents

**Notes / current limitations**
At the moment, the database and import logic assume one file per season
per quality, which works for season packs but not for episode-based
releases.

These changes are intentionally not completed yet and are part of the
discussion this PR aims to start.

**Request for feedback**
This represents a significant change in how TV content is handled in
MediaManager.
Before proceeding further, feedback from @maxdorninger on the overall
direction and next steps would be greatly appreciated.

Once aligned, the remaining tasks can be implemented incrementally.

---------

Co-authored-by: Maximilian Dorninger <97409287+maxdorninger@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-22 15:21:19 +01:00
Maximilian Dorninger
094d0e4eb7 update logo
forgot to update this file
2026-02-21 21:48:37 +01:00
Maximilian Dorninger
4d7f596ffd Rebrand to new MediaManager logo (#452)
I made two new logos because the old one wasn't very recognizable at a
glance.


![1](https://github.com/user-attachments/assets/cb37a709-e80b-4c97-a4d8-cf9ba0dc1613)

![3](https://github.com/user-attachments/assets/c56ded5c-fe15-4c02-bc20-fe2bff06caf9)
2026-02-21 20:29:16 +01:00
dependabot[bot]
300df14c8c Bump svelte from 5.51.0 to 5.53.0 in /web in the npm_and_yarn group across 1 directory (#445)
Bumps the npm_and_yarn group with 1 update in the /web directory:
[svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte).

Updates `svelte` from 5.51.0 to 5.53.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/sveltejs/svelte/releases">svelte's
releases</a>.</em></p>
<blockquote>
<h2>svelte@5.53.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p>feat: allow comments in tags (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17671">#17671</a>)</p>
</li>
<li>
<p>feat: allow error boundaries to work on the server (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17672">#17672</a>)</p>
</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>
<p>fix: use TrustedHTML to test for customizable <!-- raw HTML omitted
--> support, where necessary (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17743">#17743</a>)</p>
</li>
<li>
<p>fix: ensure head effects are kept in the effect tree (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17746">#17746</a>)</p>
</li>
<li>
<p>chore: deactivate current_batch by default in unset_context (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17738">#17738</a>)</p>
</li>
</ul>
<h2>svelte@5.52.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>feat: support TrustedHTML in <code>{@html}</code> expressions (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17701">#17701</a>)</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>
<p>fix: repair dynamic component truthy/falsy hydration mismatches (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17737">#17737</a>)</p>
</li>
<li>
<p>fix: re-run non-render-bound deriveds on the server (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17674">#17674</a>)</p>
</li>
</ul>
<h2>svelte@5.51.5</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p>fix: check to make sure <code>svelte:element</code> tags are valid
during SSR (<a
href="73098bb26c"><code>73098bb26c6f06e7fd1b0746d817d2c5ee90755f</code></a>)</p>
</li>
<li>
<p>fix: misc option escaping and backwards compatibility (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17741">#17741</a>)</p>
</li>
<li>
<p>fix: strip event handlers during SSR (<a
href="a0c7f28915"><code>a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5</code></a>)</p>
</li>
<li>
<p>fix: replace usage of <code>for in</code> with <code>for of
Object.keys</code> (<a
href="f89c7ddd7e"><code>f89c7ddd7eebaa1ef3cc540400bec2c9140b330c</code></a>)</p>
</li>
<li>
<p>fix: always escape option body in SSR (<a
href="f7c80da18c"><code>f7c80da18c215e3727c2a611b0b8744cc6e504c5</code></a>)</p>
</li>
<li>
<p>chore: upgrade <code>devalue</code> (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17739">#17739</a>)</p>
</li>
</ul>
<h2>svelte@5.51.4</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p>chore: proactively defer effects in pending boundary (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17734">#17734</a>)</p>
</li>
<li>
<p>fix: detect and error on non-idempotent each block keys in dev mode
(<a
href="https://redirect.github.com/sveltejs/svelte/pull/17732">#17732</a>)</p>
</li>
</ul>
<h2>svelte@5.51.3</h2>
<h3>Patch Changes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md">svelte's
changelog</a>.</em></p>
<blockquote>
<h2>5.53.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p>feat: allow comments in tags (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17671">#17671</a>)</p>
</li>
<li>
<p>feat: allow error boundaries to work on the server (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17672">#17672</a>)</p>
</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>
<p>fix: use TrustedHTML to test for customizable
<code>&lt;select&gt;</code> support, where necessary (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17743">#17743</a>)</p>
</li>
<li>
<p>fix: ensure head effects are kept in the effect tree (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17746">#17746</a>)</p>
</li>
<li>
<p>chore: deactivate current_batch by default in unset_context (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17738">#17738</a>)</p>
</li>
</ul>
<h2>5.52.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>feat: support TrustedHTML in <code>{@html}</code> expressions (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17701">#17701</a>)</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>
<p>fix: repair dynamic component truthy/falsy hydration mismatches (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17737">#17737</a>)</p>
</li>
<li>
<p>fix: re-run non-render-bound deriveds on the server (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17674">#17674</a>)</p>
</li>
</ul>
<h2>5.51.5</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p>fix: check to make sure <code>svelte:element</code> tags are valid
during SSR (<a
href="73098bb26c"><code>73098bb26c6f06e7fd1b0746d817d2c5ee90755f</code></a>)</p>
</li>
<li>
<p>fix: misc option escaping and backwards compatibility (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17741">#17741</a>)</p>
</li>
<li>
<p>fix: strip event handlers during SSR (<a
href="a0c7f28915"><code>a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5</code></a>)</p>
</li>
<li>
<p>fix: replace usage of <code>for in</code> with <code>for of
Object.keys</code> (<a
href="f89c7ddd7e"><code>f89c7ddd7eebaa1ef3cc540400bec2c9140b330c</code></a>)</p>
</li>
<li>
<p>fix: always escape option body in SSR (<a
href="f7c80da18c"><code>f7c80da18c215e3727c2a611b0b8744cc6e504c5</code></a>)</p>
</li>
<li>
<p>chore: upgrade <code>devalue</code> (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17739">#17739</a>)</p>
</li>
</ul>
<h2>5.51.4</h2>
<h3>Patch Changes</h3>
<ul>
<li>chore: proactively defer effects in pending boundary (<a
href="https://redirect.github.com/sveltejs/svelte/pull/17734">#17734</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="c2fc95a467"><code>c2fc95a</code></a>
Version Packages (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17747">#17747</a>)</li>
<li><a
href="92e2fc1209"><code>92e2fc1</code></a>
feat: allow comments in tags (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17671">#17671</a>)</li>
<li><a
href="2661513cd3"><code>2661513</code></a>
feat: allow error boundaries to work on the server (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17672">#17672</a>)</li>
<li><a
href="582e4443dc"><code>582e444</code></a>
fix: ensure head effects are kept in the effect tree (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17746">#17746</a>)</li>
<li><a
href="f8bf9bb461"><code>f8bf9bb</code></a>
chore: deactivate current_batch by default in unset_context (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17738">#17738</a>)</li>
<li><a
href="696d97ff3e"><code>696d97f</code></a>
fix: use TrustedHTML to test for customizable &lt;select&gt; support,
where necessa...</li>
<li><a
href="cbf4e246fc"><code>cbf4e24</code></a>
Version Packages (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17742">#17742</a>)</li>
<li><a
href="09c4cb5084"><code>09c4cb5</code></a>
fix: re-run non-render-bound deriveds on the server (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17674">#17674</a>)</li>
<li><a
href="be24b0dca7"><code>be24b0d</code></a>
feat: support TrustedHTML in {<a
href="https://github.com/html"><code>@​html</code></a>} expressions (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17701">#17701</a>)</li>
<li><a
href="9f48e7620f"><code>9f48e76</code></a>
fix: repair dynamic component truthy/falsy hydration mismatches (<a
href="https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte/issues/17737">#17737</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/sveltejs/svelte/commits/svelte@5.53.0/packages/svelte">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=svelte&package-manager=npm_and_yarn&previous-version=5.51.0&new-version=5.53.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/maxdorninger/MediaManager/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 18:08:06 +01:00
dependabot[bot]
2f102d6c5d Bump the uv group across 1 directory with 2 updates (#446)
Bumps the uv group with 2 updates in the /metadata_relay directory:
[python-multipart](https://github.com/Kludex/python-multipart) and
[urllib3](https://github.com/urllib3/urllib3).

Updates `python-multipart` from 0.0.21 to 0.0.22
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/Kludex/python-multipart/releases">python-multipart's
releases</a>.</em></p>
<blockquote>
<h2>Version 0.0.22</h2>
<h2>What's Changed</h2>
<ul>
<li>Drop directory path from filename in <code>File</code> <a
href="9433f4bbc9">9433f4b</a>.</li>
</ul>
<hr />
<p><strong>Full Changelog</strong>: <a
href="https://github.com/Kludex/python-multipart/compare/0.0.21...0.0.22">https://github.com/Kludex/python-multipart/compare/0.0.21...0.0.22</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md">python-multipart's
changelog</a>.</em></p>
<blockquote>
<h2>0.0.22 (2026-01-25)</h2>
<ul>
<li>Drop directory path from filename in <code>File</code> <a
href="9433f4bbc9">9433f4b</a>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="bea7bbb290"><code>bea7bbb</code></a>
Version 0.0.22 (<a
href="https://redirect.github.com/Kludex/python-multipart/issues/222">#222</a>)</li>
<li><a
href="0fb59a9df0"><code>0fb59a9</code></a>
chore: add return type on test (<a
href="https://redirect.github.com/Kludex/python-multipart/issues/221">#221</a>)</li>
<li><a
href="9433f4bbc9"><code>9433f4b</code></a>
Merge commit from fork</li>
<li><a
href="d5c91ecb0a"><code>d5c91ec</code></a>
Bump the github-actions group with 2 updates (<a
href="https://redirect.github.com/Kludex/python-multipart/issues/219">#219</a>)</li>
<li><a
href="5a90631b48"><code>5a90631</code></a>
bump uv (<a
href="https://redirect.github.com/Kludex/python-multipart/issues/218">#218</a>)</li>
<li>See full diff in <a
href="https://github.com/Kludex/python-multipart/compare/0.0.21...0.0.22">compare
view</a></li>
</ul>
</details>
<br />

Updates `urllib3` from 2.6.2 to 2.6.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/releases">urllib3's
releases</a>.</em></p>
<blockquote>
<h2>2.6.3</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project after a sharp decline
in financial support. If your company or organization uses Python and
would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and
thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Changes</h2>
<ul>
<li>Fixed a security issue where decompression-bomb safeguards of the
streaming API were bypassed when HTTP redirects were followed.
(CVE-2026-21441 reported by <a
href="https://github.com/D47A"><code>@​D47A</code></a>, 8.9 High,
GHSA-38jv-5279-wg99)</li>
<li>Started treating <code>Retry-After</code> times greater than 6 hours
as 6 hours by default. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3743">urllib3/urllib3#3743</a>)</li>
<li>Fixed <code>urllib3.connection.VerifiedHTTPSConnection</code> on
Emscripten. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3752">urllib3/urllib3#3752</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/blob/main/CHANGES.rst">urllib3's
changelog</a>.</em></p>
<blockquote>
<h1>2.6.3 (2026-01-07)</h1>
<ul>
<li>Fixed a high-severity security issue where decompression-bomb
safeguards of
the streaming API were bypassed when HTTP redirects were followed.
(<code>GHSA-38jv-5279-wg99
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-38jv-5279-wg99&gt;</code>__)</li>
<li>Started treating <code>Retry-After</code> times greater than 6 hours
as 6 hours by
default. (<code>[#3743](https://github.com/urllib3/urllib3/issues/3743)
&lt;https://github.com/urllib3/urllib3/issues/3743&gt;</code>__)</li>
<li>Fixed <code>urllib3.connection.VerifiedHTTPSConnection</code> on
Emscripten.
(<code>[#3752](https://github.com/urllib3/urllib3/issues/3752)
&lt;https://github.com/urllib3/urllib3/issues/3752&gt;</code>__)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="0248277dd7"><code>0248277</code></a>
Release 2.6.3</li>
<li><a
href="8864ac407b"><code>8864ac4</code></a>
Merge commit from fork</li>
<li><a
href="70cecb27ca"><code>70cecb2</code></a>
Fix Scorecard issues related to vulnerable dev dependencies (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3755">#3755</a>)</li>
<li><a
href="41f249abe1"><code>41f249a</code></a>
Move &quot;v2.0 Migration Guide&quot; to the end of the table of
contents (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3747">#3747</a>)</li>
<li><a
href="fd4dffd2fc"><code>fd4dffd</code></a>
Patch <code>VerifiedHTTPSConnection</code> for Emscripten (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3752">#3752</a>)</li>
<li><a
href="13f0bfd55e"><code>13f0bfd</code></a>
Handle massive values in Retry-After when calculating time to sleep for
(<a
href="https://redirect.github.com/urllib3/urllib3/issues/3743">#3743</a>)</li>
<li><a
href="8c480bf87b"><code>8c480bf</code></a>
Bump actions/upload-artifact from 5.0.0 to 6.0.0 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3748">#3748</a>)</li>
<li><a
href="4b40616e95"><code>4b40616</code></a>
Bump actions/cache from 4.3.0 to 5.0.1 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3750">#3750</a>)</li>
<li><a
href="82b8479663"><code>82b8479</code></a>
Bump actions/download-artifact from 6.0.0 to 7.0.0 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3749">#3749</a>)</li>
<li><a
href="34284cb017"><code>34284cb</code></a>
Mention experimental features in the security policy (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3746">#3746</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/urllib3/urllib3/compare/2.6.2...2.6.3">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/maxdorninger/MediaManager/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 18:07:53 +01:00
dependabot[bot]
3e696c463c Bump the uv group across 1 directory with 3 updates (#448)
Bumps the uv group with 3 updates in the / directory:
[cryptography](https://github.com/pyca/cryptography),
[python-multipart](https://github.com/Kludex/python-multipart) and
[urllib3](https://github.com/urllib3/urllib3).

Updates `cryptography` from 46.0.3 to 46.0.5
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst">cryptography's
changelog</a>.</em></p>
<blockquote>
<p>46.0.5 - 2026-02-10</p>
<pre><code>
* An attacker could create a malicious public key that reveals portions
of your
private key when using certain uncommon elliptic curves (binary curves).
This version now includes additional security checks to prevent this
attack.
This issue only affects binary elliptic curves, which are rarely used in
real-world applications. Credit to **XlabAI Team of Tencent Xuanwu Lab
and
Atuin Automated Vulnerability Discovery Engine** for reporting the
issue.
  **CVE-2026-26007**
* Support for ``SECT*`` binary elliptic curves is deprecated and will be
  removed in the next release.
<p>.. v46-0-4:</p>
<p>46.0.4 - 2026-01-27<br />
</code></pre></p>
<ul>
<li><code>Dropped support for win_arm64 wheels</code>_.</li>
<li>Updated Windows, macOS, and Linux wheels to be compiled with OpenSSL
3.5.5.</li>
</ul>
<p>.. _v46-0-3:</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="06e120e682"><code>06e120e</code></a>
bump version for 46.0.5 release (<a
href="https://redirect.github.com/pyca/cryptography/issues/14289">#14289</a>)</li>
<li><a
href="0eebb9dbb6"><code>0eebb9d</code></a>
EC check key on cofactor &gt; 1 (<a
href="https://redirect.github.com/pyca/cryptography/issues/14287">#14287</a>)</li>
<li><a
href="bedf6e186b"><code>bedf6e1</code></a>
fix openssl version on 46 branch (<a
href="https://redirect.github.com/pyca/cryptography/issues/14220">#14220</a>)</li>
<li><a
href="e6f44fc8e6"><code>e6f44fc</code></a>
bump for 46.0.4 and drop win arm64 due to CI issues (<a
href="https://redirect.github.com/pyca/cryptography/issues/14217">#14217</a>)</li>
<li>See full diff in <a
href="https://github.com/pyca/cryptography/compare/46.0.3...46.0.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `python-multipart` from 0.0.21 to 0.0.22
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/Kludex/python-multipart/releases">python-multipart's
releases</a>.</em></p>
<blockquote>
<h2>Version 0.0.22</h2>
<h2>What's Changed</h2>
<ul>
<li>Drop directory path from filename in <code>File</code> <a
href="9433f4bbc9">9433f4b</a>.</li>
</ul>
<hr />
<p><strong>Full Changelog</strong>: <a
href="https://github.com/Kludex/python-multipart/compare/0.0.21...0.0.22">https://github.com/Kludex/python-multipart/compare/0.0.21...0.0.22</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md">python-multipart's
changelog</a>.</em></p>
<blockquote>
<h2>0.0.22 (2026-01-25)</h2>
<ul>
<li>Drop directory path from filename in <code>File</code> <a
href="9433f4bbc9">9433f4b</a>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="bea7bbb290"><code>bea7bbb</code></a>
Version 0.0.22 (<a
href="https://redirect.github.com/Kludex/python-multipart/issues/222">#222</a>)</li>
<li><a
href="0fb59a9df0"><code>0fb59a9</code></a>
chore: add return type on test (<a
href="https://redirect.github.com/Kludex/python-multipart/issues/221">#221</a>)</li>
<li><a
href="9433f4bbc9"><code>9433f4b</code></a>
Merge commit from fork</li>
<li><a
href="d5c91ecb0a"><code>d5c91ec</code></a>
Bump the github-actions group with 2 updates (<a
href="https://redirect.github.com/Kludex/python-multipart/issues/219">#219</a>)</li>
<li><a
href="5a90631b48"><code>5a90631</code></a>
bump uv (<a
href="https://redirect.github.com/Kludex/python-multipart/issues/218">#218</a>)</li>
<li>See full diff in <a
href="https://github.com/Kludex/python-multipart/compare/0.0.21...0.0.22">compare
view</a></li>
</ul>
</details>
<br />

Updates `urllib3` from 2.6.2 to 2.6.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/releases">urllib3's
releases</a>.</em></p>
<blockquote>
<h2>2.6.3</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project after a sharp decline
in financial support. If your company or organization uses Python and
would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and
thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Changes</h2>
<ul>
<li>Fixed a security issue where decompression-bomb safeguards of the
streaming API were bypassed when HTTP redirects were followed.
(CVE-2026-21441 reported by <a
href="https://github.com/D47A"><code>@​D47A</code></a>, 8.9 High,
GHSA-38jv-5279-wg99)</li>
<li>Started treating <code>Retry-After</code> times greater than 6 hours
as 6 hours by default. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3743">urllib3/urllib3#3743</a>)</li>
<li>Fixed <code>urllib3.connection.VerifiedHTTPSConnection</code> on
Emscripten. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3752">urllib3/urllib3#3752</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/blob/main/CHANGES.rst">urllib3's
changelog</a>.</em></p>
<blockquote>
<h1>2.6.3 (2026-01-07)</h1>
<ul>
<li>Fixed a high-severity security issue where decompression-bomb
safeguards of
the streaming API were bypassed when HTTP redirects were followed.
(<code>GHSA-38jv-5279-wg99
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-38jv-5279-wg99&gt;</code>__)</li>
<li>Started treating <code>Retry-After</code> times greater than 6 hours
as 6 hours by
default. (<code>[#3743](https://github.com/urllib3/urllib3/issues/3743)
&lt;https://github.com/urllib3/urllib3/issues/3743&gt;</code>__)</li>
<li>Fixed <code>urllib3.connection.VerifiedHTTPSConnection</code> on
Emscripten.
(<code>[#3752](https://github.com/urllib3/urllib3/issues/3752)
&lt;https://github.com/urllib3/urllib3/issues/3752&gt;</code>__)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="0248277dd7"><code>0248277</code></a>
Release 2.6.3</li>
<li><a
href="8864ac407b"><code>8864ac4</code></a>
Merge commit from fork</li>
<li><a
href="70cecb27ca"><code>70cecb2</code></a>
Fix Scorecard issues related to vulnerable dev dependencies (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3755">#3755</a>)</li>
<li><a
href="41f249abe1"><code>41f249a</code></a>
Move &quot;v2.0 Migration Guide&quot; to the end of the table of
contents (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3747">#3747</a>)</li>
<li><a
href="fd4dffd2fc"><code>fd4dffd</code></a>
Patch <code>VerifiedHTTPSConnection</code> for Emscripten (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3752">#3752</a>)</li>
<li><a
href="13f0bfd55e"><code>13f0bfd</code></a>
Handle massive values in Retry-After when calculating time to sleep for
(<a
href="https://redirect.github.com/urllib3/urllib3/issues/3743">#3743</a>)</li>
<li><a
href="8c480bf87b"><code>8c480bf</code></a>
Bump actions/upload-artifact from 5.0.0 to 6.0.0 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3748">#3748</a>)</li>
<li><a
href="4b40616e95"><code>4b40616</code></a>
Bump actions/cache from 4.3.0 to 5.0.1 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3750">#3750</a>)</li>
<li><a
href="82b8479663"><code>82b8479</code></a>
Bump actions/download-artifact from 6.0.0 to 7.0.0 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3749">#3749</a>)</li>
<li><a
href="34284cb017"><code>34284cb</code></a>
Mention experimental features in the security policy (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3746">#3746</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/urllib3/urllib3/compare/2.6.2...2.6.3">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/maxdorninger/MediaManager/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 18:07:39 +01:00
dependabot[bot]
5adb88f9e0 Bump pillow from 12.1.0 to 12.1.1 (#443)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.0 to
12.1.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/python-pillow/Pillow/releases">pillow's
releases</a>.</em></p>
<blockquote>
<h2>12.1.1</h2>
<p><a
href="https://pillow.readthedocs.io/en/stable/releasenotes/12.1.1.html">https://pillow.readthedocs.io/en/stable/releasenotes/12.1.1.html</a></p>
<h2>Dependencies</h2>
<ul>
<li>Patch libavif for svt-av1 4.0 compatibility <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9413">#9413</a>
[<a href="https://github.com/hugovk"><code>@​hugovk</code></a>]</li>
</ul>
<h2>Other changes</h2>
<ul>
<li>Fix OOB Write with invalid tile extents <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9427">#9427</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="5158d98c80"><code>5158d98</code></a>
12.1.1 version bump</li>
<li><a
href="9000313cc5"><code>9000313</code></a>
Fix OOB Write with invalid tile extents (<a
href="https://redirect.github.com/python-pillow/Pillow/issues/9427">#9427</a>)</li>
<li><a
href="cd0111849f"><code>cd01118</code></a>
Patch libavif for svt-av1 4.0 compatibility</li>
<li>See full diff in <a
href="https://github.com/python-pillow/Pillow/compare/12.1.0...12.1.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pillow&package-manager=uv&previous-version=12.1.0&new-version=12.1.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:35:11 +01:00
dependabot[bot]
eb277dddac Bump sveltekit-superforms from 2.28.1 to 2.29.1 in /web (#442)
Bumps
[sveltekit-superforms](https://github.com/ciscoheat/sveltekit-superforms)
from 2.28.1 to 2.29.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ciscoheat/sveltekit-superforms/releases">sveltekit-superforms's
releases</a>.</em></p>
<blockquote>
<h2>v2.29.1</h2>
<h3>Fixed</h3>
<ul>
<li>Fixed TypeScript type inference for discriminated unions in
<code>ValidationErrors</code>. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/653">#653</a></li>
<li>Fixed FormData parsing for discriminated unions, so they work
properly without requiring <code>dataType: 'json'</code>. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/655">#655</a></li>
<li><code>reset()</code> function didn't preserve tainted state for
fields that are not being reset when using partial data. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/656">#656</a></li>
<li>Fixed FormData parsing incorrectly coercing empty strings to literal
values (e.g., <code>z.literal(&quot;bar&quot;)</code>). Empty strings
now properly fail validation instead of being replaced with the literal
value. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/664">#664</a></li>
<li>Fixed <code>ReferenceError</code> when using
<code>customValidity</code> with <code>validateForm({ update: true
})</code>. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/669">#669</a></li>
</ul>
<h3>Changed</h3>
<ul>
<li>Replaced deprecated <code>@finom/zod-to-json-schema</code> with
<code>zod-v3-to-json-schema</code>. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/pull/660">#660</a></li>
<li>Migrated Valibot adapter to use the official
<code>@valibot/to-json-schema</code> package. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/pull/668">#668</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ciscoheat/sveltekit-superforms/blob/main/CHANGELOG.md">sveltekit-superforms's
changelog</a>.</em></p>
<blockquote>
<h2>[2.29.1] - 2025-12-16</h2>
<h3>Fixed</h3>
<ul>
<li>Fixed TypeScript type inference for discriminated unions in
<code>ValidationErrors</code>. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/653">#653</a></li>
<li>Fixed FormData parsing for discriminated unions, so they work
properly without requiring <code>dataType: 'json'</code>. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/655">#655</a></li>
<li><code>reset()</code> function didn't preserve tainted state for
fields that are not being reset when using partial data. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/656">#656</a></li>
<li>Fixed FormData parsing incorrectly coercing empty strings to literal
values (e.g., <code>z.literal(&quot;bar&quot;)</code>). Empty strings
now properly fail validation instead of being replaced with the literal
value. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/664">#664</a></li>
<li>Fixed <code>ReferenceError</code> when using
<code>customValidity</code> with <code>validateForm({ update: true
})</code>. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/issues/669">#669</a></li>
</ul>
<h3>Changed</h3>
<ul>
<li>Replaced deprecated <code>@finom/zod-to-json-schema</code> with
<code>zod-v3-to-json-schema</code>. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/pull/660">#660</a></li>
<li>Migrated Valibot adapter to use the official
<code>@valibot/to-json-schema</code> package. <a
href="https://redirect.github.com/ciscoheat/sveltekit-superforms/pull/668">#668</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="22319df44e"><code>22319df</code></a>
2.29.1 changelog</li>
<li><a
href="c1b44308a8"><code>c1b4430</code></a>
2.29.1</li>
<li><a
href="9408124dc9"><code>9408124</code></a>
Incorrect dependency fix</li>
<li><a
href="887aeb6238"><code>887aeb6</code></a>
2.29.0</li>
<li><a
href="ce701fb6fc"><code>ce701fb</code></a>
2.29.0 changelog</li>
<li><a
href="58b41b1e84"><code>58b41b1</code></a>
Linter</li>
<li><a
href="fbbdb90ae7"><code>fbbdb90</code></a>
Fixed build warning</li>
<li><a
href="44a40c6b13"><code>44a40c6</code></a>
Fixed SvelteKit reference warnings</li>
<li><a
href="12bb4d5c32"><code>12bb4d5</code></a>
Using pnpm 10 for build</li>
<li><a
href="f7c87d8898"><code>f7c87d8</code></a>
Package updates</li>
<li>Additional commits viewable in <a
href="https://github.com/ciscoheat/sveltekit-superforms/compare/v2.28.1...v2.29.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=sveltekit-superforms&package-manager=npm_and_yarn&previous-version=2.28.1&new-version=2.29.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:35:00 +01:00
dependabot[bot]
516d562bd8 Bump uvicorn from 0.40.0 to 0.41.0 (#441)
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.40.0 to
0.41.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/Kludex/uvicorn/releases">uvicorn's
releases</a>.</em></p>
<blockquote>
<h2>Version 0.41.0</h2>
<h2>Added</h2>
<ul>
<li>Add <code>--limit-max-requests-jitter</code> to stagger worker
restarts (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2707">#2707</a>)</li>
<li>Add socket path to <code>scope[&quot;server&quot;]</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2561">#2561</a>)</li>
</ul>
<h2>Changed</h2>
<ul>
<li>Rename <code>LifespanOn.error_occured</code> to
<code>error_occurred</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2776">#2776</a>)</li>
</ul>
<h2>Fixed</h2>
<ul>
<li>Ignore permission denied errors in watchfiles reloader (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2817">#2817</a>)</li>
<li>Ensure lifespan shutdown runs when <code>should_exit</code> is set
during startup (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2812">#2812</a>)</li>
<li>Reduce the log level of 'request limit exceeded' messages (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2788">#2788</a>)</li>
</ul>
<hr />
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/t-kawasumi"><code>@​t-kawasumi</code></a> made
their first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2776">Kludex/uvicorn#2776</a></li>
<li><a href="https://github.com/fardyn"><code>@​fardyn</code></a> made
their first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2800">Kludex/uvicorn#2800</a></li>
<li><a href="https://github.com/ewie"><code>@​ewie</code></a> made their
first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2807">Kludex/uvicorn#2807</a></li>
<li><a href="https://github.com/shevron"><code>@​shevron</code></a> made
their first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2788">Kludex/uvicorn#2788</a></li>
<li><a href="https://github.com/jonashaag"><code>@​jonashaag</code></a>
made their first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2707">Kludex/uvicorn#2707</a></li>
</ul>
<hr />
<p><strong>Full Changelog</strong>: <a
href="https://github.com/Kludex/uvicorn/compare/0.40.0...0.41.0">https://github.com/Kludex/uvicorn/compare/0.40.0...0.41.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md">uvicorn's
changelog</a>.</em></p>
<blockquote>
<h2>0.41.0 (February 16, 2026)</h2>
<h3>Added</h3>
<ul>
<li>Add <code>--limit-max-requests-jitter</code> to stagger worker
restarts (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2707">#2707</a>)</li>
<li>Add socket path to <code>scope[&quot;server&quot;]</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2561">#2561</a>)</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Rename <code>LifespanOn.error_occured</code> to
<code>error_occurred</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2776">#2776</a>)</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Ignore permission denied errors in watchfiles reloader (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2817">#2817</a>)</li>
<li>Ensure lifespan shutdown runs when <code>should_exit</code> is set
during startup (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2812">#2812</a>)</li>
<li>Reduce the log level of 'request limit exceeded' messages (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2788">#2788</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9283c0f15c"><code>9283c0f</code></a>
Version 0.41.0 (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2821">#2821</a>)</li>
<li><a
href="a01a33eb8f"><code>a01a33e</code></a>
Add <code>--limit-max-requests-jitter</code> to stagger worker restarts
(<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2707">#2707</a>)</li>
<li><a
href="2ce65bde15"><code>2ce65bd</code></a>
Ignore permission denied errors in watchfiles reloader (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2817">#2817</a>)</li>
<li><a
href="654f2ed7d7"><code>654f2ed</code></a>
Ensure lifespan shutdown runs when <code>should_exit</code> is set
during startup (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2812">#2812</a>)</li>
<li><a
href="a03d9f6f0e"><code>a03d9f6</code></a>
Reduce the log level of 'request limit exceeded' messages (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2788">#2788</a>)</li>
<li><a
href="e377de40d0"><code>e377de4</code></a>
Add socket path to scope[&quot;server&quot;] (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2561">#2561</a>)</li>
<li><a
href="0779f7f8a4"><code>0779f7f</code></a>
Poll for readiness in <code>test_multiprocess_health_check</code> and
<code>run_server</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2816">#2816</a>)</li>
<li><a
href="7e9ce2c974"><code>7e9ce2c</code></a>
Poll for PID changes in <code>test_multiprocess_sighup</code> instead of
fixed sleep (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2">#2</a>...</li>
<li><a
href="99f0d8734d"><code>99f0d87</code></a>
Fix grep warning in scripts/sync-version (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2807">#2807</a>)</li>
<li><a
href="7ae2e6375a"><code>7ae2e63</code></a>
chore(deps): bump the python-packages group with 18 updates (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2801">#2801</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/Kludex/uvicorn/compare/0.40.0...0.41.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=uvicorn&package-manager=uv&previous-version=0.40.0&new-version=0.41.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:34:48 +01:00
dependabot[bot]
dea75841b2 Bump lucide-svelte from 0.564.0 to 0.574.0 in /web (#439)
Bumps
[lucide-svelte](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-svelte)
from 0.564.0 to 0.574.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/lucide-icons/lucide/releases">lucide-svelte's
releases</a>.</em></p>
<blockquote>
<h2>Version 0.574.0</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(icons): changed <code>rocking-chair</code> icon by <a
href="https://github.com/jamiemlaw"><code>@​jamiemlaw</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3445">lucide-icons/lucide#3445</a></li>
<li>fix(icons): flipped <code>coins</code> icon by <a
href="https://github.com/jguddas"><code>@​jguddas</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3158">lucide-icons/lucide#3158</a></li>
<li>feat(icons): added <code>x-line-top</code> icon by <a
href="https://github.com/jguddas"><code>@​jguddas</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/2838">lucide-icons/lucide#2838</a></li>
<li>feat(icons): added <code>mouse-left</code> icon by <a
href="https://github.com/marvfash"><code>@​marvfash</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/2788">lucide-icons/lucide#2788</a></li>
<li>feat(icons): added <code>mouse-right</code> icon by <a
href="https://github.com/marvfash"><code>@​marvfash</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/2787">lucide-icons/lucide#2787</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/marvfash"><code>@​marvfash</code></a>
made their first contribution in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/2788">lucide-icons/lucide#2788</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lucide-icons/lucide/compare/0.572.0...0.574.0">https://github.com/lucide-icons/lucide/compare/0.572.0...0.574.0</a></p>
<h2>Version 0.573.0</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(icons): changed <code>rocking-chair</code> icon by <a
href="https://github.com/jamiemlaw"><code>@​jamiemlaw</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3445">lucide-icons/lucide#3445</a></li>
<li>fix(icons): flipped <code>coins</code> icon by <a
href="https://github.com/jguddas"><code>@​jguddas</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3158">lucide-icons/lucide#3158</a></li>
<li>feat(icons): added <code>x-line-top</code> icon by <a
href="https://github.com/jguddas"><code>@​jguddas</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/2838">lucide-icons/lucide#2838</a></li>
<li>feat(icons): added <code>mouse-left</code> icon by <a
href="https://github.com/marvfash"><code>@​marvfash</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/2788">lucide-icons/lucide#2788</a></li>
<li>feat(icons): added <code>mouse-right</code> icon by <a
href="https://github.com/marvfash"><code>@​marvfash</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/2787">lucide-icons/lucide#2787</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/marvfash"><code>@​marvfash</code></a>
made their first contribution in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/2788">lucide-icons/lucide#2788</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lucide-icons/lucide/compare/0.572.0...0.573.0">https://github.com/lucide-icons/lucide/compare/0.572.0...0.573.0</a></p>
<h2>Version 0.572.0</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(icons): added <code>message-circle-check</code> icon by <a
href="https://github.com/Shrinks99"><code>@​Shrinks99</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3770">lucide-icons/lucide#3770</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Shrinks99"><code>@​Shrinks99</code></a>
made their first contribution in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3770">lucide-icons/lucide#3770</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lucide-icons/lucide/compare/0.571.0...0.572.0">https://github.com/lucide-icons/lucide/compare/0.571.0...0.572.0</a></p>
<h2>Version 0.571.0</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(icons): rearange <code>circle</code>-icons path and circle order
by <a
href="https://github.com/adamlindqvist"><code>@​adamlindqvist</code></a>
in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3746">lucide-icons/lucide#3746</a></li>
<li>feat(icons): added <code>shelving-unit</code> icon by <a
href="https://github.com/karsa-mistmere"><code>@​karsa-mistmere</code></a>
in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3041">lucide-icons/lucide#3041</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/adamlindqvist"><code>@​adamlindqvist</code></a>
made their first contribution in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3746">lucide-icons/lucide#3746</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lucide-icons/lucide/compare/0.570.0...0.571.0">https://github.com/lucide-icons/lucide/compare/0.570.0...0.571.0</a></p>
<h2>Version 0.570.0</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(icons): added <code>towel-rack</code> icon by <a
href="https://github.com/jguddas"><code>@​jguddas</code></a> in <a
href="https://redirect.github.com/lucide-icons/lucide/pull/3350">lucide-icons/lucide#3350</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/lucide-icons/lucide/commits/0.574.0/packages/lucide-svelte">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=lucide-svelte&package-manager=npm_and_yarn&previous-version=0.564.0&new-version=0.574.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:34:38 +01:00
dependabot[bot]
20e0dbf936 Bump uvicorn from 0.40.0 to 0.41.0 in /metadata_relay (#440)
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.40.0 to
0.41.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/Kludex/uvicorn/releases">uvicorn's
releases</a>.</em></p>
<blockquote>
<h2>Version 0.41.0</h2>
<h2>Added</h2>
<ul>
<li>Add <code>--limit-max-requests-jitter</code> to stagger worker
restarts (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2707">#2707</a>)</li>
<li>Add socket path to <code>scope[&quot;server&quot;]</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2561">#2561</a>)</li>
</ul>
<h2>Changed</h2>
<ul>
<li>Rename <code>LifespanOn.error_occured</code> to
<code>error_occurred</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2776">#2776</a>)</li>
</ul>
<h2>Fixed</h2>
<ul>
<li>Ignore permission denied errors in watchfiles reloader (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2817">#2817</a>)</li>
<li>Ensure lifespan shutdown runs when <code>should_exit</code> is set
during startup (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2812">#2812</a>)</li>
<li>Reduce the log level of 'request limit exceeded' messages (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2788">#2788</a>)</li>
</ul>
<hr />
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/t-kawasumi"><code>@​t-kawasumi</code></a> made
their first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2776">Kludex/uvicorn#2776</a></li>
<li><a href="https://github.com/fardyn"><code>@​fardyn</code></a> made
their first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2800">Kludex/uvicorn#2800</a></li>
<li><a href="https://github.com/ewie"><code>@​ewie</code></a> made their
first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2807">Kludex/uvicorn#2807</a></li>
<li><a href="https://github.com/shevron"><code>@​shevron</code></a> made
their first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2788">Kludex/uvicorn#2788</a></li>
<li><a href="https://github.com/jonashaag"><code>@​jonashaag</code></a>
made their first contribution in <a
href="https://redirect.github.com/Kludex/uvicorn/pull/2707">Kludex/uvicorn#2707</a></li>
</ul>
<hr />
<p><strong>Full Changelog</strong>: <a
href="https://github.com/Kludex/uvicorn/compare/0.40.0...0.41.0">https://github.com/Kludex/uvicorn/compare/0.40.0...0.41.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md">uvicorn's
changelog</a>.</em></p>
<blockquote>
<h2>0.41.0 (February 16, 2026)</h2>
<h3>Added</h3>
<ul>
<li>Add <code>--limit-max-requests-jitter</code> to stagger worker
restarts (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2707">#2707</a>)</li>
<li>Add socket path to <code>scope[&quot;server&quot;]</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2561">#2561</a>)</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Rename <code>LifespanOn.error_occured</code> to
<code>error_occurred</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2776">#2776</a>)</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Ignore permission denied errors in watchfiles reloader (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2817">#2817</a>)</li>
<li>Ensure lifespan shutdown runs when <code>should_exit</code> is set
during startup (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2812">#2812</a>)</li>
<li>Reduce the log level of 'request limit exceeded' messages (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2788">#2788</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9283c0f15c"><code>9283c0f</code></a>
Version 0.41.0 (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2821">#2821</a>)</li>
<li><a
href="a01a33eb8f"><code>a01a33e</code></a>
Add <code>--limit-max-requests-jitter</code> to stagger worker restarts
(<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2707">#2707</a>)</li>
<li><a
href="2ce65bde15"><code>2ce65bd</code></a>
Ignore permission denied errors in watchfiles reloader (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2817">#2817</a>)</li>
<li><a
href="654f2ed7d7"><code>654f2ed</code></a>
Ensure lifespan shutdown runs when <code>should_exit</code> is set
during startup (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2812">#2812</a>)</li>
<li><a
href="a03d9f6f0e"><code>a03d9f6</code></a>
Reduce the log level of 'request limit exceeded' messages (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2788">#2788</a>)</li>
<li><a
href="e377de40d0"><code>e377de4</code></a>
Add socket path to scope[&quot;server&quot;] (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2561">#2561</a>)</li>
<li><a
href="0779f7f8a4"><code>0779f7f</code></a>
Poll for readiness in <code>test_multiprocess_health_check</code> and
<code>run_server</code> (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2816">#2816</a>)</li>
<li><a
href="7e9ce2c974"><code>7e9ce2c</code></a>
Poll for PID changes in <code>test_multiprocess_sighup</code> instead of
fixed sleep (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2">#2</a>...</li>
<li><a
href="99f0d8734d"><code>99f0d87</code></a>
Fix grep warning in scripts/sync-version (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2807">#2807</a>)</li>
<li><a
href="7ae2e6375a"><code>7ae2e63</code></a>
chore(deps): bump the python-packages group with 18 updates (<a
href="https://redirect.github.com/Kludex/uvicorn/issues/2801">#2801</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/Kludex/uvicorn/compare/0.40.0...0.41.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=uvicorn&package-manager=uv&previous-version=0.40.0&new-version=0.41.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:34:27 +01:00
dependabot[bot]
c8f2a4316e Bump alembic from 1.17.2 to 1.18.4 (#438)
Bumps [alembic](https://github.com/sqlalchemy/alembic) from 1.17.2 to
1.18.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/sqlalchemy/alembic/releases">alembic's
releases</a>.</em></p>
<blockquote>
<h1>1.18.4</h1>
<p>Released: February 10, 2026</p>
<h2>bug</h2>
<ul>
<li>
<p><strong>[bug] [operations]</strong> Reverted the behavior of
<code>Operations.add_column()</code> that would
automatically render the &quot;PRIMARY KEY&quot; keyword inline when a
<code>Column</code> with <code>primary_key=True</code> is added. The
automatic
behavior, added in version 1.18.2, is now opt-in via the new
<code>Operations.add_column.inline_primary_key</code> parameter. This
change restores the ability to render a PostgreSQL SERIAL column, which
is
required to be <code>primary_key=True</code>, while not impacting the
ability to
render a separate primary key constraint. This also provides consistency
with the <code>Operations.add_column.inline_references</code> parameter
and
gives users explicit control over SQL generation.</p>
<p>To render PRIMARY KEY inline, use the
<code>Operations.add_column.inline_primary_key</code> parameter set to
<code>True</code>:</p>
<p>op.add_column(
&quot;my_table&quot;,
Column(&quot;id&quot;, Integer, primary_key=True),
inline_primary_key=True
)References: <a
href="https://redirect.github.com/sqlalchemy/alembic/issues/1232">#1232</a></p>
</li>
</ul>
<h1>1.18.3</h1>
<p>Released: January 29, 2026</p>
<h2>bug</h2>
<ul>
<li>
<p><strong>[bug] [autogenerate]</strong> Fixed regression in version
1.18.0 due to <a
href="https://redirect.github.com/sqlalchemy/alembic/issues/1771">#1771</a>
where autogenerate
would raise <code>NoReferencedTableError</code> when a foreign key
constraint
referenced a table that was not part of the initial table load,
including
tables filtered out by the
<code>EnvironmentContext.configure.include_name</code> callable or
tables
in remote schemas that were not included in the initial reflection
run.</p>
<p>The change in <a
href="https://redirect.github.com/sqlalchemy/alembic/issues/1771">#1771</a>
was a performance optimization that eliminated
additional reflection queries for tables that were only referenced by
foreign keys but not explicitly included in the main reflection run.
However, this optimization inadvertently removed the creation of
<code>Table</code> objects for these referenced tables, causing
autogenerate
to fail when processing foreign key constraints that pointed to
them.</p>
<p>The fix creates placeholder <code>Table</code> objects for foreign
key targets</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/sqlalchemy/alembic/commits">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=alembic&package-manager=uv&previous-version=1.17.2&new-version=1.18.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:34:15 +01:00
dependabot[bot]
4836e3e188 Bump prettier-plugin-svelte from 3.4.0 to 3.4.1 in /web (#437)
Bumps
[prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte)
from 3.4.0 to 3.4.1.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/sveltejs/prettier-plugin-svelte/blob/v3.4.1/CHANGELOG.md">prettier-plugin-svelte's
changelog</a>.</em></p>
<blockquote>
<h2>3.4.1</h2>
<ul>
<li>(fix) externalize all prettier imports</li>
<li>(fix) don't remove parantheses of <code>bind:</code>ings with
<code>as</code> type casts</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/sveltejs/prettier-plugin-svelte/commits/v3.4.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=prettier-plugin-svelte&package-manager=npm_and_yarn&previous-version=3.4.0&new-version=3.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:33:54 +01:00
dependabot[bot]
7a6466ea9d Bump @sinclair/typebox from 0.34.41 to 0.34.48 in /web (#436)
Bumps
[@sinclair/typebox](https://github.com/sinclairzx81/typebox-legacy) from
0.34.41 to 0.34.48.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/sinclairzx81/typebox-legacy/commits/0.34.48">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by [GitHub Actions](<a
href="https://www.npmjs.com/~GitHub">https://www.npmjs.com/~GitHub</a>
Actions), a new releaser for <code>@​sinclair/typebox</code> since your
current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@sinclair/typebox&package-manager=npm_and_yarn&previous-version=0.34.41&new-version=0.34.48)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:33:33 +01:00
Maximilian Dorninger
b427aa5723 Merge pull request #433 from maxdorninger/dependabot/npm_and_yarn/web/npm_and_yarn-e0ba90b5b1
Bump @sveltejs/kit from 2.49.2 to 2.51.0 in /web in the npm_and_yarn group across 1 directory
2026-02-13 19:39:57 +01:00
dependabot[bot]
82aa01a650 Bump @sveltejs/kit in /web in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /web directory: [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit).


Updates `@sveltejs/kit` from 2.49.2 to 2.51.0
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.51.0/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.51.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 18:25:01 +00:00
Maximilian Dorninger
bc3895ab40 Merge pull request #427 from maxdorninger/dependabot/npm_and_yarn/web/typeschema/class-validator-0.3.0
Bump @typeschema/class-validator from 0.2.0 to 0.3.0 in /web
2026-02-13 19:24:38 +01:00
Maximilian Dorninger
b7ed529f77 Merge pull request #428 from maxdorninger/dependabot/uv/starlette-0.52.1
Bump starlette from 0.50.0 to 0.52.1
2026-02-13 19:24:26 +01:00
Maximilian Dorninger
370df4efa0 Merge pull request #429 from maxdorninger/dependabot/npm_and_yarn/web/vite-7.3.1
Bump vite from 7.2.7 to 7.3.1 in /web
2026-02-13 19:24:12 +01:00
Maximilian Dorninger
a3e85d6338 Merge pull request #431 from maxdorninger/dependabot/npm_and_yarn/web/svelte-5.51.0
Bump svelte from 5.45.8 to 5.51.0 in /web
2026-02-13 19:24:01 +01:00
Maximilian Dorninger
a2816f2dfb Merge pull request #432 from maxdorninger/dependabot/npm_and_yarn/web/lucide-svelte-0.564.0
Bump lucide-svelte from 0.544.0 to 0.564.0 in /web
2026-02-13 19:23:48 +01:00
Maximilian Dorninger
0026b891f5 Merge pull request #430 from maxdorninger/dependabot/uv/cachetools-7.0.1
Bump cachetools from 6.2.4 to 7.0.1
2026-02-13 19:23:34 +01:00
dependabot[bot]
b312d880b7 Bump lucide-svelte from 0.544.0 to 0.564.0 in /web
Bumps [lucide-svelte](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-svelte) from 0.544.0 to 0.564.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.564.0/packages/lucide-svelte)

---
updated-dependencies:
- dependency-name: lucide-svelte
  dependency-version: 0.564.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 18:08:18 +00:00
dependabot[bot]
71e2a08535 Bump svelte from 5.45.8 to 5.51.0 in /web
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.45.8 to 5.51.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.51.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.51.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 18:08:10 +00:00
dependabot[bot]
f2bf1a2dae Bump cachetools from 6.2.4 to 7.0.1
Bumps [cachetools](https://github.com/tkem/cachetools) from 6.2.4 to 7.0.1.
- [Changelog](https://github.com/tkem/cachetools/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/tkem/cachetools/compare/v6.2.4...v7.0.1)

---
updated-dependencies:
- dependency-name: cachetools
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 18:08:06 +00:00
dependabot[bot]
6b70980c2a Bump vite from 7.2.7 to 7.3.1 in /web
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.2.7 to 7.3.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 18:08:01 +00:00
dependabot[bot]
e80a516c23 Bump starlette from 0.50.0 to 0.52.1
Bumps [starlette](https://github.com/Kludex/starlette) from 0.50.0 to 0.52.1.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/0.50.0...0.52.1)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 0.52.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 18:07:55 +00:00
dependabot[bot]
280e136209 Bump @typeschema/class-validator from 0.2.0 to 0.3.0 in /web
Bumps [@typeschema/class-validator](https://github.com/decs/typeschema) from 0.2.0 to 0.3.0.
- [Release notes](https://github.com/decs/typeschema/releases)
- [Commits](https://github.com/decs/typeschema/compare/@typeschema/class-validator@0.2.0...@typeschema/class-validator@0.3.0)

---
updated-dependencies:
- dependency-name: "@typeschema/class-validator"
  dependency-version: 0.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 18:07:51 +00:00
maxid
5c62c9f5be Merge remote-tracking branch 'origin/master' 2026-02-13 19:06:44 +01:00
maxid
1e46cdc03b add metadata_relay to dependabot.yml 2026-02-13 19:06:36 +01:00
Maximilian Dorninger
18573fa7d9 Bump actions/setup-python from 5 to 6 (#412)
Bumps [actions/setup-python](https://github.com/actions/setup-python)
from 5 to 6.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-python/releases">actions/setup-python's
releases</a>.</em></p>
<blockquote>
<h2>v6.0.0</h2>
<h2>What's Changed</h2>
<h3>Breaking Changes</h3>
<ul>
<li>Upgrade to node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1164">actions/setup-python#1164</a></li>
</ul>
<p>Make sure your runner is on version v2.327.1 or later to ensure
compatibility with this release. <a
href="https://github.com/actions/runner/releases/tag/v2.327.1">See
Release Notes</a></p>
<h3>Enhancements:</h3>
<ul>
<li>Add support for <code>pip-version</code> by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1129">actions/setup-python#1129</a></li>
<li>Enhance reading from .python-version by <a
href="https://github.com/krystof-k"><code>@​krystof-k</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/787">actions/setup-python#787</a></li>
<li>Add version parsing from Pipfile by <a
href="https://github.com/aradkdj"><code>@​aradkdj</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1067">actions/setup-python#1067</a></li>
</ul>
<h3>Bug fixes:</h3>
<ul>
<li>Clarify pythonLocation behaviour for PyPy and GraalPy in environment
variables by <a
href="https://github.com/aparnajyothi-y"><code>@​aparnajyothi-y</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1183">actions/setup-python#1183</a></li>
<li>Change missing cache directory error to warning by <a
href="https://github.com/aparnajyothi-y"><code>@​aparnajyothi-y</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1182">actions/setup-python#1182</a></li>
<li>Add Architecture-Specific PATH Management for Python with --user
Flag on Windows by <a
href="https://github.com/aparnajyothi-y"><code>@​aparnajyothi-y</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1122">actions/setup-python#1122</a></li>
<li>Include python version in PyPy python-version output by <a
href="https://github.com/cdce8p"><code>@​cdce8p</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1110">actions/setup-python#1110</a></li>
<li>Update docs: clarification on pip authentication with setup-python
by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1156">actions/setup-python#1156</a></li>
</ul>
<h3>Dependency updates:</h3>
<ul>
<li>Upgrade idna from 2.9 to 3.7 in /<strong>tests</strong>/data by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/setup-python/pull/843">actions/setup-python#843</a></li>
<li>Upgrade form-data to fix critical vulnerabilities <a
href="https://redirect.github.com/actions/setup-python/issues/182">#182</a>
&amp; <a
href="https://redirect.github.com/actions/setup-python/issues/183">#183</a>
by <a
href="https://github.com/aparnajyothi-y"><code>@​aparnajyothi-y</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1163">actions/setup-python#1163</a></li>
<li>Upgrade setuptools to 78.1.1 to fix path traversal vulnerability in
PackageIndex.download by <a
href="https://github.com/aparnajyothi-y"><code>@​aparnajyothi-y</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1165">actions/setup-python#1165</a></li>
<li>Upgrade actions/checkout from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/setup-python/pull/1181">actions/setup-python#1181</a></li>
<li>Upgrade <code>@​actions/tool-cache</code> from 2.0.1 to 2.0.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/setup-python/pull/1095">actions/setup-python#1095</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/krystof-k"><code>@​krystof-k</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-python/pull/787">actions/setup-python#787</a></li>
<li><a href="https://github.com/cdce8p"><code>@​cdce8p</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-python/pull/1110">actions/setup-python#1110</a></li>
<li><a href="https://github.com/aradkdj"><code>@​aradkdj</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-python/pull/1067">actions/setup-python#1067</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-python/compare/v5...v6.0.0">https://github.com/actions/setup-python/compare/v5...v6.0.0</a></p>
<h2>v5.6.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Workflow updates related to Ubuntu 20.04 by <a
href="https://github.com/aparnajyothi-y"><code>@​aparnajyothi-y</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1065">actions/setup-python#1065</a></li>
<li>Fix for Candidate Not Iterable Error by <a
href="https://github.com/aparnajyothi-y"><code>@​aparnajyothi-y</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1082">actions/setup-python#1082</a></li>
<li>Upgrade semver and <code>@​types/semver</code> by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1091">actions/setup-python#1091</a></li>
<li>Upgrade prettier from 2.8.8 to 3.5.3 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1046">actions/setup-python#1046</a></li>
<li>Upgrade ts-jest from 29.1.2 to 29.3.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1081">actions/setup-python#1081</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-python/compare/v5...v5.6.0">https://github.com/actions/setup-python/compare/v5...v5.6.0</a></p>
<h2>v5.5.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements:</h3>
<ul>
<li>Support free threaded Python versions like '3.13t' by <a
href="https://github.com/colesbury"><code>@​colesbury</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/973">actions/setup-python#973</a></li>
<li>Enhance Workflows: Include ubuntu-arm runners, Add e2e Testing for
free threaded and Upgrade <code>@​action/cache</code> from 4.0.0 to
4.0.3 by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1056">actions/setup-python#1056</a></li>
<li>Add support for .tool-versions file in setup-python by <a
href="https://github.com/mahabaleshwars"><code>@​mahabaleshwars</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1043">actions/setup-python#1043</a></li>
</ul>
<h3>Bug fixes:</h3>
<ul>
<li>Fix architecture for pypy on Linux ARM64 by <a
href="https://github.com/mayeut"><code>@​mayeut</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1011">actions/setup-python#1011</a>
This update maps arm64 to aarch64 for Linux ARM64 PyPy
installations.</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="a309ff8b42"><code>a309ff8</code></a>
Bump urllib3 from 2.6.0 to 2.6.3 in /<strong>tests</strong>/data (<a
href="https://redirect.github.com/actions/setup-python/issues/1264">#1264</a>)</li>
<li><a
href="bfe8cc55a7"><code>bfe8cc5</code></a>
Upgrade <a href="https://github.com/actions"><code>@​actions</code></a>
dependencies to Node 24 compatible versions (<a
href="https://redirect.github.com/actions/setup-python/issues/1259">#1259</a>)</li>
<li><a
href="4f41a90a1f"><code>4f41a90</code></a>
Bump urllib3 from 2.5.0 to 2.6.0 in /<strong>tests</strong>/data (<a
href="https://redirect.github.com/actions/setup-python/issues/1253">#1253</a>)</li>
<li><a
href="83679a892e"><code>83679a8</code></a>
Bump <code>@​types/node</code> from 24.1.0 to 24.9.1 and update macos-13
to macos-15-intel ...</li>
<li><a
href="bfc4944b43"><code>bfc4944</code></a>
Bump prettier from 3.5.3 to 3.6.2 (<a
href="https://redirect.github.com/actions/setup-python/issues/1234">#1234</a>)</li>
<li><a
href="97aeb3efb8"><code>97aeb3e</code></a>
Bump requests from 2.32.2 to 2.32.4 in /<strong>tests</strong>/data (<a
href="https://redirect.github.com/actions/setup-python/issues/1130">#1130</a>)</li>
<li><a
href="443da59188"><code>443da59</code></a>
Bump actions/publish-action from 0.3.0 to 0.4.0 &amp; Documentation
update for pi...</li>
<li><a
href="cfd55ca824"><code>cfd55ca</code></a>
graalpy: add graalpy early-access and windows builds (<a
href="https://redirect.github.com/actions/setup-python/issues/880">#880</a>)</li>
<li><a
href="bba65e51ff"><code>bba65e5</code></a>
Bump typescript from 5.4.2 to 5.9.3 and update docs/advanced-usage.md
(<a
href="https://redirect.github.com/actions/setup-python/issues/1094">#1094</a>)</li>
<li><a
href="18566f86b3"><code>18566f8</code></a>
Improve wording and &quot;fix example&quot; (remove 3.13) on testing
against pre-releas...</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/setup-python/compare/v5...v6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-python&package-manager=github_actions&previous-version=5&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 19:04:05 +01:00
maxid
6debd7a42d update package-lock.json 2026-02-13 18:54:54 +01:00
55 changed files with 2419 additions and 4055 deletions

View File

@@ -23,3 +23,9 @@ updates:
interval: "weekly"
open-pull-requests-limit: 5
- package-ecosystem: "uv"
directory: "/metadata_relay"
schedule:
interval: "weekly"
open-pull-requests-limit: 5

View File

@@ -1,7 +1,7 @@
<br />
<div align="center">
<a href="https://maxdorninger.github.io/MediaManager/">
<img src="https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/web/static/logo.svg" alt="Logo" width="260" height="260">
<img src="https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/docs/assets/logo-with-text.svg" alt="Logo" width="800">
</a>
<h3 align="center">MediaManager</h3>

View File

@@ -30,14 +30,13 @@ from media_manager.auth.db import OAuthAccount, User # noqa: E402
from media_manager.config import MediaManagerConfig # noqa: E402
from media_manager.database import Base # noqa: E402
from media_manager.indexer.models import IndexerQueryResult # noqa: E402
from media_manager.movies.models import Movie, MovieFile, MovieRequest # noqa: E402
from media_manager.movies.models import Movie, MovieFile # noqa: E402
from media_manager.notification.models import Notification # noqa: E402
from media_manager.torrent.models import Torrent # noqa: E402
from media_manager.tv.models import ( # noqa: E402
Episode,
EpisodeFile,
Season,
SeasonFile,
SeasonRequest,
Show,
)
@@ -47,15 +46,13 @@ target_metadata = Base.metadata
# noinspection PyStatementEffect
__all__ = [
"Episode",
"EpisodeFile",
"IndexerQueryResult",
"Movie",
"MovieFile",
"MovieRequest",
"Notification",
"OAuthAccount",
"Season",
"SeasonFile",
"SeasonRequest",
"Show",
"Torrent",
"User",

View File

@@ -0,0 +1,46 @@
"""create episode file table and add episode column to indexerqueryresult
Revision ID: 3a8fbd71e2c2
Revises: 9f3c1b2a4d8e
Create Date: 2026-01-08 13:43:00
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy.dialects import postgresql
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3a8fbd71e2c2"
down_revision: Union[str, None] = "9f3c1b2a4d8e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
quality_enum = postgresql.ENUM("uhd", "fullhd", "hd", "sd", "unknown", name="quality",
create_type=False,
)
# Create episode file table
op.create_table(
"episode_file",
sa.Column("episode_id", sa.UUID(), nullable=False),
sa.Column("torrent_id", sa.UUID(), nullable=True),
sa.Column("file_path_suffix", sa.String(), nullable=False),
sa.Column("quality", quality_enum, nullable=False),
sa.ForeignKeyConstraint(["episode_id"], ["episode.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["torrent_id"], ["torrent.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("episode_id", "file_path_suffix"),
)
# Add episode column to indexerqueryresult
op.add_column(
"indexer_query_result", sa.Column("episode", postgresql.ARRAY(sa.Integer()), nullable=True),
)
def downgrade() -> None:
op.drop_table("episode_file")
op.drop_column("indexer_query_result", "episode")

View File

@@ -0,0 +1,31 @@
"""add overview column to episode table
Revision ID: 9f3c1b2a4d8e
Revises: 2c61f662ca9e
Create Date: 2025-12-29 21:45:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "9f3c1b2a4d8e"
down_revision: Union[str, None] = "2c61f662ca9e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add overview to episode table
op.add_column(
"episode",
sa.Column("overview", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("episode", "overview")

View File

@@ -0,0 +1,71 @@
"""migrate season files to episode files and drop the legacy table
Revision ID: a6f714d3c8b9
Revises: 16e78af9e5bf
Create Date: 2026-02-22 16:30:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "a6f714d3c8b9"
down_revision: Union[str, None] = "3a8fbd71e2c2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Copy season_file records into episode_file and remove the legacy table."""
op.execute(
"""
INSERT INTO episode_file (episode_id, torrent_id, file_path_suffix, quality)
SELECT episode.id, season_file.torrent_id, season_file.file_path_suffix, season_file.quality
FROM season_file
JOIN season ON season.id = season_file.season_id
JOIN episode ON episode.season_id = season.id
LEFT JOIN episode_file ON
episode_file.episode_id = episode.id
AND episode_file.file_path_suffix = season_file.file_path_suffix
WHERE episode_file.episode_id IS NULL
"""
)
op.drop_table("season_file")
def downgrade() -> None:
"""Recreate season_file, repopulate it from episode_file, and keep both tables."""
quality_enum = postgresql.ENUM(
"uhd", "fullhd", "hd", "sd", "unknown", name="quality", create_type=False
)
op.create_table(
"season_file",
sa.Column("season_id", sa.UUID(), nullable=False),
sa.Column("torrent_id", sa.UUID(), nullable=True),
sa.Column("file_path_suffix", sa.String(), nullable=False),
sa.Column("quality", quality_enum, nullable=False),
sa.ForeignKeyConstraint(["season_id"], ["season.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["torrent_id"], ["torrent.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("season_id", "file_path_suffix"),
)
op.execute(
"""
INSERT INTO season_file (season_id, torrent_id, file_path_suffix, quality)
SELECT DISTINCT ON (episode.season_id, episode_file.file_path_suffix)
episode.season_id,
episode_file.torrent_id,
episode_file.file_path_suffix,
episode_file.quality
FROM episode_file
JOIN episode ON episode.id = episode_file.episode_id
ORDER BY episode.season_id, episode_file.file_path_suffix, episode_file.torrent_id, episode_file.quality
"""
)

View File

@@ -0,0 +1,65 @@
"""remove requests
Revision ID: e60ae827ed98
Revises: a6f714d3c8b9
Create Date: 2026-02-22 18:07:12.866130
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'e60ae827ed98'
down_revision: Union[str, None] = 'a6f714d3c8b9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('movie_request')
op.drop_table('season_request')
op.alter_column('episode', 'overview',
existing_type=sa.TEXT(),
type_=sa.String(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('season_request',
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('season_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('wanted_quality', postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality'), autoincrement=False, nullable=False),
sa.Column('min_quality', postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality'), autoincrement=False, nullable=False),
sa.Column('requested_by_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('authorized', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('authorized_by_id', sa.UUID(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['authorized_by_id'], ['user.id'], name=op.f('season_request_authorized_by_id_fkey'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['requested_by_id'], ['user.id'], name=op.f('season_request_requested_by_id_fkey'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['season_id'], ['season.id'], name=op.f('season_request_season_id_fkey'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('season_request_pkey')),
sa.UniqueConstraint('season_id', 'wanted_quality', name=op.f('season_request_season_id_wanted_quality_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_table('movie_request',
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('movie_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('wanted_quality', postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality'), autoincrement=False, nullable=False),
sa.Column('min_quality', postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality'), autoincrement=False, nullable=False),
sa.Column('authorized', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('requested_by_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('authorized_by_id', sa.UUID(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['authorized_by_id'], ['user.id'], name=op.f('movie_request_authorized_by_id_fkey'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['movie_id'], ['movie.id'], name=op.f('movie_request_movie_id_fkey'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['requested_by_id'], ['user.id'], name=op.f('movie_request_requested_by_id_fkey'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('movie_request_pkey')),
sa.UniqueConstraint('movie_id', 'wanted_quality', name=op.f('movie_request_movie_id_wanted_quality_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
# ### end Alembic commands ###

BIN
docs/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 110 KiB

22
docs/custom.css Normal file
View File

@@ -0,0 +1,22 @@
/*.md-header__button.md-logo {*/
/* margin-top: 0;*/
/* margin-bottom: 0;*/
/* padding-top: 0;*/
/* padding-bottom: 0;*/
/*}*/
/*.md-header__button.md-logo img,*/
/*.md-header__button.md-logo svg {*/
/* height: 70%;*/
/* width: 70%;*/
/*}*/
/* Increase logo size */
.md-header__button.md-logo svg, .md-header__button.md-logo img {
height: 2.5rem; /* Increase height (default is usually ~1.2rem to 1.5rem) */
width: auto;
}
/* Adjust header height if necessary to fit the larger logo */
.md-header {
height: 4rem; /* Match or exceed your new logo height */
}

View File

@@ -18,6 +18,7 @@ class IndexerQueryResult(Base):
flags = mapped_column(ARRAY(String))
quality: Mapped[Quality]
season = mapped_column(ARRAY(Integer))
episode = mapped_column(ARRAY(Integer))
size = mapped_column(BigInteger)
usenet: Mapped[bool]
age: Mapped[int]

View File

@@ -35,10 +35,10 @@ class IndexerQueryResult(BaseModel):
@computed_field
@property
def quality(self) -> Quality:
high_quality_pattern = r"\b(4k)\b"
medium_quality_pattern = r"\b(1080p)\b"
low_quality_pattern = r"\b(720p)\b"
very_low_quality_pattern = r"\b(480p|360p)\b"
high_quality_pattern = r"\b(4k|2160p|uhd)\b"
medium_quality_pattern = r"\b(1080p|full[ ._-]?hd)\b"
low_quality_pattern = r"\b(720p|(?<!full[ ._-])hd(?![a-z]))\b"
very_low_quality_pattern = r"\b(480p|360p|sd)\b"
if re.search(high_quality_pattern, self.title, re.IGNORECASE):
return Quality.uhd
@@ -54,14 +54,55 @@ class IndexerQueryResult(BaseModel):
@computed_field
@property
def season(self) -> list[int]:
pattern = r"\bS(\d+)\b"
matches = re.findall(pattern, self.title, re.IGNORECASE)
if matches.__len__() == 2:
result = list(range(int(matches[0]), int(matches[1]) + 1))
elif matches.__len__() == 1:
result = [int(matches[0])]
title = self.title.lower()
# 1) S01E01 / S1E2
m = re.search(r"s(\d{1,2})e\d{1,3}", title)
if m:
return [int(m.group(1))]
# 2) Range S01-S03 / S1-S3
m = re.search(r"s(\d{1,2})\s*(?:-|\u2013)\s*s?(\d{1,2})", title)
if m:
start, end = int(m.group(1)), int(m.group(2))
if start <= end:
return list(range(start, end + 1))
return []
# 3) Pack S01 / S1
m = re.search(r"\bs(\d{1,2})\b", title)
if m:
return [int(m.group(1))]
# 4) Season 01 / Season 1
m = re.search(r"\bseason\s*(\d{1,2})\b", title)
if m:
return [int(m.group(1))]
return []
@computed_field(return_type=list[int])
@property
def episode(self) -> list[int]:
title = self.title.lower()
result: list[int] = []
pattern = r"s\d{1,2}e(\d{1,3})(?:\s*-\s*(?:s?\d{1,2}e)?(\d{1,3}))?"
match = re.search(pattern, title)
if not match:
return result
start = int(match.group(1))
end = match.group(2)
if end:
end = int(end)
if end >= start:
result = list(range(start, end + 1))
else:
result = []
result = [start]
return result
def __gt__(self, other: "IndexerQueryResult") -> bool:

View File

@@ -3,7 +3,6 @@ from uuid import UUID
from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from media_manager.auth.db import User
from media_manager.database import Base
from media_manager.torrent.models import Quality
@@ -22,10 +21,6 @@ class Movie(Base):
original_language: Mapped[str | None] = mapped_column(default=None)
imdb_id: Mapped[str | None] = mapped_column(default=None)
movie_requests: Mapped[list["MovieRequest"]] = relationship(
"MovieRequest", back_populates="movie", cascade="all, delete-orphan"
)
class MovieFile(Base):
__tablename__ = "movie_file"
@@ -42,31 +37,3 @@ class MovieFile(Base):
)
torrent = relationship("Torrent", back_populates="movie_files", uselist=False)
class MovieRequest(Base):
__tablename__ = "movie_request"
__table_args__ = (UniqueConstraint("movie_id", "wanted_quality"),)
id: Mapped[UUID] = mapped_column(primary_key=True)
movie_id: Mapped[UUID] = mapped_column(
ForeignKey(column="movie.id", ondelete="CASCADE"),
)
wanted_quality: Mapped[Quality]
min_quality: Mapped[Quality]
authorized: Mapped[bool] = mapped_column(default=False)
requested_by_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="user.id", ondelete="SET NULL"),
)
authorized_by_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="user.id", ondelete="SET NULL"),
)
requested_by: Mapped["User|None"] = relationship(
foreign_keys=[requested_by_id], uselist=False
)
authorized_by: Mapped["User|None"] = relationship(
foreign_keys=[authorized_by_id], uselist=False
)
movie = relationship("Movie", back_populates="movie_requests", uselist=False)

View File

@@ -5,10 +5,10 @@ from sqlalchemy.exc import (
IntegrityError,
SQLAlchemyError,
)
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session
from media_manager.exceptions import ConflictError, NotFoundError
from media_manager.movies.models import Movie, MovieFile, MovieRequest
from media_manager.movies.models import Movie, MovieFile
from media_manager.movies.schemas import (
Movie as MovieSchema,
)
@@ -17,17 +17,10 @@ from media_manager.movies.schemas import (
)
from media_manager.movies.schemas import (
MovieId,
MovieRequestId,
)
from media_manager.movies.schemas import (
MovieRequest as MovieRequestSchema,
)
from media_manager.movies.schemas import (
MovieTorrent as MovieTorrentSchema,
)
from media_manager.movies.schemas import (
RichMovieRequest as RichMovieRequestSchema,
)
from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import TorrentId
@@ -173,46 +166,6 @@ class MovieRepository:
log.exception(f"Database error while deleting movie {movie_id}")
raise
def add_movie_request(
self, movie_request: MovieRequestSchema
) -> MovieRequestSchema:
"""
Adds a Movie to the MovieRequest table, which marks it as requested.
:param movie_request: The MovieRequest object to add.
:return: The added MovieRequest object.
:raises IntegrityError: If a similar request already exists or violates constraints.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Adding movie request: {movie_request.model_dump_json()}")
db_model = MovieRequest(
id=movie_request.id,
movie_id=movie_request.movie_id,
requested_by_id=movie_request.requested_by.id
if movie_request.requested_by
else None,
authorized_by_id=movie_request.authorized_by.id
if movie_request.authorized_by
else None,
wanted_quality=movie_request.wanted_quality,
min_quality=movie_request.min_quality,
authorized=movie_request.authorized,
)
try:
self.db.add(db_model)
self.db.commit()
self.db.refresh(db_model)
log.info(f"Successfully added movie request with id: {db_model.id}")
return MovieRequestSchema.model_validate(db_model)
except IntegrityError:
self.db.rollback()
log.exception("Integrity error while adding movie request")
raise
except SQLAlchemyError:
self.db.rollback()
log.exception("Database error while adding movie request")
raise
def set_movie_library(self, movie_id: MovieId, library: str) -> None:
"""
Sets the library for a movie.
@@ -234,49 +187,6 @@ class MovieRepository:
log.exception(f"Database error setting library for movie {movie_id}")
raise
def delete_movie_request(self, movie_request_id: MovieRequestId) -> None:
"""
Removes a MovieRequest by its ID.
:param movie_request_id: The ID of the movie request to delete.
:raises NotFoundError: If the movie request is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = delete(MovieRequest).where(MovieRequest.id == movie_request_id)
result = self.db.execute(stmt)
if result.rowcount == 0:
self.db.rollback()
msg = f"movie request with id {movie_request_id} not found."
raise NotFoundError(msg)
self.db.commit()
# Successfully deleted movie request with id: {movie_request_id}
except SQLAlchemyError:
self.db.rollback()
log.exception(
f"Database error while deleting movie request {movie_request_id}"
)
raise
def get_movie_requests(self) -> list[RichMovieRequestSchema]:
"""
Retrieve all movie requests.
:return: A list of RichMovieRequest objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = select(MovieRequest).options(
joinedload(MovieRequest.requested_by),
joinedload(MovieRequest.authorized_by),
joinedload(MovieRequest.movie),
)
results = self.db.execute(stmt).scalars().unique().all()
return [RichMovieRequestSchema.model_validate(x) for x in results]
except SQLAlchemyError:
log.exception("Database error while retrieving movie requests")
raise
def add_movie_file(self, movie_file: MovieFileSchema) -> MovieFileSchema:
"""
Adds a movie file record to the database.
@@ -396,25 +306,6 @@ class MovieRepository:
log.exception("Database error retrieving all movies with torrents")
raise
def get_movie_request(self, movie_request_id: MovieRequestId) -> MovieRequestSchema:
"""
Retrieve a movie request by its ID.
:param movie_request_id: The ID of the movie request.
:return: A MovieRequest object.
:raises NotFoundError: If the movie request is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
request = self.db.get(MovieRequest, movie_request_id)
if not request:
msg = f"Movie request with id {movie_request_id} not found."
raise NotFoundError(msg)
return MovieRequestSchema.model_validate(request)
except SQLAlchemyError:
log.exception(f"Database error retrieving movie request {movie_request_id}")
raise
def get_movie_by_torrent_id(self, torrent_id: TorrentId) -> MovieSchema:
"""
Retrieve a movie by a torrent ID.

View File

@@ -1,9 +1,7 @@
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from media_manager.auth.schemas import UserRead
from media_manager.auth.users import current_active_user, current_superuser
from media_manager.config import LibraryItem, MediaManagerConfig
from media_manager.exceptions import ConflictError, NotFoundError
@@ -13,20 +11,14 @@ from media_manager.indexer.schemas import (
)
from media_manager.metadataProvider.dependencies import metadata_provider_dep
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.movies import log
from media_manager.movies.dependencies import (
movie_dep,
movie_service_dep,
)
from media_manager.movies.schemas import (
CreateMovieRequest,
Movie,
MovieRequest,
MovieRequestBase,
MovieRequestId,
PublicMovie,
PublicMovieFile,
RichMovieRequest,
RichMovieTorrent,
)
from media_manager.schemas import MediaImportSuggestion
@@ -188,103 +180,6 @@ def get_available_libraries() -> list[LibraryItem]:
return MediaManagerConfig().misc.movie_libraries
# -----------------------------------------------------------------------------
# MOVIE REQUESTS
# -----------------------------------------------------------------------------
@router.get(
"/requests",
dependencies=[Depends(current_active_user)],
)
def get_all_movie_requests(movie_service: movie_service_dep) -> list[RichMovieRequest]:
"""
Get all movie requests.
"""
return movie_service.get_all_movie_requests()
@router.post(
"/requests",
status_code=status.HTTP_201_CREATED,
)
def create_movie_request(
movie_service: movie_service_dep,
movie_request: CreateMovieRequest,
user: Annotated[UserRead, Depends(current_active_user)],
) -> MovieRequest:
"""
Create a new movie request.
"""
log.info(
f"User {user.email} is creating a movie request for {movie_request.movie_id}"
)
movie_request: MovieRequest = MovieRequest.model_validate(movie_request)
movie_request.requested_by = user
if user.is_superuser:
movie_request.authorized = True
movie_request.authorized_by = user
return movie_service.add_movie_request(movie_request=movie_request)
@router.put(
"/requests/{movie_request_id}",
)
def update_movie_request(
movie_service: movie_service_dep,
movie_request_id: MovieRequestId,
update_movie_request: MovieRequestBase,
user: Annotated[UserRead, Depends(current_active_user)],
) -> MovieRequest:
"""
Update an existing movie request.
"""
movie_request = movie_service.get_movie_request_by_id(
movie_request_id=movie_request_id
)
if movie_request.requested_by.id != user.id or user.is_superuser:
movie_request.min_quality = update_movie_request.min_quality
movie_request.wanted_quality = update_movie_request.wanted_quality
return movie_service.update_movie_request(movie_request=movie_request)
@router.patch("/requests/{movie_request_id}", status_code=status.HTTP_204_NO_CONTENT)
def authorize_request(
movie_service: movie_service_dep,
movie_request_id: MovieRequestId,
user: Annotated[UserRead, Depends(current_superuser)],
authorized_status: bool = False,
) -> None:
"""
Authorize or de-authorize a movie request.
"""
movie_request = movie_service.get_movie_request_by_id(
movie_request_id=movie_request_id
)
movie_request.authorized = authorized_status
if authorized_status:
movie_request.authorized_by = user
else:
movie_request.authorized_by = None
movie_service.update_movie_request(movie_request=movie_request)
@router.delete(
"/requests/{movie_request_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(current_superuser)],
)
def delete_movie_request(
movie_service: movie_service_dep, movie_request_id: MovieRequestId
) -> None:
"""
Delete a movie request.
"""
movie_service.delete_movie_request(movie_request_id=movie_request_id)
# -----------------------------------------------------------------------------
# MOVIES - SINGLE RESOURCE
# -----------------------------------------------------------------------------

View File

@@ -2,14 +2,12 @@ import typing
import uuid
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field
from media_manager.auth.schemas import UserRead
from media_manager.torrent.models import Quality
from media_manager.torrent.schemas import TorrentId, TorrentStatus
MovieId = typing.NewType("MovieId", UUID)
MovieRequestId = typing.NewType("MovieRequestId", UUID)
class Movie(BaseModel):
@@ -40,38 +38,6 @@ class PublicMovieFile(MovieFile):
imported: bool = False
class MovieRequestBase(BaseModel):
min_quality: Quality
wanted_quality: Quality
@model_validator(mode="after")
def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "MovieRequestBase":
if self.min_quality.value < self.wanted_quality.value:
msg = "wanted_quality must be equal to or lower than minimum_quality."
raise ValueError(msg)
return self
class CreateMovieRequest(MovieRequestBase):
movie_id: MovieId
class MovieRequest(MovieRequestBase):
model_config = ConfigDict(from_attributes=True)
id: MovieRequestId = Field(default_factory=lambda: MovieRequestId(uuid.uuid4()))
movie_id: MovieId
requested_by: UserRead | None = None
authorized: bool = False
authorized_by: UserRead | None = None
class RichMovieRequest(MovieRequest):
movie: Movie
class MovieTorrent(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -4,10 +4,9 @@ from pathlib import Path
from typing import overload
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from media_manager.config import MediaManagerConfig
from media_manager.database import SessionLocal, get_session
from media_manager.database import get_session
from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError
from media_manager.indexer.repository import IndexerRepository
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
@@ -25,11 +24,8 @@ from media_manager.movies.schemas import (
Movie,
MovieFile,
MovieId,
MovieRequest,
MovieRequestId,
PublicMovie,
PublicMovieFile,
RichMovieRequest,
RichMovieTorrent,
)
from media_manager.notification.repository import NotificationRepository
@@ -38,7 +34,6 @@ from media_manager.schemas import MediaImportSuggestion
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import (
Quality,
QualityStrings,
Torrent,
TorrentStatus,
)
@@ -89,44 +84,6 @@ class MovieService:
metadata_provider.download_movie_poster_image(movie=saved_movie)
return saved_movie
def add_movie_request(self, movie_request: MovieRequest) -> MovieRequest:
"""
Add a new movie request.
:param movie_request: The movie request to add.
:return: The added movie request.
"""
return self.movie_repository.add_movie_request(movie_request=movie_request)
def get_movie_request_by_id(self, movie_request_id: MovieRequestId) -> MovieRequest:
"""
Get a movie request by its ID.
:param movie_request_id: The ID of the movie request.
:return: The movie request or None if not found.
"""
return self.movie_repository.get_movie_request(
movie_request_id=movie_request_id
)
def update_movie_request(self, movie_request: MovieRequest) -> MovieRequest:
"""
Update an existing movie request.
:param movie_request: The movie request to update.
:return: The updated movie request.
"""
self.movie_repository.delete_movie_request(movie_request_id=movie_request.id)
return self.movie_repository.add_movie_request(movie_request=movie_request)
def delete_movie_request(self, movie_request_id: MovieRequestId) -> None:
"""
Delete a movie request by its ID.
:param movie_request_id: The ID of the movie request to delete.
"""
self.movie_repository.delete_movie_request(movie_request_id=movie_request_id)
def delete_movie(
self,
movie: Movie,
@@ -391,14 +348,6 @@ class MovieService:
external_id=external_id, metadata_provider=metadata_provider
)
def get_all_movie_requests(self) -> list[RichMovieRequest]:
"""
Get all movie requests.
:return: A list of rich movie requests.
"""
return self.movie_repository.get_movie_requests()
def set_movie_library(self, movie: Movie, library: str) -> None:
self.movie_repository.set_movie_library(movie_id=movie.id, library=library)
@@ -471,65 +420,6 @@ class MovieService:
self.torrent_service.resume_download(torrent=movie_torrent)
return movie_torrent
def download_approved_movie_request(
self, movie_request: MovieRequest, movie: Movie
) -> bool:
"""
Download an approved movie request.
:param movie_request: The movie request to download.
:param movie: The Movie object.
:return: True if the download was successful, False otherwise.
:raises ValueError: If the movie request is not authorized.
"""
if not movie_request.authorized:
msg = "Movie request is not authorized"
raise ValueError(msg)
log.info(f"Downloading approved movie request {movie_request.id}")
torrents = self.get_all_available_torrents_for_movie(movie=movie)
available_torrents: list[IndexerQueryResult] = []
for torrent in torrents:
if (
(torrent.quality.value < movie_request.wanted_quality.value)
or (torrent.quality.value > movie_request.min_quality.value)
or (torrent.seeders < 3)
):
log.debug(
f"Skipping torrent {torrent.title} with quality {torrent.quality} for movie {movie.id}, because it does not match the requested quality {movie_request.wanted_quality}"
)
else:
available_torrents.append(torrent)
log.debug(
f"Taking torrent {torrent.title} with quality {torrent.quality} for movie {movie.id} into consideration"
)
if len(available_torrents) == 0:
log.warning(
f"No torrents found for movie request {movie_request.id} with quality between {QualityStrings[movie_request.min_quality.name]} and {QualityStrings[movie_request.wanted_quality.name]}"
)
return False
available_torrents.sort()
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
movie_file = MovieFile(
movie_id=movie.id,
quality=torrent.quality,
torrent_id=torrent.id,
file_path_suffix=QualityStrings[torrent.quality.name].value.upper(),
)
try:
self.movie_repository.add_movie_file(movie_file=movie_file)
except IntegrityError:
log.warning(
f"Movie file for movie {movie.name} and torrent {torrent.title} already exists"
)
self.delete_movie_request(movie_request.id)
return True
def get_movie_root_path(self, movie: Movie) -> Path:
misc_config = MediaManagerConfig().misc
movie_file_path = (
@@ -774,47 +664,6 @@ class MovieService:
return importable_movies
def auto_download_all_approved_movie_requests() -> None:
"""
Auto download all approved movie requests.
This is a standalone function as it creates its own DB session.
"""
db: Session = SessionLocal() if SessionLocal else next(get_session())
movie_repository = MovieRepository(db=db)
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
notification_service = NotificationService(
notification_repository=NotificationRepository(db=db)
)
movie_service = MovieService(
movie_repository=movie_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
notification_service=notification_service,
)
log.info("Auto downloading all approved movie requests")
movie_requests = movie_repository.get_movie_requests()
log.info(f"Found {len(movie_requests)} movie requests to process")
count = 0
for movie_request in movie_requests:
if movie_request.authorized:
movie = movie_repository.get_movie_by_id(movie_id=movie_request.movie_id)
if movie_service.download_approved_movie_request(
movie_request=movie_request, movie=movie
):
count += 1
else:
log.info(
f"Could not download movie request {movie_request.id} for movie {movie.name}"
)
log.info(f"Auto downloaded {count} approved movie requests")
db.commit()
db.close()
def import_all_movie_torrents() -> None:
with next(get_session()) as db:
movie_repository = MovieRepository(db=db)

View File

@@ -5,12 +5,10 @@ from apscheduler.triggers.cron import CronTrigger
import media_manager.database
from media_manager.config import MediaManagerConfig
from media_manager.movies.service import (
auto_download_all_approved_movie_requests,
import_all_movie_torrents,
update_all_movies_metadata,
)
from media_manager.tv.service import (
auto_download_all_approved_season_requests,
import_all_show_torrents,
update_all_non_ended_shows_metadata,
)
@@ -23,7 +21,6 @@ def setup_scheduler(config: MediaManagerConfig) -> BackgroundScheduler:
jobstores = {"default": SQLAlchemyJobStore(engine=media_manager.database.engine)}
scheduler = BackgroundScheduler(jobstores=jobstores)
every_15_minutes_trigger = CronTrigger(minute="*/15", hour="*")
daily_trigger = CronTrigger(hour=0, minute=0, jitter=60 * 60 * 24 * 2)
weekly_trigger = CronTrigger(
day_of_week="mon", hour=0, minute=0, jitter=60 * 60 * 24 * 2
)
@@ -39,18 +36,6 @@ def setup_scheduler(config: MediaManagerConfig) -> BackgroundScheduler:
id="import_all_show_torrents",
replace_existing=True,
)
scheduler.add_job(
auto_download_all_approved_season_requests,
daily_trigger,
id="auto_download_all_approved_season_requests",
replace_existing=True,
)
scheduler.add_job(
auto_download_all_approved_movie_requests,
daily_trigger,
id="auto_download_all_approved_movie_requests",
replace_existing=True,
)
scheduler.add_job(
update_all_movies_metadata,
weekly_trigger,

View File

@@ -60,9 +60,7 @@ class QbittorrentDownloadClient(AbstractDownloadClient):
try:
self.api_client.torrents_create_category(
name=self.config.category_name,
save_path=self.config.category_save_path
if self.config.category_save_path != ""
else None,
save_path=self.config.category_save_path,
)
except Conflict409Error:
try:

View File

@@ -16,5 +16,5 @@ class Torrent(Base):
hash: Mapped[str]
usenet: Mapped[bool]
season_files = relationship("SeasonFile", back_populates="torrent")
episode_files = relationship("EpisodeFile", back_populates="torrent")
movie_files = relationship("MovieFile", back_populates="torrent")

View File

@@ -3,17 +3,13 @@ from sqlalchemy import delete, select
from media_manager.database import DbSessionDependency
from media_manager.exceptions import NotFoundError
from media_manager.movies.models import Movie, MovieFile
from media_manager.movies.schemas import (
Movie as MovieSchema,
)
from media_manager.movies.schemas import (
MovieFile as MovieFileSchema,
)
from media_manager.movies.schemas import Movie as MovieSchema
from media_manager.movies.schemas import MovieFile as MovieFileSchema
from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import Torrent as TorrentSchema
from media_manager.torrent.schemas import TorrentId
from media_manager.tv.models import Season, SeasonFile, Show
from media_manager.tv.schemas import SeasonFile as SeasonFileSchema
from media_manager.tv.models import Episode, EpisodeFile, Season, Show
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
from media_manager.tv.schemas import Show as ShowSchema
@@ -21,19 +17,22 @@ class TorrentRepository:
def __init__(self, db: DbSessionDependency) -> None:
self.db = db
def get_seasons_files_of_torrent(
def get_episode_files_of_torrent(
self, torrent_id: TorrentId
) -> list[SeasonFileSchema]:
stmt = select(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
) -> list[EpisodeFileSchema]:
stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
result = self.db.execute(stmt).scalars().all()
return [SeasonFileSchema.model_validate(season_file) for season_file in result]
return [
EpisodeFileSchema.model_validate(episode_file) for episode_file in result
]
def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None:
stmt = (
select(Show)
.join(SeasonFile.season)
.join(Season.show)
.where(SeasonFile.torrent_id == torrent_id)
.join(Show.seasons)
.join(Season.episodes)
.join(Episode.episode_files)
.where(EpisodeFile.torrent_id == torrent_id)
)
result = self.db.execute(stmt).unique().scalar_one_or_none()
if result is None:
@@ -69,10 +68,10 @@ class TorrentRepository:
)
self.db.execute(movie_files_stmt)
season_files_stmt = delete(SeasonFile).where(
SeasonFile.torrent_id == torrent_id
episode_files_stmt = delete(EpisodeFile).where(
EpisodeFile.torrent_id == torrent_id
)
self.db.execute(season_files_stmt)
self.db.execute(episode_files_stmt)
self.db.delete(self.db.get(Torrent, torrent_id))

View File

@@ -5,7 +5,7 @@ from media_manager.movies.schemas import Movie, MovieFile
from media_manager.torrent.manager import DownloadManager
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import Torrent, TorrentId
from media_manager.tv.schemas import SeasonFile, Show
from media_manager.tv.schemas import EpisodeFile, Show
log = logging.getLogger(__name__)
@@ -19,13 +19,13 @@ class TorrentService:
self.torrent_repository = torrent_repository
self.download_manager = download_manager or DownloadManager()
def get_season_files_of_torrent(self, torrent: Torrent) -> list[SeasonFile]:
def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]:
"""
Returns all season files of a torrent
:param torrent: the torrent to get the season files of
:return: list of season files
Returns all episode files of a torrent
:param torrent: the torrent to get the episode files of
:return: list of episode files
"""
return self.torrent_repository.get_seasons_files_of_torrent(
return self.torrent_repository.get_episode_files_of_torrent(
torrent_id=torrent.id
)

View File

@@ -3,7 +3,6 @@ from uuid import UUID
from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from media_manager.auth.db import User
from media_manager.database import Base
from media_manager.torrent.models import Quality
@@ -48,13 +47,6 @@ class Season(Base):
back_populates="season", cascade="all, delete"
)
season_files = relationship(
"SeasonFile", back_populates="season", cascade="all, delete"
)
season_requests = relationship(
"SeasonRequest", back_populates="season", cascade="all, delete"
)
class Episode(Base):
__tablename__ = "episode"
@@ -66,15 +58,19 @@ class Episode(Base):
number: Mapped[int]
external_id: Mapped[int]
title: Mapped[str]
overview: Mapped[str | None] = mapped_column(nullable=True)
season: Mapped["Season"] = relationship(back_populates="episodes")
episode_files = relationship(
"EpisodeFile", back_populates="episode", cascade="all, delete"
)
class SeasonFile(Base):
__tablename__ = "season_file"
__table_args__ = (PrimaryKeyConstraint("season_id", "file_path_suffix"),)
season_id: Mapped[UUID] = mapped_column(
ForeignKey(column="season.id", ondelete="CASCADE"),
class EpisodeFile(Base):
__tablename__ = "episode_file"
__table_args__ = (PrimaryKeyConstraint("episode_id", "file_path_suffix"),)
episode_id: Mapped[UUID] = mapped_column(
ForeignKey(column="episode.id", ondelete="CASCADE"),
)
torrent_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="torrent.id", ondelete="SET NULL"),
@@ -82,31 +78,5 @@ class SeasonFile(Base):
file_path_suffix: Mapped[str]
quality: Mapped[Quality]
torrent = relationship("Torrent", back_populates="season_files", uselist=False)
season = relationship("Season", back_populates="season_files", uselist=False)
class SeasonRequest(Base):
__tablename__ = "season_request"
__table_args__ = (UniqueConstraint("season_id", "wanted_quality"),)
id: Mapped[UUID] = mapped_column(primary_key=True)
season_id: Mapped[UUID] = mapped_column(
ForeignKey(column="season.id", ondelete="CASCADE"),
)
wanted_quality: Mapped[Quality]
min_quality: Mapped[Quality]
requested_by_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="user.id", ondelete="SET NULL"),
)
authorized: Mapped[bool] = mapped_column(default=False)
authorized_by_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="user.id", ondelete="SET NULL"),
)
requested_by: Mapped["User|None"] = relationship(
foreign_keys=[requested_by_id], uselist=False
)
authorized_by: Mapped["User|None"] = relationship(
foreign_keys=[authorized_by_id], uselist=False
)
season = relationship("Season", back_populates="season_requests", uselist=False)
torrent = relationship("Torrent", back_populates="episode_files", uselist=False)
episode = relationship("Episode", back_populates="episode_files", uselist=False)

View File

@@ -1,8 +1,5 @@
from sqlalchemy import delete, func, select
from sqlalchemy.exc import (
IntegrityError,
SQLAlchemyError,
)
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session, joinedload
from media_manager.exceptions import ConflictError, NotFoundError
@@ -10,32 +7,18 @@ from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import Torrent as TorrentSchema
from media_manager.torrent.schemas import TorrentId
from media_manager.tv import log
from media_manager.tv.models import Episode, Season, SeasonFile, SeasonRequest, Show
from media_manager.tv.schemas import (
Episode as EpisodeSchema,
)
from media_manager.tv.models import Episode, EpisodeFile, Season, Show
from media_manager.tv.schemas import Episode as EpisodeSchema
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
from media_manager.tv.schemas import (
EpisodeId,
EpisodeNumber,
SeasonId,
SeasonNumber,
SeasonRequestId,
ShowId,
)
from media_manager.tv.schemas import (
RichSeasonRequest as RichSeasonRequestSchema,
)
from media_manager.tv.schemas import (
Season as SeasonSchema,
)
from media_manager.tv.schemas import (
SeasonFile as SeasonFileSchema,
)
from media_manager.tv.schemas import (
SeasonRequest as SeasonRequestSchema,
)
from media_manager.tv.schemas import (
Show as ShowSchema,
)
from media_manager.tv.schemas import Season as SeasonSchema
from media_manager.tv.schemas import Show as ShowSchema
class TvRepository:
@@ -120,9 +103,7 @@ class TvRepository:
def get_total_downloaded_episodes_count(self) -> int:
try:
stmt = (
select(func.count()).select_from(Episode).join(Season).join(SeasonFile)
)
stmt = select(func.count(Episode.id)).select_from(Episode).join(EpisodeFile)
return self.db.execute(stmt).scalar_one_or_none()
except SQLAlchemyError:
log.exception("Database error while calculating downloaded episodes count")
@@ -173,6 +154,7 @@ class TvRepository:
number=episode.number,
external_id=episode.external_id,
title=episode.title,
overview=episode.overview,
)
for episode in season.episodes
],
@@ -234,64 +216,40 @@ class TvRepository:
log.exception(f"Database error while retrieving season {season_id}")
raise
def add_season_request(
self, season_request: SeasonRequestSchema
) -> SeasonRequestSchema:
def get_episode(self, episode_id: EpisodeId) -> EpisodeSchema:
"""
Adds a Season to the SeasonRequest table, which marks it as requested.
Retrieve an episode by its ID.
:param season_request: The SeasonRequest object to add.
:return: The added SeasonRequest object.
:raises IntegrityError: If a similar request already exists or violates constraints.
:raises SQLAlchemyError: If a database error occurs.
"""
db_model = SeasonRequest(
id=season_request.id,
season_id=season_request.season_id,
wanted_quality=season_request.wanted_quality,
min_quality=season_request.min_quality,
requested_by_id=season_request.requested_by.id
if season_request.requested_by
else None,
authorized=season_request.authorized,
authorized_by_id=season_request.authorized_by.id
if season_request.authorized_by
else None,
)
try:
self.db.add(db_model)
self.db.commit()
self.db.refresh(db_model)
return SeasonRequestSchema.model_validate(db_model)
except IntegrityError:
self.db.rollback()
log.exception("Integrity error while adding season request")
raise
except SQLAlchemyError:
self.db.rollback()
log.exception("Database error while adding season request")
raise
def delete_season_request(self, season_request_id: SeasonRequestId) -> None:
"""
Removes a SeasonRequest by its ID.
:param season_request_id: The ID of the season request to delete.
:raises NotFoundError: If the season request is not found.
:param episode_id: The ID of the episode to get.
:return: An Episode object.
:raises NotFoundError: If the episode with the given ID is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = delete(SeasonRequest).where(SeasonRequest.id == season_request_id)
result = self.db.execute(stmt)
if result.rowcount == 0:
self.db.rollback()
msg = f"SeasonRequest with id {season_request_id} not found."
episode = self.db.get(Episode, episode_id)
if not episode:
msg = f"Episode with id {episode_id} not found."
raise NotFoundError(msg)
self.db.commit()
except SQLAlchemyError:
self.db.rollback()
log.exception(
f"Database error while deleting season request {season_request_id}"
return EpisodeSchema.model_validate(episode)
except SQLAlchemyError as e:
log.error(f"Database error while retrieving episode {episode_id}: {e}")
raise
def get_season_by_episode(self, episode_id: EpisodeId) -> SeasonSchema:
try:
stmt = select(Season).join(Season.episodes).where(Episode.id == episode_id)
season = self.db.scalar(stmt)
if not season:
msg = f"Season not found for episode {episode_id}"
raise NotFoundError(msg)
return SeasonSchema.model_validate(season)
except SQLAlchemyError as e:
log.error(
f"Database error while retrieving season for episode {episode_id}: {e}"
)
raise
@@ -323,78 +281,46 @@ class TvRepository:
)
raise
def get_season_requests(self) -> list[RichSeasonRequestSchema]:
def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema:
"""
Retrieve all season requests.
Adds an episode file record to the database.
:return: A list of RichSeasonRequest objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = select(SeasonRequest).options(
joinedload(SeasonRequest.requested_by),
joinedload(SeasonRequest.authorized_by),
joinedload(SeasonRequest.season).joinedload(Season.show),
)
results = self.db.execute(stmt).scalars().unique().all()
return [
RichSeasonRequestSchema(
id=SeasonRequestId(x.id),
min_quality=x.min_quality,
wanted_quality=x.wanted_quality,
season_id=SeasonId(x.season_id),
show=x.season.show,
season=x.season,
requested_by=x.requested_by,
authorized_by=x.authorized_by,
authorized=x.authorized,
)
for x in results
]
except SQLAlchemyError:
log.exception("Database error while retrieving season requests")
raise
def add_season_file(self, season_file: SeasonFileSchema) -> SeasonFileSchema:
"""
Adds a season file record to the database.
:param season_file: The SeasonFile object to add.
:return: The added SeasonFile object.
:param episode_file: The EpisodeFile object to add.
:return: The added EpisodeFile object.
:raises IntegrityError: If the record violates constraints.
:raises SQLAlchemyError: If a database error occurs.
"""
db_model = SeasonFile(**season_file.model_dump())
db_model = EpisodeFile(**episode_file.model_dump())
try:
self.db.add(db_model)
self.db.commit()
self.db.refresh(db_model)
return SeasonFileSchema.model_validate(db_model)
except IntegrityError:
return EpisodeFileSchema.model_validate(db_model)
except IntegrityError as e:
self.db.rollback()
log.exception("Integrity error while adding season file")
log.error(f"Integrity error while adding episode file: {e}")
raise
except SQLAlchemyError:
except SQLAlchemyError as e:
self.db.rollback()
log.exception("Database error while adding season file")
log.error(f"Database error while adding episode file: {e}")
raise
def remove_season_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
def remove_episode_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
"""
Removes season file records associated with a given torrent ID.
Removes episode file records associated with a given torrent ID.
:param torrent_id: The ID of the torrent whose season files are to be removed.
:return: The number of season files removed.
:param torrent_id: The ID of the torrent whose episode files are to be removed.
:return: The number of episode files removed.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = delete(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
stmt = delete(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
result = self.db.execute(stmt)
self.db.commit()
except SQLAlchemyError:
self.db.rollback()
log.exception(
f"Database error removing season files for torrent_id {torrent_id}"
f"Database error removing episode files for torrent_id {torrent_id}"
)
raise
return result.rowcount
@@ -420,23 +346,45 @@ class TvRepository:
log.exception(f"Database error setting library for show {show_id}")
raise
def get_season_files_by_season_id(
def get_episode_files_by_season_id(
self, season_id: SeasonId
) -> list[SeasonFileSchema]:
) -> list[EpisodeFileSchema]:
"""
Retrieve all season files for a given season ID.
Retrieve all episode files for a given season ID.
:param season_id: The ID of the season.
:return: A list of SeasonFile objects.
:return: A list of EpisodeFile objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = select(SeasonFile).where(SeasonFile.season_id == season_id)
stmt = (
select(EpisodeFile).join(Episode).where(Episode.season_id == season_id)
)
results = self.db.execute(stmt).scalars().all()
return [SeasonFileSchema.model_validate(sf) for sf in results]
return [EpisodeFileSchema.model_validate(ef) for ef in results]
except SQLAlchemyError:
log.exception(
f"Database error retrieving season files for season_id {season_id}"
f"Database error retrieving episode files for season_id {season_id}"
)
raise
def get_episode_files_by_episode_id(
self, episode_id: EpisodeId
) -> list[EpisodeFileSchema]:
"""
Retrieve all episode files for a given episode ID.
:param episode_id: The ID of the episode.
:return: A list of EpisodeFile objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = select(EpisodeFile).where(EpisodeFile.episode_id == episode_id)
results = self.db.execute(stmt).scalars().all()
return [EpisodeFileSchema.model_validate(sf) for sf in results]
except SQLAlchemyError as e:
log.error(
f"Database error retrieving episode files for episode_id {episode_id}: {e}"
)
raise
@@ -452,8 +400,9 @@ class TvRepository:
stmt = (
select(Torrent)
.distinct()
.join(SeasonFile, SeasonFile.torrent_id == Torrent.id)
.join(Season, Season.id == SeasonFile.season_id)
.join(EpisodeFile, EpisodeFile.torrent_id == Torrent.id)
.join(Episode, Episode.id == EpisodeFile.episode_id)
.join(Season, Season.id == Episode.season_id)
.where(Season.show_id == show_id)
)
results = self.db.execute(stmt).scalars().unique().all()
@@ -474,8 +423,9 @@ class TvRepository:
select(Show)
.distinct()
.join(Season, Show.id == Season.show_id)
.join(SeasonFile, Season.id == SeasonFile.season_id)
.join(Torrent, SeasonFile.torrent_id == Torrent.id)
.join(Episode, Season.id == Episode.season_id)
.join(EpisodeFile, Episode.id == EpisodeFile.episode_id)
.join(Torrent, EpisodeFile.torrent_id == Torrent.id)
.options(joinedload(Show.seasons).joinedload(Season.episodes))
.order_by(Show.name)
)
@@ -497,8 +447,9 @@ class TvRepository:
stmt = (
select(Season.number)
.distinct()
.join(SeasonFile, Season.id == SeasonFile.season_id)
.where(SeasonFile.torrent_id == torrent_id)
.join(Episode, Episode.season_id == Season.id)
.join(EpisodeFile, EpisodeFile.episode_id == Episode.id)
.where(EpisodeFile.torrent_id == torrent_id)
)
results = self.db.execute(stmt).scalars().unique().all()
return [SeasonNumber(x) for x in results]
@@ -508,27 +459,29 @@ class TvRepository:
)
raise
def get_season_request(
self, season_request_id: SeasonRequestId
) -> SeasonRequestSchema:
def get_episodes_by_torrent_id(self, torrent_id: TorrentId) -> list[EpisodeNumber]:
"""
Retrieve a season request by its ID.
Retrieve episode numbers associated with a given torrent ID.
:param season_request_id: The ID of the season request.
:return: A SeasonRequest object.
:raises NotFoundError: If the season request is not found.
:param torrent_id: The ID of the torrent.
:return: A list of EpisodeNumber objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
request = self.db.get(SeasonRequest, season_request_id)
if not request:
log.warning(f"Season request with id {season_request_id} not found.")
msg = f"Season request with id {season_request_id} not found."
raise NotFoundError(msg)
return SeasonRequestSchema.model_validate(request)
except SQLAlchemyError:
log.exception(
f"Database error retrieving season request {season_request_id}"
stmt = (
select(Episode.number)
.join(EpisodeFile, EpisodeFile.episode_id == Episode.id)
.where(EpisodeFile.torrent_id == torrent_id)
.order_by(Episode.number)
)
episode_numbers = self.db.execute(stmt).scalars().all()
return [EpisodeNumber(n) for n in sorted(set(episode_numbers))]
except SQLAlchemyError as e:
log.error(
f"Database error retrieving episodes for torrent_id {torrent_id}: {e}"
)
raise
@@ -734,11 +687,15 @@ class TvRepository:
return SeasonSchema.model_validate(db_season)
def update_episode_attributes(
self, episode_id: EpisodeId, title: str | None = None
self,
episode_id: EpisodeId,
title: str | None = None,
overview: str | None = None,
) -> EpisodeSchema:
"""
Update attributes of an existing episode.
:param overview: Tje new overview for the episode.
:param episode_id: The ID of the episode to update.
:param title: The new title for the episode.
:param external_id: The new external ID for the episode.
@@ -755,6 +712,9 @@ class TvRepository:
if title is not None and db_episode.title != title:
db_episode.title = title
updated = True
if overview is not None and db_episode.overview != overview:
db_episode.overview = overview
updated = True
if updated:
self.db.commit()

View File

@@ -1,10 +1,7 @@
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from media_manager.auth.db import User
from media_manager.auth.schemas import UserRead
from media_manager.auth.users import current_active_user, current_superuser
from media_manager.config import LibraryItem, MediaManagerConfig
from media_manager.exceptions import MediaAlreadyExistsError, NotFoundError
@@ -17,24 +14,18 @@ from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.schemas import MediaImportSuggestion
from media_manager.torrent.schemas import Torrent
from media_manager.torrent.utils import get_importable_media_directories
from media_manager.tv import log
from media_manager.tv.dependencies import (
season_dep,
show_dep,
tv_service_dep,
)
from media_manager.tv.schemas import (
CreateSeasonRequest,
PublicSeasonFile,
PublicEpisodeFile,
PublicShow,
RichSeasonRequest,
RichShowTorrent,
Season,
SeasonRequest,
SeasonRequestId,
Show,
ShowId,
UpdateSeasonRequest,
)
router = APIRouter()
@@ -278,110 +269,6 @@ def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep) -> RichShow
return tv_service.get_torrents_for_show(show=show)
# -----------------------------------------------------------------------------
# SEASONS - REQUESTS
# -----------------------------------------------------------------------------
@router.get(
"/seasons/requests",
status_code=status.HTTP_200_OK,
dependencies=[Depends(current_active_user)],
)
def get_season_requests(tv_service: tv_service_dep) -> list[RichSeasonRequest]:
"""
Get all season requests.
"""
return tv_service.get_all_season_requests()
@router.post("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
def request_a_season(
user: Annotated[User, Depends(current_active_user)],
season_request: CreateSeasonRequest,
tv_service: tv_service_dep,
) -> None:
"""
Create a new season request.
"""
request: SeasonRequest = SeasonRequest.model_validate(season_request)
request.requested_by = UserRead.model_validate(user)
if user.is_superuser:
request.authorized = True
request.authorized_by = UserRead.model_validate(user)
tv_service.add_season_request(request)
return
@router.put("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
def update_request(
tv_service: tv_service_dep,
user: Annotated[User, Depends(current_active_user)],
season_request: UpdateSeasonRequest,
) -> None:
"""
Update an existing season request.
"""
updated_season_request: SeasonRequest = SeasonRequest.model_validate(season_request)
request = tv_service.get_season_request_by_id(
season_request_id=updated_season_request.id
)
if request.requested_by.id == user.id or user.is_superuser:
updated_season_request.requested_by = UserRead.model_validate(user)
tv_service.update_season_request(season_request=updated_season_request)
return
@router.patch(
"/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT
)
def authorize_request(
tv_service: tv_service_dep,
user: Annotated[User, Depends(current_superuser)],
season_request_id: SeasonRequestId,
authorized_status: bool = False,
) -> None:
"""
Authorize or de-authorize a season request.
"""
season_request = tv_service.get_season_request_by_id(
season_request_id=season_request_id
)
if not season_request:
raise NotFoundError
season_request.authorized_by = UserRead.model_validate(user)
season_request.authorized = authorized_status
if not authorized_status:
season_request.authorized_by = None
tv_service.update_season_request(season_request=season_request)
@router.delete(
"/seasons/requests/{request_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_season_request(
tv_service: tv_service_dep,
user: Annotated[User, Depends(current_active_user)],
request_id: SeasonRequestId,
) -> None:
"""
Delete a season request.
"""
request = tv_service.get_season_request_by_id(season_request_id=request_id)
if user.is_superuser or request.requested_by.id == user.id:
tv_service.delete_season_request(season_request_id=request_id)
log.info(f"User {user.id} deleted season request {request_id}.")
return
log.warning(
f"User {user.id} tried to delete season request {request_id} but is not authorized."
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this request",
)
# -----------------------------------------------------------------------------
# SEASONS
# -----------------------------------------------------------------------------
@@ -402,13 +289,13 @@ def get_season(season: season_dep) -> Season:
"/seasons/{season_id}/files",
dependencies=[Depends(current_active_user)],
)
def get_season_files(
def get_episode_files(
season: season_dep, tv_service: tv_service_dep
) -> list[PublicSeasonFile]:
) -> list[PublicEpisodeFile]:
"""
Get files associated with a specific season.
Get episode files associated with a specific season.
"""
return tv_service.get_public_season_files_by_season_id(season=season)
return tv_service.get_public_episode_files_by_season_id(season=season)
# -----------------------------------------------------------------------------

View File

@@ -2,9 +2,8 @@ import typing
import uuid
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field
from media_manager.auth.schemas import UserRead
from media_manager.torrent.models import Quality
from media_manager.torrent.schemas import TorrentId, TorrentStatus
@@ -14,7 +13,6 @@ EpisodeId = typing.NewType("EpisodeId", UUID)
SeasonNumber = typing.NewType("SeasonNumber", int)
EpisodeNumber = typing.NewType("EpisodeNumber", int)
SeasonRequestId = typing.NewType("SeasonRequestId", UUID)
class Episode(BaseModel):
@@ -24,6 +22,7 @@ class Episode(BaseModel):
number: EpisodeNumber
external_id: int
title: str
overview: str | None = None
class Season(BaseModel):
@@ -62,52 +61,16 @@ class Show(BaseModel):
seasons: list[Season]
class SeasonRequestBase(BaseModel):
min_quality: Quality
wanted_quality: Quality
@model_validator(mode="after")
def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "SeasonRequestBase":
if self.min_quality.value < self.wanted_quality.value:
msg = "wanted_quality must be equal to or lower than minimum_quality."
raise ValueError(msg)
return self
class CreateSeasonRequest(SeasonRequestBase):
season_id: SeasonId
class UpdateSeasonRequest(SeasonRequestBase):
id: SeasonRequestId
class SeasonRequest(SeasonRequestBase):
class EpisodeFile(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: SeasonRequestId = Field(default_factory=lambda: SeasonRequestId(uuid.uuid4()))
season_id: SeasonId
requested_by: UserRead | None = None
authorized: bool = False
authorized_by: UserRead | None = None
class RichSeasonRequest(SeasonRequest):
show: Show
season: Season
class SeasonFile(BaseModel):
model_config = ConfigDict(from_attributes=True)
season_id: SeasonId
episode_id: EpisodeId
quality: Quality
torrent_id: TorrentId | None
file_path_suffix: str
class PublicSeasonFile(SeasonFile):
class PublicEpisodeFile(EpisodeFile):
downloaded: bool = False
@@ -123,6 +86,7 @@ class RichSeasonTorrent(BaseModel):
file_path_suffix: str
seasons: list[SeasonNumber]
episodes: list[EpisodeNumber]
class RichShowTorrent(BaseModel):
@@ -135,6 +99,18 @@ class RichShowTorrent(BaseModel):
torrents: list[RichSeasonTorrent]
class PublicEpisode(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: EpisodeId
number: EpisodeNumber
downloaded: bool = False
title: str
overview: str | None = None
external_id: int
class PublicSeason(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -147,7 +123,7 @@ class PublicSeason(BaseModel):
external_id: int
episodes: list[Episode]
episodes: list[PublicEpisode]
class PublicShow(BaseModel):

View File

@@ -25,7 +25,6 @@ from media_manager.schemas import MediaImportSuggestion
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import (
Quality,
QualityStrings,
Torrent,
TorrentStatus,
)
@@ -41,21 +40,17 @@ from media_manager.torrent.utils import (
from media_manager.tv import log
from media_manager.tv.repository import TvRepository
from media_manager.tv.schemas import (
Episode as EpisodeSchema,
)
from media_manager.tv.schemas import (
Episode,
EpisodeFile,
EpisodeId,
EpisodeNumber,
PublicEpisodeFile,
PublicSeason,
PublicSeasonFile,
PublicShow,
RichSeasonRequest,
RichSeasonTorrent,
RichShowTorrent,
Season,
SeasonFile,
SeasonId,
SeasonRequest,
SeasonRequestId,
Show,
ShowId,
)
@@ -94,28 +89,6 @@ class TvService:
metadata_provider.download_show_poster_image(show=saved_show)
return saved_show
def add_season_request(self, season_request: SeasonRequest) -> SeasonRequest:
"""
Add a new season request.
:param season_request: The season request to add.
:return: The added season request.
"""
return self.tv_repository.add_season_request(season_request=season_request)
def get_season_request_by_id(
self, season_request_id: SeasonRequestId
) -> SeasonRequest | None:
"""
Get a season request by its ID.
:param season_request_id: The ID of the season request.
:return: The season request or None if not found.
"""
return self.tv_repository.get_season_request(
season_request_id=season_request_id
)
def get_total_downloaded_episoded_count(self) -> int:
"""
Get total number of downloaded episodes.
@@ -123,27 +96,9 @@ class TvService:
return self.tv_repository.get_total_downloaded_episodes_count()
def update_season_request(self, season_request: SeasonRequest) -> SeasonRequest:
"""
Update an existing season request.
:param season_request: The season request to update.
:return: The updated season request.
"""
self.tv_repository.delete_season_request(season_request_id=season_request.id)
return self.tv_repository.add_season_request(season_request=season_request)
def set_show_library(self, show: Show, library: str) -> None:
self.tv_repository.set_show_library(show_id=show.id, library=library)
def delete_season_request(self, season_request_id: SeasonRequestId) -> None:
"""
Delete a season request by its ID.
:param season_request_id: The ID of the season request to delete.
"""
self.tv_repository.delete_season_request(season_request_id=season_request_id)
def delete_show(
self,
show: Show,
@@ -173,6 +128,7 @@ class TvService:
for torrent in torrents:
try:
self.torrent_service.cancel_download(torrent, delete_files=True)
self.torrent_service.delete_torrent(torrent_id=torrent.id)
log.info(f"Deleted torrent: {torrent.hash}")
except Exception:
log.warning(
@@ -181,24 +137,26 @@ class TvService:
self.tv_repository.delete_show(show_id=show.id)
def get_public_season_files_by_season_id(
def get_public_episode_files_by_season_id(
self, season: Season
) -> list[PublicSeasonFile]:
) -> list[PublicEpisodeFile]:
"""
Get all public season files for a given season.
Get all public episode files for a given season.
:param season: The season object.
:return: A list of public season files.
:return: A list of public episode files.
"""
season_files = self.tv_repository.get_season_files_by_season_id(
episode_files = self.tv_repository.get_episode_files_by_season_id(
season_id=season.id
)
public_season_files = [PublicSeasonFile.model_validate(x) for x in season_files]
public_episode_files = [
PublicEpisodeFile.model_validate(x) for x in episode_files
]
result = []
for season_file in public_season_files:
if self.season_file_exists_on_file(season_file=season_file):
season_file.downloaded = True
result.append(season_file)
for episode_file in public_episode_files:
if self.episode_file_exists_on_file(episode_file=episode_file):
episode_file.downloaded = True
result.append(episode_file)
return result
@overload
@@ -334,11 +292,27 @@ class TvService:
:param show: The show object.
:return: A public show.
"""
seasons = [PublicSeason.model_validate(season) for season in show.seasons]
for season in seasons:
season.downloaded = self.is_season_downloaded(season_id=season.id)
public_show = PublicShow.model_validate(show)
public_show.seasons = seasons
public_seasons: list[PublicSeason] = []
for season in show.seasons:
public_season = PublicSeason.model_validate(season)
for episode in public_season.episodes:
episode.downloaded = self.is_episode_downloaded(
episode=episode,
season=season,
show=show,
)
# A season is considered downloaded if it has episodes and all of them are downloaded,
# matching the behavior of is_season_downloaded.
public_season.downloaded = bool(public_season.episodes) and all(
episode.downloaded for episode in public_season.episodes
)
public_seasons.append(public_season)
public_show.seasons = public_seasons
return public_show
def get_show_by_id(self, show_id: ShowId) -> Show:
@@ -350,33 +324,85 @@ class TvService:
"""
return self.tv_repository.get_show_by_id(show_id=show_id)
def is_season_downloaded(self, season_id: SeasonId) -> bool:
def is_season_downloaded(self, season: Season, show: Show) -> bool:
"""
Check if a season is downloaded.
:param season_id: The ID of the season.
:param season: The season object.
:param show: The show object.
:return: True if the season is downloaded, False otherwise.
"""
season_files = self.tv_repository.get_season_files_by_season_id(
season_id=season_id
episodes = season.episodes
if not episodes:
return False
for episode in episodes:
if not self.is_episode_downloaded(
episode=episode, season=season, show=show
):
return False
return True
def is_episode_downloaded(
self, episode: Episode, season: Season, show: Show
) -> bool:
"""
Check if an episode is downloaded and imported (file exists on disk).
An episode is considered downloaded if:
- There is at least one EpisodeFile in the database AND
- A matching episode file exists in the season directory on disk.
:param episode: The episode object.
:param season: The season object.
:param show: The show object.
:return: True if the episode is downloaded and imported, False otherwise.
"""
episode_files = self.tv_repository.get_episode_files_by_episode_id(
episode_id=episode.id
)
for season_file in season_files:
if self.season_file_exists_on_file(season_file=season_file):
return True
if not episode_files:
return False
season_dir = self.get_root_season_directory(show, season.number)
if not season_dir.exists():
return False
episode_token = f"S{season.number:02d}E{episode.number:02d}"
video_extensions = {".mkv", ".mp4", ".avi", ".mov"}
try:
for file in season_dir.iterdir():
if (
file.is_file()
and episode_token.lower() in file.name.lower()
and file.suffix.lower() in video_extensions
):
return True
except OSError as e:
log.error(
f"Disk check failed for episode {episode.id} in {season_dir}: {e}"
)
return False
def season_file_exists_on_file(self, season_file: SeasonFile) -> bool:
def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool:
"""
Check if a season file exists on the filesystem.
Check if an episode file exists on the filesystem.
:param season_file: The season file to check.
:param episode_file: The episode file to check.
:return: True if the file exists, False otherwise.
"""
if season_file.torrent_id is None:
if episode_file.torrent_id is None:
return True
try:
torrent_file = self.torrent_service.get_torrent_by_id(
torrent_id=season_file.torrent_id
torrent_id=episode_file.torrent_id
)
if torrent_file.imported:
@@ -409,13 +435,23 @@ class TvService:
"""
return self.tv_repository.get_season(season_id=season_id)
def get_all_season_requests(self) -> list[RichSeasonRequest]:
def get_episode(self, episode_id: EpisodeId) -> Episode:
"""
Get all season requests.
Get an episode by its ID.
:return: A list of rich season requests.
:param episode_id: The ID of the episode.
:return: The episode.
"""
return self.tv_repository.get_season_requests()
return self.tv_repository.get_episode(episode_id=episode_id)
def get_season_by_episode(self, episode_id: EpisodeId) -> Season:
"""
Get a season by the episode ID.
:param episode_id: The ID of the episode.
:return: The season.
"""
return self.tv_repository.get_season_by_episode(episode_id=episode_id)
def get_torrents_for_show(self, show: Show) -> RichShowTorrent:
"""
@@ -430,10 +466,16 @@ class TvService:
seasons = self.tv_repository.get_seasons_by_torrent_id(
torrent_id=show_torrent.id
)
season_files = self.torrent_service.get_season_files_of_torrent(
episodes = self.tv_repository.get_episodes_by_torrent_id(
torrent_id=show_torrent.id
)
episode_files = self.torrent_service.get_episode_files_of_torrent(
torrent=show_torrent
)
file_path_suffix = season_files[0].file_path_suffix if season_files else ""
file_path_suffix = (
episode_files[0].file_path_suffix if episode_files else ""
)
season_torrent = RichSeasonTorrent(
torrent_id=show_torrent.id,
torrent_title=show_torrent.title,
@@ -441,10 +483,12 @@ class TvService:
quality=show_torrent.quality,
imported=show_torrent.imported,
seasons=seasons,
episodes=episodes if len(seasons) == 1 else [],
file_path_suffix=file_path_suffix,
usenet=show_torrent.usenet,
)
rich_season_torrents.append(season_torrent)
return RichShowTorrent(
show_id=show.id,
name=show.name,
@@ -487,95 +531,54 @@ class TvService:
season = self.tv_repository.get_season_by_number(
season_number=season_number, show_id=show_id
)
season_file = SeasonFile(
season_id=season.id,
quality=indexer_result.quality,
torrent_id=show_torrent.id,
file_path_suffix=override_show_file_path_suffix,
)
self.tv_repository.add_season_file(season_file=season_file)
episodes = {episode.number: episode.id for episode in season.episodes}
if indexer_result.episode:
episode_ids = []
missing_episodes = []
for ep_number in indexer_result.episode:
ep_id = episodes.get(EpisodeNumber(ep_number))
if ep_id is None:
missing_episodes.append(ep_number)
continue
episode_ids.append(ep_id)
if missing_episodes:
log.warning(
"Some episodes from indexer result were not found in season %s "
"for show %s and will be skipped: %s",
season.id,
show_id,
", ".join(str(ep) for ep in missing_episodes),
)
else:
episode_ids = [episode.id for episode in season.episodes]
for episode_id in episode_ids:
episode_file = EpisodeFile(
episode_id=episode_id,
quality=indexer_result.quality,
torrent_id=show_torrent.id,
file_path_suffix=override_show_file_path_suffix,
)
self.tv_repository.add_episode_file(episode_file=episode_file)
except IntegrityError:
log.error(
f"Season file for season {season.id} and quality {indexer_result.quality} already exists, skipping."
f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping."
)
self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id)
self.torrent_service.cancel_download(
torrent=show_torrent, delete_files=True
)
raise
else:
log.info(
f"Successfully added season files for torrent {show_torrent.title} and show ID {show_id}"
f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}"
)
self.torrent_service.resume_download(torrent=show_torrent)
return show_torrent
def download_approved_season_request(
self, season_request: SeasonRequest, show: Show
) -> bool:
"""
Download an approved season request.
:param season_request: The season request to download.
:param show: The Show object.
:return: True if the download was successful, False otherwise.
:raises ValueError: If the season request is not authorized.
"""
if not season_request.authorized:
msg = f"Season request {season_request.id} is not authorized for download"
raise ValueError(msg)
log.info(f"Downloading approved season request {season_request.id}")
season = self.get_season(season_id=season_request.season_id)
torrents = self.get_all_available_torrents_for_a_season(
season_number=season.number, show_id=show.id
)
available_torrents: list[IndexerQueryResult] = []
for torrent in torrents:
if (
(torrent.quality.value < season_request.wanted_quality.value)
or (torrent.quality.value > season_request.min_quality.value)
or (torrent.seeders < 3)
):
log.info(
f"Skipping torrent {torrent.title} with quality {torrent.quality} for season {season.id}, because it does not match the requested quality {season_request.wanted_quality}"
)
elif torrent.season != [season.number]:
log.info(
f"Skipping torrent {torrent.title} with quality {torrent.quality} for season {season.id}, because it contains to many/wrong seasons {torrent.season} (wanted: {season.number})"
)
else:
available_torrents.append(torrent)
log.info(
f"Taking torrent {torrent.title} with quality {torrent.quality} for season {season.id} into consideration"
)
if len(available_torrents) == 0:
log.warning(
f"No torrents matching criteria were found (wanted quality: {season_request.wanted_quality}, min_quality: {season_request.min_quality} for season {season.id})"
)
return False
available_torrents.sort()
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
season_file = SeasonFile(
season_id=season.id,
quality=torrent.quality,
torrent_id=torrent.id,
file_path_suffix=QualityStrings[torrent.quality.name].value.upper(),
)
try:
self.tv_repository.add_season_file(season_file=season_file)
except IntegrityError:
log.warning(
f"Season file for season {season.id} and quality {torrent.quality} already exists, skipping."
)
self.delete_season_request(season_request.id)
return True
def get_root_show_directory(self, show: Show) -> Path:
misc_config = MediaManagerConfig().misc
show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]"
@@ -653,12 +656,12 @@ class TvService:
video_files: list[Path],
subtitle_files: list[Path],
file_path_suffix: str = "",
) -> tuple[bool, int]:
) -> tuple[bool, list[Episode]]:
season_path = self.get_root_season_directory(
show=show, season_number=season.number
)
success = True
imported_episodes_count = 0
imported_episodes = []
try:
season_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
@@ -677,7 +680,7 @@ class TvService:
file_path_suffix=file_path_suffix,
)
if imported:
imported_episodes_count += 1
imported_episodes.append(episode)
except Exception:
# Send notification about missing episode file
@@ -690,11 +693,72 @@ class TvService:
log.warning(
f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}."
)
return success, imported_episodes_count
return success, imported_episodes
def import_torrent_files(self, torrent: Torrent, show: Show) -> None:
def import_episode_files(
self,
show: Show,
season: Season,
episode: Episode,
video_files: list[Path],
subtitle_files: list[Path],
file_path_suffix: str = "",
) -> bool:
episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}"
if file_path_suffix != "":
episode_file_name += f" - {file_path_suffix}"
pattern = (
r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*"
)
subtitle_pattern = pattern + r"[. ]([A-Za-z]{2})[. ]srt"
target_file_name = (
self.get_root_season_directory(show=show, season_number=season.number)
/ episode_file_name
)
# import subtitle
for subtitle_file in subtitle_files:
regex_result = re.search(
subtitle_pattern, subtitle_file.name, re.IGNORECASE
)
if regex_result:
language_code = regex_result.group(1)
target_subtitle_file = target_file_name.with_suffix(
f".{language_code}.srt"
)
import_file(target_file=target_subtitle_file, source_file=subtitle_file)
else:
log.debug(
f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}"
)
found_video = False
# import episode videos
for file in video_files:
if re.search(pattern, file.name, re.IGNORECASE):
target_video_file = target_file_name.with_suffix(file.suffix)
import_file(target_file=target_video_file, source_file=file)
found_video = True
break
if not found_video:
# Send notification about missing episode file
if self.notification_service:
self.notification_service.send_notification_to_all_providers(
title="Missing Episode File",
message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.",
)
log.warning(
f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}."
)
return False
return True
def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None:
"""
Organizes files from a torrent into the TV directory structure, mapping them to seasons and episodes.
Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes.
:param torrent: The Torrent object
:param show: The Show object
"""
@@ -707,33 +771,68 @@ class TvService:
f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files)
)
season_files = self.torrent_service.get_season_files_of_torrent(torrent=torrent)
episode_files = self.torrent_service.get_episode_files_of_torrent(
torrent=torrent
)
if not episode_files:
log.warning(
f"No episode files associated with torrent {torrent.title}, skipping import."
)
return
log.info(
f"Found {len(season_files)} season files associated with torrent {torrent.title}"
f"Found {len(episode_files)} episode files associated with torrent {torrent.title}"
)
for season_file in season_files:
season = self.get_season(season_id=season_file.season_id)
season_import_success, _imported_episodes_count = self.import_season(
imported_episodes_by_season: dict[int, list[int]] = {}
for episode_file in episode_files:
season = self.get_season_by_episode(episode_id=episode_file.episode_id)
episode = self.get_episode(episode_file.episode_id)
season_path = self.get_root_season_directory(
show=show, season_number=season.number
)
if not season_path.exists():
try:
season_path.mkdir(parents=True)
except Exception as e:
log.warning(f"Could not create path {season_path}: {e}")
msg = f"Could not create path {season_path}"
raise Exception(msg) from e # noqa: TRY002
episoded_import_success = self.import_episode_files(
show=show,
season=season,
episode=episode,
video_files=video_files,
subtitle_files=subtitle_files,
file_path_suffix=season_file.file_path_suffix,
file_path_suffix=episode_file.file_path_suffix,
)
success.append(season_import_success)
if season_import_success:
success.append(episoded_import_success)
if episoded_import_success:
imported_episodes_by_season.setdefault(season.number, []).append(
episode.number
)
log.info(
f"Season {season.number} successfully imported from torrent {torrent.title}"
f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}"
)
else:
log.warning(
f"Season {season.number} failed to import from torrent {torrent.title}"
f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}"
)
log.info(
f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors"
)
success_messages: list[str] = []
for season_number, episodes in imported_episodes_by_season.items():
episode_list = ",".join(str(e) for e in sorted(episodes))
success_messages.append(
f"Episode(s): {episode_list} from Season {season_number}"
)
episodes_summary = "; ".join(success_messages)
if all(success):
torrent.imported = True
@@ -743,7 +842,11 @@ class TvService:
if self.notification_service:
self.notification_service.send_notification_to_all_providers(
title="TV Show imported successfully",
message=f"Successfully imported {show.name} ({show.year}) from torrent {torrent.title}.",
message=(
f"Successfully imported {episodes_summary} "
f"of {show.name} ({show.year}) "
f"from torrent {torrent.title}."
),
)
else:
if self.notification_service:
@@ -752,6 +855,10 @@ class TvService:
message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.",
)
log.info(
f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors"
)
def update_show_metadata(
self, db_show: Show, metadata_provider: AbstractMetadataProvider
) -> Show | None:
@@ -823,17 +930,19 @@ class TvService:
self.tv_repository.update_episode_attributes(
episode_id=existing_episode.id,
title=fresh_episode_data.title,
overview=fresh_episode_data.overview,
)
else:
# Add new episode
log.debug(
f"Adding new episode {fresh_episode_data.number} to season {existing_season.number}"
)
episode_schema = EpisodeSchema(
episode_schema = Episode(
id=EpisodeId(fresh_episode_data.id),
number=fresh_episode_data.number,
external_id=fresh_episode_data.external_id,
title=fresh_episode_data.title,
overview=fresh_episode_data.overview,
)
self.tv_repository.add_episode_to_season(
season_id=existing_season.id, episode_data=episode_schema
@@ -844,11 +953,12 @@ class TvService:
f"Adding new season {fresh_season_data.number} to show {db_show.name}"
)
episodes_for_schema = [
EpisodeSchema(
Episode(
id=EpisodeId(ep_data.id),
number=ep_data.number,
external_id=ep_data.external_id,
title=ep_data.title,
overview=ep_data.overview,
)
for ep_data in fresh_season_data.episodes
]
@@ -911,21 +1021,22 @@ class TvService:
directory=new_source_path
)
for season in tv_show.seasons:
success, imported_episode_count = self.import_season(
_success, imported_episodes = self.import_season(
show=tv_show,
season=season,
video_files=video_files,
subtitle_files=subtitle_files,
file_path_suffix="IMPORTED",
)
season_file = SeasonFile(
season_id=season.id,
quality=Quality.unknown,
file_path_suffix="IMPORTED",
torrent_id=None,
)
if success or imported_episode_count > (len(season.episodes) / 2):
self.tv_repository.add_season_file(season_file=season_file)
for episode in imported_episodes:
episode_file = EpisodeFile(
episode_id=episode.id,
quality=Quality.unknown,
file_path_suffix="IMPORTED",
torrent_id=None,
)
self.tv_repository.add_episode_file(episode_file=episode_file)
def get_importable_tv_shows(
self, metadata_provider: AbstractMetadataProvider
@@ -960,49 +1071,6 @@ class TvService:
return import_suggestions
def auto_download_all_approved_season_requests() -> None:
"""
Auto download all approved season requests.
This is a standalone function as it creates its own DB session.
"""
with next(get_session()) as db:
tv_repository = TvRepository(db=db)
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
notification_service = NotificationService(
notification_repository=NotificationRepository(db=db)
)
tv_service = TvService(
tv_repository=tv_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
notification_service=notification_service,
)
log.info("Auto downloading all approved season requests")
season_requests = tv_repository.get_season_requests()
log.info(f"Found {len(season_requests)} season requests to process")
count = 0
for season_request in season_requests:
if season_request.authorized:
log.info(f"Processing season request {season_request.id} for download")
show = tv_repository.get_show_by_season_id(
season_id=season_request.season_id
)
if tv_service.download_approved_season_request(
season_request=season_request, show=show
):
count += 1
else:
log.warning(
f"Failed to download season request {season_request.id} for show {show.name}"
)
log.info(f"Auto downloaded {count} approved season requests")
db.commit()
def import_all_show_torrents() -> None:
with next(get_session()) as db:
tv_repository = TvRepository(db=db)
@@ -1029,9 +1097,12 @@ def import_all_show_torrents() -> None:
f"torrent {t.title} is not a tv torrent, skipping import."
)
continue
tv_service.import_torrent_files(torrent=t, show=show)
except RuntimeError:
log.exception(f"Error importing torrent {t.title} for show {show.name}")
tv_service.import_episode_files_from_torrent(torrent=t, show=show)
except RuntimeError as e:
log.error(
f"Error importing torrent {t.title} for show {show.name}: {e}",
exc_info=True,
)
log.info("Finished importing all torrents")
db.commit()
@@ -1077,30 +1148,8 @@ def update_all_non_ended_shows_metadata() -> None:
db_show=show, metadata_provider=metadata_provider
)
# Automatically add season requests for new seasons
existing_seasons = [x.id for x in show.seasons]
new_seasons = [
x for x in updated_show.seasons if x.id not in existing_seasons
]
if show.continuous_download:
for new_season in new_seasons:
log.info(
f"Automatically adding season request for new season {new_season.number} of show {updated_show.name}"
)
tv_service.add_season_request(
SeasonRequest(
min_quality=Quality.sd,
wanted_quality=Quality.uhd,
season_id=new_season.id,
authorized=True,
)
)
if updated_show:
log.debug(
f"Added new seasons: {len(new_seasons)} to show: {updated_show.name}"
)
log.debug("Updated show metadata", extra={"show": updated_show.name})
else:
log.warning(f"Failed to update metadata for show: {show.name}")
db.commit()

18
metadata_relay/uv.lock generated
View File

@@ -547,11 +547,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.21"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
@@ -791,24 +791,24 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.6.2"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uvicorn"
version = "0.40.0"
version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]
[package.optional-dependencies]

View File

@@ -2,7 +2,7 @@ site_name: "MediaManager Documentation"
theme:
name: "material"
logo: "assets/logo.svg"
favicon: "assets/logo.svg"
favicon: "assets/favicon.ico"
features:
- navigation.sections
- navigation.expand
@@ -68,3 +68,5 @@ nav:
extra:
version:
provider: mike
extra_css:
- custom.css

250
uv.lock generated
View File

@@ -8,16 +8,16 @@ resolution-markers = [
[[package]]
name = "alembic"
version = "1.17.2"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" },
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
@@ -201,11 +201,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/ea/fe/67e991cb1df3e9c94
[[package]]
name = "cachetools"
version = "6.2.4"
version = "7.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" },
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
]
[[package]]
@@ -326,58 +326,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.3"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
]
[[package]]
@@ -404,17 +401,18 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.128.0"
version = "0.129.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
]
[package.optional-dependencies]
@@ -491,7 +489,7 @@ all = [
[[package]]
name = "fastapi-users"
version = "15.0.3"
version = "15.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "email-validator" },
@@ -501,9 +499,9 @@ dependencies = [
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/35/7272d1c6c81828a1f1a4dca2d1731e8a428ae52c36404d78f7602d5fa044/fastapi_users-15.0.3.tar.gz", hash = "sha256:94b24f8889b51ca3d8da92a88bced2bca2764cb1dd21c7d6d838890ff57b6472", size = 121336, upload-time = "2025-12-19T09:41:09.488Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/52/fadeae2c8435fb457a9cd91e402639fa5c9a25b16e6d204e043bf00cd875/fastapi_users-15.0.4.tar.gz", hash = "sha256:62657a4323de929cd98697b0fbdea77773ef271a6b57ef359080b9f773ebe144", size = 121394, upload-time = "2026-02-05T09:36:41.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/a0/81b33000d59eb265b88074c6a15c1fda6f9120581878c368a94e87638d96/fastapi_users-15.0.3-py3-none-any.whl", hash = "sha256:cea3da00ba1bfdd04ce61dcb4515a0914f19d9609d3ba68cf54367c876f380c3", size = 39031, upload-time = "2025-12-19T09:41:10.34Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/5fb2a18227ccbd5138515f21fc4fa8abcd9982238de43511d7f941e708db/fastapi_users-15.0.4-py3-none-any.whl", hash = "sha256:30940894825e1dd7b86f6013e4bc75eccc25ae8ce5261d1b180f6411bb28aff4", size = 39037, upload-time = "2026-02-05T09:36:42.195Z" },
]
[package.optional-dependencies]
@@ -983,60 +981,60 @@ wheels = [
[[package]]
name = "pillow"
version = "12.1.0"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]]
@@ -1250,11 +1248,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.10.1"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[package.optional-dependencies]
@@ -1298,11 +1296,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.21"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
@@ -1626,14 +1624,14 @@ asyncio = [
[[package]]
name = "starlette"
version = "0.50.0"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
@@ -1800,24 +1798,24 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.6.2"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uvicorn"
version = "0.40.0"
version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]
[package.optional-dependencies]

1796
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,18 +21,18 @@
"@fontsource/fira-mono": "^5.0.0",
"@lucide/svelte": "^0.482.0",
"@neoconfetti/svelte": "^2.0.0",
"@sinclair/typebox": "^0.34.38",
"@sinclair/typebox": "^0.34.48",
"@sveltejs/adapter-auto": "^6.0.2",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.1",
"@sveltejs/kit": "^2.27.3",
"@sveltejs/kit": "^2.51.0",
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/table-core": "^8.21.3",
"@typeschema/class-validator": "^0.2.0",
"@typeschema/class-validator": "^0.3.0",
"@vinejs/vine": "^1.8.0",
"arktype": "^2.1.20",
"autoprefixer": "^10.4.20",
@@ -50,13 +50,13 @@
"openapi-typescript": "^7.9.1",
"paneforge": "^1.0.0-next.6",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.1",
"superstruct": "^2.0.2",
"svelte": "^5.38.0",
"svelte": "^5.53.0",
"svelte-check": "^4.3.1",
"svelte-sonner": "^1.0.7",
"sveltekit-superforms": "^2.27.1",
"sveltekit-superforms": "^2.29.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^0.2.1",
"tailwindcss": "^4.1.11",
@@ -65,13 +65,13 @@
"typescript-eslint": "^8.39.1",
"valibot": "^0.42.1",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.1.1",
"vite": "^7.3.1",
"yup": "^1.7.0",
"zod": "^3.25.76"
},
"dependencies": {
"animejs": "^4.2.2",
"lucide-svelte": "^0.544.0",
"lucide-svelte": "^0.574.0",
"openapi-fetch": "^0.14.0",
"uuid": "^11.1.0"
}

View File

@@ -530,74 +530,6 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/tv/seasons/requests': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Season Requests
* @description Get all season requests.
*/
get: operations['get_season_requests_api_v1_tv_seasons_requests_get'];
/**
* Update Request
* @description Update an existing season request.
*/
put: operations['update_request_api_v1_tv_seasons_requests_put'];
/**
* Request A Season
* @description Create a new season request.
*/
post: operations['request_a_season_api_v1_tv_seasons_requests_post'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/tv/seasons/requests/{season_request_id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Authorize Request
* @description Authorize or de-authorize a season request.
*/
patch: operations['authorize_request_api_v1_tv_seasons_requests__season_request_id__patch'];
trace?: never;
};
'/api/v1/tv/seasons/requests/{request_id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Delete Season Request
* @description Delete a season request.
*/
delete: operations['delete_season_request_api_v1_tv_seasons_requests__request_id__delete'];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/tv/seasons/{season_id}': {
parameters: {
query?: never;
@@ -626,10 +558,10 @@ export interface paths {
cookie?: never;
};
/**
* Get Season Files
* @description Get files associated with a specific season.
* Get Episode Files
* @description Get episode files associated with a specific season.
*/
get: operations['get_season_files_api_v1_tv_seasons__season_id__files_get'];
get: operations['get_episode_files_api_v1_tv_seasons__season_id__files_get'];
put?: never;
post?: never;
delete?: never;
@@ -896,58 +828,6 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/movies/requests': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get All Movie Requests
* @description Get all movie requests.
*/
get: operations['get_all_movie_requests_api_v1_movies_requests_get'];
put?: never;
/**
* Create Movie Request
* @description Create a new movie request.
*/
post: operations['create_movie_request_api_v1_movies_requests_post'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/movies/requests/{movie_request_id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Update Movie Request
* @description Update an existing movie request.
*/
put: operations['update_movie_request_api_v1_movies_requests__movie_request_id__put'];
post?: never;
/**
* Delete Movie Request
* @description Delete a movie request.
*/
delete: operations['delete_movie_request_api_v1_movies_requests__movie_request_id__delete'];
options?: never;
head?: never;
/**
* Authorize Request
* @description Authorize or de-authorize a movie request.
*/
patch: operations['authorize_request_api_v1_movies_requests__movie_request_id__patch'];
trace?: never;
};
'/api/v1/movies/{movie_id}': {
parameters: {
query?: never;
@@ -1283,26 +1163,6 @@ export interface components {
/** Token */
token: string;
};
/** CreateMovieRequest */
CreateMovieRequest: {
min_quality: components['schemas']['Quality'];
wanted_quality: components['schemas']['Quality'];
/**
* Movie Id
* Format: uuid
*/
movie_id: string;
};
/** CreateSeasonRequest */
CreateSeasonRequest: {
min_quality: components['schemas']['Quality'];
wanted_quality: components['schemas']['Quality'];
/**
* Season Id
* Format: uuid
*/
season_id: string;
};
/** Episode */
Episode: {
/**
@@ -1316,6 +1176,8 @@ export interface components {
external_id: number;
/** Title */
title: string;
/** Overview */
overview?: string | null;
};
/** ErrorModel */
ErrorModel: {
@@ -1360,6 +1222,8 @@ export interface components {
readonly quality: components['schemas']['Quality'];
/** Season */
readonly season: number[];
/** Episode */
readonly episode: number[];
};
/** LibraryItem */
LibraryItem: {
@@ -1428,33 +1292,6 @@ export interface components {
/** Imdb Id */
imdb_id?: string | null;
};
/** MovieRequest */
MovieRequest: {
min_quality: components['schemas']['Quality'];
wanted_quality: components['schemas']['Quality'];
/**
* Id
* Format: uuid
*/
id?: string;
/**
* Movie Id
* Format: uuid
*/
movie_id: string;
requested_by?: components['schemas']['UserRead'] | null;
/**
* Authorized
* @default false
*/
authorized: boolean;
authorized_by?: components['schemas']['UserRead'] | null;
};
/** MovieRequestBase */
MovieRequestBase: {
min_quality: components['schemas']['Quality'];
wanted_quality: components['schemas']['Quality'];
};
/** MovieTorrent */
MovieTorrent: {
/**
@@ -1504,6 +1341,45 @@ export interface components {
/** Authorization Url */
authorization_url: string;
};
/** PublicEpisode */
PublicEpisode: {
/**
* Id
* Format: uuid
*/
id: string;
/** Number */
number: number;
/**
* Downloaded
* @default false
*/
downloaded: boolean;
/** Title */
title: string;
/** Overview */
overview?: string | null;
/** External Id */
external_id: number;
};
/** PublicEpisodeFile */
PublicEpisodeFile: {
/**
* Episode Id
* Format: uuid
*/
episode_id: string;
quality: components['schemas']['Quality'];
/** Torrent Id */
torrent_id: string | null;
/** File Path Suffix */
file_path_suffix: string;
/**
* Downloaded
* @default false
*/
downloaded: boolean;
};
/** PublicMovie */
PublicMovie: {
/**
@@ -1580,25 +1456,7 @@ export interface components {
/** External Id */
external_id: number;
/** Episodes */
episodes: components['schemas']['Episode'][];
};
/** PublicSeasonFile */
PublicSeasonFile: {
/**
* Season Id
* Format: uuid
*/
season_id: string;
quality: components['schemas']['Quality'];
/** Torrent Id */
torrent_id: string | null;
/** File Path Suffix */
file_path_suffix: string;
/**
* Downloaded
* @default false
*/
downloaded: boolean;
episodes: components['schemas']['PublicEpisode'][];
};
/** PublicShow */
PublicShow: {
@@ -1637,29 +1495,6 @@ export interface components {
* @enum {integer}
*/
Quality: 1 | 2 | 3 | 4 | 5;
/** RichMovieRequest */
RichMovieRequest: {
min_quality: components['schemas']['Quality'];
wanted_quality: components['schemas']['Quality'];
/**
* Id
* Format: uuid
*/
id?: string;
/**
* Movie Id
* Format: uuid
*/
movie_id: string;
requested_by?: components['schemas']['UserRead'] | null;
/**
* Authorized
* @default false
*/
authorized: boolean;
authorized_by?: components['schemas']['UserRead'] | null;
movie: components['schemas']['Movie'];
};
/** RichMovieTorrent */
RichMovieTorrent: {
/**
@@ -1676,30 +1511,6 @@ export interface components {
/** Torrents */
torrents: components['schemas']['MovieTorrent'][];
};
/** RichSeasonRequest */
RichSeasonRequest: {
min_quality: components['schemas']['Quality'];
wanted_quality: components['schemas']['Quality'];
/**
* Id
* Format: uuid
*/
id?: string;
/**
* Season Id
* Format: uuid
*/
season_id: string;
requested_by?: components['schemas']['UserRead'] | null;
/**
* Authorized
* @default false
*/
authorized: boolean;
authorized_by?: components['schemas']['UserRead'] | null;
show: components['schemas']['Show'];
season: components['schemas']['Season'];
};
/** RichSeasonTorrent */
RichSeasonTorrent: {
/**
@@ -1719,6 +1530,8 @@ export interface components {
file_path_suffix: string;
/** Seasons */
seasons: number[];
/** Episodes */
episodes: number[];
};
/** RichShowTorrent */
RichShowTorrent: {
@@ -1819,16 +1632,6 @@ export interface components {
* @enum {integer}
*/
TorrentStatus: 1 | 2 | 3 | 4;
/** UpdateSeasonRequest */
UpdateSeasonRequest: {
min_quality: components['schemas']['Quality'];
wanted_quality: components['schemas']['Quality'];
/**
* Id
* Format: uuid
*/
id: string;
};
/** UserCreate */
UserCreate: {
/**
@@ -1903,6 +1706,10 @@ export interface components {
msg: string;
/** Error Type */
type: string;
/** Input */
input?: unknown;
/** Context */
ctx?: Record<string, never>;
};
};
responses: never;
@@ -3058,148 +2865,6 @@ export interface operations {
};
};
};
get_season_requests_api_v1_tv_seasons_requests_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['RichSeasonRequest'][];
};
};
};
};
update_request_api_v1_tv_seasons_requests_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
'application/json': components['schemas']['UpdateSeasonRequest'];
};
};
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
request_a_season_api_v1_tv_seasons_requests_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
'application/json': components['schemas']['CreateSeasonRequest'];
};
};
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
authorize_request_api_v1_tv_seasons_requests__season_request_id__patch: {
parameters: {
query?: {
authorized_status?: boolean;
};
header?: never;
path: {
season_request_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
delete_season_request_api_v1_tv_seasons_requests__request_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
request_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
get_season_api_v1_tv_seasons__season_id__get: {
parameters: {
query?: never;
@@ -3232,7 +2897,7 @@ export interface operations {
};
};
};
get_season_files_api_v1_tv_seasons__season_id__files_get: {
get_episode_files_api_v1_tv_seasons__season_id__files_get: {
parameters: {
query?: never;
header?: never;
@@ -3250,7 +2915,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['PublicSeasonFile'][];
'application/json': components['schemas']['PublicEpisodeFile'][];
};
};
/** @description Validation Error */
@@ -3714,154 +3379,6 @@ export interface operations {
};
};
};
get_all_movie_requests_api_v1_movies_requests_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['RichMovieRequest'][];
};
};
};
};
create_movie_request_api_v1_movies_requests_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
'application/json': components['schemas']['CreateMovieRequest'];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['MovieRequest'];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
update_movie_request_api_v1_movies_requests__movie_request_id__put: {
parameters: {
query?: never;
header?: never;
path: {
movie_request_id: string;
};
cookie?: never;
};
requestBody: {
content: {
'application/json': components['schemas']['MovieRequestBase'];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['MovieRequest'];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
delete_movie_request_api_v1_movies_requests__movie_request_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
movie_request_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
authorize_request_api_v1_movies_requests__movie_request_id__patch: {
parameters: {
query?: {
authorized_status?: boolean;
};
header?: never;
path: {
movie_request_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
get_movie_by_id_api_v1_movies__movie_id__get: {
parameters: {
query?: never;

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
import { formatSecondsToOptimalUnit } from '$lib/utils.ts';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
import { invalidateAll } from '$app/navigation';
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
import { getFullyQualifiedMediaName } from '$lib/utils';
let { show }: { show: components['schemas']['Show'] } = $props();
let dialogueState = $state(false);
let torrentsError: string | null = $state(null);
let queryOverride: string = $state('');
let filePathSuffix: string = $state('');
let torrentsPromise: any = $state();
let torrentsData: any[] | null = $state(null);
let isLoading: boolean = $state(false);
const tableColumnHeadings = [
{ name: 'Size', id: 'size' },
{ name: 'Usenet', id: 'usenet' },
{ name: 'Seeders', id: 'seeders' },
{ name: 'Age', id: 'age' },
{ name: 'Score', id: 'score' },
{ name: 'Indexer', id: 'indexer' },
{ name: 'Indexer Flags', id: 'flags' },
{ name: 'Seasons', id: 'season' }
];
async function downloadTorrent(result_id: string) {
torrentsError = null;
const { response } = await client.POST('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
public_indexer_result_id: result_id,
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
}
}
});
if (response.status === 409) {
const errorMessage = `There already is a File using the Filepath Suffix '${filePathSuffix}'. Try again with a different Filepath Suffix.`;
console.warn(errorMessage);
torrentsError = errorMessage;
if (dialogueState) toast.info(errorMessage);
} else if (!response.ok) {
const errorMessage = `Failed to download torrent for show ${show.id}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
toast.error(errorMessage);
} else {
toast.success('Torrent download started successfully!');
}
await invalidateAll();
}
async function search() {
if (!queryOverride || queryOverride.trim() === '') {
toast.error('Please enter a custom query.');
return;
}
isLoading = true;
torrentsError = null;
torrentsData = null;
torrentsPromise = client
.GET('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
search_query_override: queryOverride
}
}
})
.then((data) => data?.data)
.finally(() => (isLoading = false));
toast.info('Searching for torrents...');
torrentsData = await torrentsPromise;
if (!torrentsData || torrentsData.length === 0) {
toast.info('No torrents found.');
} else {
toast.success(`Found ${torrentsData.length} torrents.`);
}
}
</script>
<DownloadDialogWrapper
bind:open={dialogueState}
triggerText="Custom Download"
title="Custom Torrent Download"
description="Search and download torrents using a fully custom query string."
>
<div class="grid w-full items-center gap-1.5">
<Label for="query-override">Enter a custom query</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input
bind:value={queryOverride}
id="query-override"
type="text"
placeholder={`e.g. ${getFullyQualifiedMediaName(show)} S01 1080p BluRay`}
/>
<Button disabled={isLoading} class="w-fit" onclick={search}>Search</Button>
</div>
<p class="text-sm text-muted-foreground">
The custom query completely overrides the default search logic. Make sure the torrent title
matches the episodes you want imported.
</p>
</div>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">
An error occurred: {torrentsError}
</div>
{/if}
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
{#snippet rowSnippet(torrent)}
<Table.Cell class="font-medium">{torrent.title}</Table.Cell>
<Table.Cell>{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB</Table.Cell>
<Table.Cell>{torrent.usenet}</Table.Cell>
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
<Table.Cell>
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
</Table.Cell>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
<Table.Cell>
{#if torrent.flags}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
{/if}
</Table.Cell>
<Table.Cell>
{torrent.season ?? '-'}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
bind:filePathSuffix
media={show}
callback={() => downloadTorrent(torrent.id)}
/>
</Table.Cell>
{/snippet}
</TorrentTable>
</DownloadDialogWrapper>

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { formatSecondsToOptimalUnit } from '$lib/utils';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
import { invalidateAll } from '$app/navigation';
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
let {
show,
selectedEpisodeNumbers,
triggerText = 'Download Episodes'
}: {
show: components['schemas']['Show'];
selectedEpisodeNumbers: { seasonNumber: number; episodeNumber: number }[];
triggerText?: string;
} = $props();
let dialogueState = $state(false);
let torrentsPromise: any = $state();
let torrentsError: string | null = $state(null);
let isLoading: boolean = $state(false);
let filePathSuffix: string = $state('');
const tableColumnHeadings = [
{ name: 'Size', id: 'size' },
{ name: 'Usenet', id: 'usenet' },
{ name: 'Seeders', id: 'seeders' },
{ name: 'Age', id: 'age' },
{ name: 'Score', id: 'score' },
{ name: 'Indexer', id: 'indexer' },
{ name: 'Indexer Flags', id: 'flags' }
];
function torrentMatchesSelectedEpisodes(
torrentTitle: string,
selectedEpisodes: { seasonNumber: number; episodeNumber: number }[]
) {
const normalizedTitle = torrentTitle.toLowerCase();
return selectedEpisodes.some((ep) => {
const s = String(ep.seasonNumber).padStart(2, '0');
const e = String(ep.episodeNumber).padStart(2, '0');
const patterns = [
`s${s}e${e}`,
`${s}x${e}`,
`season ${ep.seasonNumber} episode ${ep.episodeNumber}`
];
return patterns.some((pattern) => normalizedTitle.includes(pattern));
});
}
async function search() {
if (!selectedEpisodeNumbers || selectedEpisodeNumbers.length === 0) {
toast.error('No episodes selected.');
return;
}
isLoading = true;
torrentsError = null;
torrentsPromise = Promise.all(
selectedEpisodeNumbers.map((ep) =>
client
.GET('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
season_number: ep.seasonNumber,
episode_number: ep.episodeNumber
}
}
})
.then((r) => r?.data ?? [])
)
)
.then((results) => results.flat())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then((allTorrents: any[]) =>
allTorrents.filter((torrent) =>
torrentMatchesSelectedEpisodes(torrent.title, selectedEpisodeNumbers)
)
)
.finally(() => (isLoading = false));
try {
await torrentsPromise;
} catch (error: any) {
console.error(error);
torrentsError = error.message || 'An error occurred while searching for torrents.';
toast.error(torrentsError);
}
}
async function downloadTorrent(result_id: string) {
const { response } = await client.POST('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
public_indexer_result_id: result_id,
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
}
}
});
if (!response.ok) {
toast.error('Download failed.');
} else {
toast.success('Download started.');
}
await invalidateAll();
}
</script>
<DownloadDialogWrapper
bind:open={dialogueState}
{triggerText}
title="Download Selected Episodes"
description="Search and download torrents for selected episodes."
>
<div class="flex flex-col gap-3">
<p class="text-sm text-muted-foreground">
Selected episodes:
<strong>
{selectedEpisodeNumbers.length > 0
? selectedEpisodeNumbers
.map(
(e) =>
`S${String(e.seasonNumber).padStart(2, '0')}E${String(e.episodeNumber).padStart(2, '0')}`
)
.join(', ')
: 'None'}
</strong>
</p>
<Button
class="w-fit"
disabled={isLoading || selectedEpisodeNumbers.length === 0}
onclick={search}
>
Search Torrents
</Button>
</div>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">
An error occurred: {torrentsError}
</div>
{/if}
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
{#snippet rowSnippet(torrent)}
<Table.Cell>{torrent.title}</Table.Cell>
<Table.Cell>
{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
</Table.Cell>
<Table.Cell>{torrent.usenet}</Table.Cell>
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
<Table.Cell>
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
</Table.Cell>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
<Table.Cell>
{#if torrent.flags}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
bind:filePathSuffix
media={show}
callback={() => downloadTorrent(torrent.id)}
/>
</Table.Cell>
{/snippet}
</TorrentTable>
</DownloadDialogWrapper>

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { formatSecondsToOptimalUnit } from '$lib/utils.ts';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
import { invalidateAll } from '$app/navigation';
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
let {
show,
selectedSeasonNumbers,
triggerText = 'Download Selected Seasons'
}: {
show: components['schemas']['Show'];
selectedSeasonNumbers: number[];
triggerText?: string;
} = $props();
let dialogueState = $state(false);
let torrentsError: string | null = $state(null);
let filePathSuffix: string = $state('');
let torrentsPromise: any = $state();
let isLoading: boolean = $state(false);
const tableColumnHeadings = [
{ name: 'Size', id: 'size' },
{ name: 'Usenet', id: 'usenet' },
{ name: 'Seeders', id: 'seeders' },
{ name: 'Age', id: 'age' },
{ name: 'Score', id: 'score' },
{ name: 'Indexer', id: 'indexer' },
{ name: 'Indexer Flags', id: 'flags' },
{ name: 'Seasons', id: 'season' }
];
async function downloadTorrent(result_id: string) {
torrentsError = null;
const { response } = await client.POST('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
public_indexer_result_id: result_id,
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
}
}
});
if (response.status === 409) {
const errorMessage = `Filepath Suffix '${filePathSuffix}' already exists.`;
torrentsError = errorMessage;
toast.error(errorMessage);
} else if (!response.ok) {
const errorMessage = `Failed to download torrent: ${response.statusText}`;
torrentsError = errorMessage;
toast.error(errorMessage);
} else {
toast.success('Torrent download started successfully!');
}
await invalidateAll();
}
function isEpisodeRelease(title: string) {
const lower = title.toLowerCase();
const episodePatterns = [
/s\d{1,2}e\d{1,2}/i,
/\d{1,2}x\d{1,2}/i,
/\be\d{1,2}\b/i,
/e\d{1,2}-e?\d{1,2}/i,
/vol\.?\s?\d+/i
];
return episodePatterns.some((regex) => regex.test(lower));
}
async function search() {
if (!selectedSeasonNumbers || selectedSeasonNumbers.length === 0) {
toast.error('No seasons selected.');
return;
}
isLoading = true;
torrentsError = null;
toast.info(`Searching torrents for seasons: ${selectedSeasonNumbers.join(', ')}`);
torrentsPromise = Promise.all(
selectedSeasonNumbers.map((seasonNumber) =>
client
.GET('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
season_number: seasonNumber
}
}
})
.then((data) => data?.data ?? [])
)
)
.then((results) => results.flat())
.then((allTorrents) => allTorrents.filter((torrent) => !isEpisodeRelease(torrent.title)))
.finally(() => (isLoading = false));
try {
await torrentsPromise;
} catch (error: any) {
console.error(error);
torrentsError = error.message || 'An error occurred while searching for torrents.';
toast.error(torrentsError);
}
}
</script>
<DownloadDialogWrapper
bind:open={dialogueState}
{triggerText}
title="Download Selected Seasons"
description="Search and download torrents for the selected seasons."
>
<div class="flex flex-col gap-3">
<p class="text-sm text-muted-foreground">
Selected seasons:
<strong>
{selectedSeasonNumbers.length > 0
? selectedSeasonNumbers
.slice()
.sort((a, b) => a - b)
.map((n) => `S${String(n).padStart(2, '0')}`)
.join(', ')
: 'None'}
</strong>
</p>
<Button
class="w-fit"
disabled={isLoading || selectedSeasonNumbers.length === 0}
onclick={search}
>
Search Torrents
</Button>
</div>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">
An error occurred: {torrentsError}
</div>
{/if}
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
{#snippet rowSnippet(torrent)}
<Table.Cell class="font-medium">{torrent.title}</Table.Cell>
<Table.Cell>
{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
</Table.Cell>
<Table.Cell>{torrent.usenet}</Table.Cell>
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
<Table.Cell>
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
</Table.Cell>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
<Table.Cell>
{#if torrent.flags}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
{/if}
</Table.Cell>
<Table.Cell>
{torrent.season ?? '-'}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
bind:filePathSuffix
media={show}
callback={() => downloadTorrent(torrent.id)}
/>
</Table.Cell>
{/snippet}
</TorrentTable>
</DownloadDialogWrapper>

View File

@@ -34,10 +34,6 @@
{
title: 'Torrents',
url: resolve('/dashboard/tv/torrents', {})
},
{
title: 'Requests',
url: resolve('/dashboard/tv/requests', {})
}
]
},
@@ -54,10 +50,6 @@
{
title: 'Torrents',
url: resolve('/dashboard/movies/torrents', {})
},
{
title: 'Requests',
url: resolve('/dashboard/movies/requests', {})
}
]
}

View File

@@ -1,117 +0,0 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import { invalidateAll } from '$app/navigation';
let { movie }: { movie: components['schemas']['PublicMovie'] } = $props();
let dialogOpen = $state(false);
let minQuality = $state<string | undefined>(undefined);
let wantedQuality = $state<string | undefined>(undefined);
let isSubmittingRequest = $state(false);
let submitRequestError = $state<string | null>(null);
const qualityValues: components['schemas']['Quality'][] = [1, 2, 3, 4];
let qualityOptions = $derived(
qualityValues.map((q) => ({ value: q.toString(), label: getTorrentQualityString(q) }))
);
let isFormInvalid = $derived(
!minQuality || !wantedQuality || parseInt(wantedQuality) > parseInt(minQuality)
);
async function handleRequestMovie() {
isSubmittingRequest = true;
submitRequestError = null;
const { response } = await client.POST('/api/v1/movies/requests', {
body: {
movie_id: movie.id!,
min_quality: parseInt(minQuality!) as components['schemas']['Quality'],
wanted_quality: parseInt(wantedQuality!) as components['schemas']['Quality']
}
});
isSubmittingRequest = false;
if (response.ok) {
dialogOpen = false;
minQuality = undefined;
wantedQuality = undefined;
toast.success('Movie request submitted successfully!');
} else {
toast.error('Failed to submit request');
}
await invalidateAll();
}
</script>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger
class={buttonVariants({ variant: 'default' })}
onclick={() => {
dialogOpen = true;
}}
>
Request Movie
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[clamp(300px,50vw,600px)] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Request {getFullyQualifiedMediaName(movie)}</Dialog.Title>
<Dialog.Description>Select desired qualities to submit a request.</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<!-- Min Quality Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="min-quality">Min Quality</Label>
<Select.Root bind:value={minQuality} type="single">
<Select.Trigger class="w-full" id="min-quality">
{minQuality ? getTorrentQualityString(parseInt(minQuality)) : 'Select Minimum Quality'}
</Select.Trigger>
<Select.Content>
{#each qualityOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Wanted Quality Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="wanted-quality">Wanted Quality</Label>
<Select.Root bind:value={wantedQuality} type="single">
<Select.Trigger class="w-full" id="wanted-quality">
{wantedQuality
? getTorrentQualityString(parseInt(wantedQuality))
: 'Select Wanted Quality'}
</Select.Trigger>
<Select.Content>
{#each qualityOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if submitRequestError}
<p class="col-span-full text-center text-sm text-red-500">{submitRequestError}</p>
{/if}
</div>
<Dialog.Footer>
<Button disabled={isSubmittingRequest} onclick={() => (dialogOpen = false)} variant="outline"
>Cancel
</Button>
<Button disabled={isFormInvalid || isSubmittingRequest} onclick={handleRequestMovie}>
{#if isSubmittingRequest}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Submitting...
{:else}
Submit Request
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,155 +0,0 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
import client from '$lib/api';
import type { components } from '$lib/api/api';
let { show }: { show: components['schemas']['PublicShow'] } = $props();
let dialogOpen = $state(false);
let selectedSeasonsIds = $state<string[]>([]);
let minQuality = $state<string | undefined>(undefined);
let wantedQuality = $state<string | undefined>(undefined);
let isSubmittingRequest = $state(false);
let submitRequestError = $state<string | null>(null);
const qualityValues: components['schemas']['Quality'][] = [1, 2, 3, 4];
let qualityOptions = $derived(
qualityValues.map((q) => ({ value: q.toString(), label: getTorrentQualityString(q) }))
);
let isFormInvalid = $derived(
!selectedSeasonsIds ||
selectedSeasonsIds.length === 0 ||
!minQuality ||
!wantedQuality ||
parseInt(wantedQuality) > parseInt(minQuality)
);
async function handleRequestSeason() {
isSubmittingRequest = true;
submitRequestError = null;
for (const id of selectedSeasonsIds) {
const { response, error } = await client.POST('/api/v1/tv/seasons/requests', {
body: {
season_id: id,
min_quality: parseInt(minQuality!) as components['schemas']['Quality'],
wanted_quality: parseInt(wantedQuality!) as components['schemas']['Quality']
}
});
if (!response.ok) {
toast.error('Failed to submit request: ' + error);
submitRequestError = `Failed to submit request for season ID ${id}: ${error}`;
}
}
if (!submitRequestError) {
dialogOpen = false;
// Reset form fields
selectedSeasonsIds = [];
minQuality = undefined;
wantedQuality = undefined;
toast.success('Season request(s) submitted successfully!');
}
isSubmittingRequest = false;
}
</script>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger
class={buttonVariants({ variant: 'default' })}
onclick={() => {
dialogOpen = true;
}}
>
Request Season
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[clamp(300px,50vw,600px)] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Request a Season for {getFullyQualifiedMediaName(show)}</Dialog.Title>
<Dialog.Description>
Select a season and desired qualities to submit a request.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<!-- Season Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="season">Season</Label>
<Select.Root bind:value={selectedSeasonsIds} type="multiple">
<Select.Trigger class="w-full" id="season">
{#each selectedSeasonsIds as seasonId (seasonId)}
{#if show.seasons.find((season) => season.id === seasonId)}
Season {show.seasons.find((season) => season.id === seasonId)?.number},&nbsp;
{/if}
{:else}
Select one or more seasons
{/each}
</Select.Trigger>
<Select.Content>
{#each show.seasons as season (season.id)}
<Select.Item value={season.id || ''}>
Season {season.number}{season.name ? `: ${season.name}` : ''}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Min Quality Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="min-quality">Min Quality</Label>
<Select.Root bind:value={minQuality} type="single">
<Select.Trigger class="w-full" id="min-quality">
{minQuality ? getTorrentQualityString(parseInt(minQuality)) : 'Select Minimum Quality'}
</Select.Trigger>
<Select.Content>
{#each qualityOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Wanted Quality Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="wanted-quality">Wanted Quality</Label>
<Select.Root bind:value={wantedQuality} type="single">
<Select.Trigger class="w-full" id="wanted-quality">
{wantedQuality
? getTorrentQualityString(parseInt(wantedQuality))
: 'Select Wanted Quality'}
</Select.Trigger>
<Select.Content>
{#each qualityOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if submitRequestError}
<p class="col-span-full text-center text-sm text-red-500">{submitRequestError}</p>
{/if}
</div>
<Dialog.Footer>
<Button disabled={isSubmittingRequest} onclick={() => (dialogOpen = false)} variant="outline"
>Cancel
</Button>
<Button disabled={isFormInvalid || isSubmittingRequest} onclick={handleRequestSeason}>
{#if isSubmittingRequest}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Submitting...
{:else}
Submit Request
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,227 +0,0 @@
<script lang="ts">
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import type { components } from '$lib/api/api';
import * as Table from '$lib/components/ui/table';
import { getContext } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { goto, invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import client from '$lib/api';
let {
requests,
filter = () => true,
isShow = true
}: {
requests: (
| components['schemas']['RichSeasonRequest']
| components['schemas']['RichMovieRequest']
)[];
filter?: (
request:
| components['schemas']['RichSeasonRequest']
| components['schemas']['RichMovieRequest']
) => boolean;
isShow: boolean;
} = $props();
const user: () => components['schemas']['UserRead'] = getContext('user');
async function approveRequest(requestId: string, currentAuthorizedStatus: boolean) {
let response;
if (isShow) {
const data = await client.PATCH('/api/v1/tv/seasons/requests/{season_request_id}', {
params: {
path: {
season_request_id: requestId
},
query: {
authorized_status: !currentAuthorizedStatus
}
}
});
response = data.response;
} else {
const data = await client.PATCH('/api/v1/movies/requests/{movie_request_id}', {
params: {
path: {
movie_request_id: requestId
},
query: {
authorized_status: !currentAuthorizedStatus
}
}
});
response = data.response;
}
if (response.ok) {
toast.success(
`Request ${!currentAuthorizedStatus ? 'approved' : 'unapproved'} successfully.`
);
} else {
const errorText = await response.text();
console.error(`Failed to update request status ${response.statusText}`, errorText);
toast.error(`Failed to update request status: ${response.statusText}`);
}
await invalidateAll();
}
async function deleteRequest(requestId: string) {
if (
!window.confirm(
'Are you sure you want to delete this season request? This action cannot be undone.'
)
) {
return;
}
let response;
if (isShow) {
const data = await client.DELETE('/api/v1/tv/seasons/requests/{request_id}', {
params: {
path: {
request_id: requestId
}
}
});
response = data.response;
} else {
const data = await client.DELETE('/api/v1/movies/requests/{movie_request_id}', {
params: {
path: {
movie_request_id: requestId
}
}
});
response = data.response;
}
if (response.ok) {
toast.success('Request deleted successfully');
} else {
console.error(`Failed to delete request ${response.statusText}`, await response.text());
toast.error('Failed to delete request');
}
await invalidateAll();
}
</script>
<Table.Root>
<Table.Caption>A list of all requests.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>{isShow ? 'Show' : 'Movie'}</Table.Head>
{#if isShow}
<Table.Head>Season</Table.Head>
{/if}
<Table.Head>Minimum Quality</Table.Head>
<Table.Head>Wanted Quality</Table.Head>
<Table.Head>Requested by</Table.Head>
<Table.Head>Approved</Table.Head>
<Table.Head>Approved by</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each requests as request (request.id)}
{#if filter(request)}
<Table.Row>
<Table.Cell>
{#if isShow}
<a
href={resolve('/dashboard/tv/[showId]', {
showId: (request as components['schemas']['RichSeasonRequest']).show.id!
})}
class="text-primary hover:underline"
>
{getFullyQualifiedMediaName(
(request as components['schemas']['RichSeasonRequest']).show
)}
</a>
{:else}
<a
href={resolve('/dashboard/movies/[movieId]', {
movieId: (request as components['schemas']['RichMovieRequest']).movie.id!
})}
class="text-primary hover:underline"
>
{getFullyQualifiedMediaName(
(request as components['schemas']['RichMovieRequest']).movie
)}
</a>
{/if}
</Table.Cell>
{#if isShow}
<Table.Cell>
{(request as components['schemas']['RichSeasonRequest']).season.number}
</Table.Cell>
{/if}
<Table.Cell>
{getTorrentQualityString(request.min_quality)}
</Table.Cell>
<Table.Cell>
{getTorrentQualityString(request.wanted_quality)}
</Table.Cell>
<Table.Cell>
{request.requested_by?.email ?? 'N/A'}
</Table.Cell>
<Table.Cell>
<CheckmarkX state={request.authorized} />
</Table.Cell>
<Table.Cell>
{request.authorized_by?.email ?? 'N/A'}
</Table.Cell>
<!-- TODO: ADD DIALOGUE TO MODIFY REQUEST -->
<Table.Cell class="flex max-w-[150px] flex-col gap-1">
{#if user().is_superuser}
<Button
class=""
size="sm"
onclick={() => approveRequest(request.id!, request.authorized)}
>
{request.authorized ? 'Unapprove' : 'Approve'}
</Button>
{#if isShow}
<Button
class=""
size="sm"
variant="outline"
onclick={() =>
goto(
resolve('/dashboard/tv/[showId]', {
showId: (request as components['schemas']['RichSeasonRequest']).show.id!
})
)}
>
Download manually
</Button>
{:else}
<Button
class=""
size="sm"
variant="outline"
onclick={() =>
goto(
resolve('/dashboard/movies/[movieId]', {
movieId: (request as components['schemas']['RichMovieRequest']).movie.id!
})
)}
>
Download manually
</Button>
{/if}
{/if}
{#if user().is_superuser || user().id === request.requested_by?.id}
<Button variant="destructive" size="sm" onclick={() => deleteRequest(request.id!)}
>Delete
</Button>
{/if}
</Table.Cell>
</Table.Row>
{/if}
{:else}
<Table.Row>
<Table.Cell colspan={8} class="text-center">There are currently no requests.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import {
convertTorrentSeasonRangeToIntegerRange,
convertTorrentEpisodeRangeToIntegerRange,
getTorrentQualityString,
getTorrentStatusString
} from '$lib/utils.js';
@@ -59,6 +60,7 @@
<Table.Head>Name</Table.Head>
{#if isShow}
<Table.Head>Seasons</Table.Head>
<Table.Head>Episodes</Table.Head>
{/if}
<Table.Head>Download Status</Table.Head>
<Table.Head>Quality</Table.Head>
@@ -97,6 +99,11 @@
(torrent as components['schemas']['RichSeasonTorrent']).seasons!
)}
</Table.Cell>
<Table.Cell>
{convertTorrentEpisodeRangeToIntegerRange(
(torrent as components['schemas']['RichSeasonTorrent']).episodes!
)}
</Table.Cell>
{/if}
<Table.Cell>
{getTorrentStatusString(torrent.status)}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -51,6 +51,17 @@ export function convertTorrentSeasonRangeToIntegerRange(seasons: number[]): stri
}
}
export function convertTorrentEpisodeRangeToIntegerRange(episodes: number[]): string {
if (episodes.length === 1) return episodes[0]?.toString() || 'unknown';
else if (episodes.length > 1) {
const lastEpisode = episodes.at(-1);
return episodes[0]?.toString() + '-' + (lastEpisode?.toString() || 'unknown');
} else {
console.log('Error parsing episode range: ' + episodes);
return 'Error parsing episode range: ' + episodes;
}
}
export async function handleLogout() {
await client.POST('/api/v1/auth/cookie/logout');
await goto(resolve('/login', {}));

View File

@@ -11,7 +11,6 @@
import TorrentTable from '$lib/components/torrents/torrent-table.svelte';
import MediaPicture from '$lib/components/media-picture.svelte';
import DownloadMovieDialog from '$lib/components/download-dialogs/download-movie-dialog.svelte';
import RequestMovieDialog from '$lib/components/requests/request-movie-dialog.svelte';
import LibraryCombobox from '$lib/components/library-combobox.svelte';
import { resolve } from '$app/paths';
import * as Card from '$lib/components/ui/card/index.js';
@@ -80,7 +79,7 @@
<Card.Title>Overview</Card.Title>
</Card.Header>
<Card.Content>
<p class="leading-7 not-first:mt-6">
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
{movie.overview}
</p>
</Card.Content>
@@ -108,7 +107,6 @@
{#if user().is_superuser}
<DownloadMovieDialog {movie} />
{/if}
<RequestMovieDialog {movie} />
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import RequestsTable from '$lib/components/requests/requests-table.svelte';
import { resolve } from '$app/paths';
let requests = $derived(page.data.requestsData);
</script>
<svelte:head>
<title>Movie Requests - MediaManager</title>
<meta content="View and manage movie download requests in MediaManager" name="description" />
</svelte:head>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator class="mr-2 h-4" orientation="vertical" />
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href={resolve('/dashboard', {})}>MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href={resolve('/dashboard', {})}>Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href={resolve('/dashboard/movies', {})}>Movies</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Page>Movie Requests</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
Movie Requests
</h1>
<RequestsTable {requests} isShow={false} />
</main>

View File

@@ -1,10 +0,0 @@
import type { PageLoad } from './$types';
import client from '$lib/api';
export const load: PageLoad = async ({ fetch }) => {
const { data } = await client.GET('/api/v1/movies/requests', { fetch: fetch });
return {
requestsData: data
};
};

View File

@@ -4,15 +4,17 @@
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import { goto } from '$app/navigation';
import { ImageOff } from 'lucide-svelte';
import { Ellipsis } from 'lucide-svelte';
import * as Table from '$lib/components/ui/table/index.js';
import { getContext } from 'svelte';
import type { components } from '$lib/api/api';
import { getFullyQualifiedMediaName } from '$lib/utils';
import DownloadSeasonDialog from '$lib/components/download-dialogs/download-season-dialog.svelte';
import DownloadSelectedSeasonsDialog from '$lib/components/download-dialogs/download-selected-seasons-dialog.svelte';
import DownloadSelectedEpisodesDialog from '$lib/components/download-dialogs/download-selected-episodes-dialog.svelte';
import DownloadCustomDialog from '$lib/components/download-dialogs/download-custom-dialog.svelte';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import { page } from '$app/state';
import TorrentTable from '$lib/components/torrents/torrent-table.svelte';
import RequestSeasonDialog from '$lib/components/requests/request-season-dialog.svelte';
import MediaPicture from '$lib/components/media-picture.svelte';
import { Switch } from '$lib/components/ui/switch/index.js';
import { toast } from 'svelte-sonner';
@@ -22,11 +24,85 @@
import DeleteMediaDialog from '$lib/components/delete-media-dialog.svelte';
import { resolve } from '$app/paths';
import client from '$lib/api';
import { Checkbox } from '$lib/components/ui/checkbox';
import { SvelteSet } from 'svelte/reactivity';
let show: components['schemas']['PublicShow'] = $derived(page.data.showData);
let torrents: components['schemas']['RichShowTorrent'] = $derived(page.data.torrentsData);
let user: () => components['schemas']['UserRead'] = getContext('user');
let expandedSeasons = $state<Set<string>>(new Set());
function toggleSeason(seasonId: string) {
if (expandedSeasons.has(seasonId)) {
expandedSeasons.delete(seasonId);
} else {
expandedSeasons.add(seasonId);
}
expandedSeasons = new SvelteSet(expandedSeasons);
}
let selectedSeasons = $state<Set<string>>(new Set());
function toggleSeasonSelection(seasonId: string) {
if (selectedSeasons.has(seasonId)) {
selectedSeasons.delete(seasonId);
} else {
selectedSeasons.add(seasonId);
}
selectedSeasons = new SvelteSet(selectedSeasons);
}
let selectedSeasonNumbers = $derived(
show.seasons.filter((s) => selectedSeasons.has(s.id)).map((s) => s.number)
);
let downloadButtonLabel = $derived(
selectedSeasonNumbers.length === 0
? 'Download Seasons'
: `Download Season(s) ${selectedSeasonNumbers
.slice()
.sort((a, b) => a - b)
.map((n) => `S${String(n).padStart(2, '0')}`)
.join(', ')}`
);
let selectedEpisodes = $state<Set<string>>(new Set());
function toggleEpisodeSelection(episodeId: string) {
if (selectedEpisodes.has(episodeId)) {
selectedEpisodes.delete(episodeId);
} else {
selectedEpisodes.add(episodeId);
}
selectedEpisodes = new SvelteSet(selectedEpisodes);
}
let selectedEpisodeNumbers = $derived(
show.seasons.flatMap((season) =>
season.episodes
.filter((ep) => selectedEpisodes.has(ep.id))
.map((ep) => ({
seasonNumber: season.number,
episodeNumber: ep.number
}))
)
);
let episodeDownloadLabel = $derived(
selectedEpisodeNumbers.length === 0
? 'Download Episodes'
: `Download Episode(s) ${selectedEpisodeNumbers
.map(
(e) =>
`S${String(e.seasonNumber).padStart(2, '0')}E${String(e.episodeNumber).padStart(
2,
'0'
)}`
)
.join(', ')}`
);
let continuousDownloadEnabled = $derived(show.continuous_download);
async function toggle_continuous_download() {
@@ -109,7 +185,7 @@
<Card.Title>Overview</Card.Title>
</Card.Header>
<Card.Content>
<p class="leading-7 not-first:mt-6">
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
{show.overview}
</p>
</Card.Content>
@@ -146,9 +222,24 @@
</Card.Header>
<Card.Content class="flex flex-col items-center gap-4">
{#if user().is_superuser}
<DownloadSeasonDialog {show} />
{#if selectedSeasonNumbers.length > 0}
<DownloadSelectedSeasonsDialog
{show}
{selectedSeasonNumbers}
triggerText={downloadButtonLabel}
/>
{/if}
{#if selectedEpisodeNumbers.length > 0}
<DownloadSelectedEpisodesDialog
{show}
{selectedEpisodeNumbers}
triggerText={episodeDownloadLabel}
/>
{/if}
{#if selectedSeasonNumbers.length === 0 && selectedEpisodeNumbers.length === 0}
<DownloadCustomDialog {show} />
{/if}
{/if}
<RequestSeasonDialog {show} />
</Card.Content>
</Card.Root>
</div>
@@ -162,35 +253,87 @@
</Card.Description>
</Card.Header>
<Card.Content class="w-full overflow-x-auto">
<Table.Root>
<Table.Root class="w-full table-fixed">
<Table.Caption>A list of all seasons.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Number</Table.Head>
<Table.Head>Exists on file</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head class="w-[40px]"></Table.Head>
<Table.Head class="w-[80px]">Number</Table.Head>
<Table.Head class="w-[100px]">Exists on file</Table.Head>
<Table.Head class="w-[240px]">Title</Table.Head>
<Table.Head>Overview</Table.Head>
<Table.Head class="w-[64px] text-center">Details</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if show.seasons.length > 0}
{#each show.seasons as season (season.id)}
<Table.Row
onclick={() =>
goto(
resolve('/dashboard/tv/[showId]/[seasonId]', {
showId: show.id,
seasonId: season.id
})
)}
class={`group cursor-pointer transition-colors hover:bg-muted/60 ${
expandedSeasons.has(season.id) ? 'bg-muted/50' : 'bg-muted/10'
}`}
onclick={() => toggleSeason(season.id)}
>
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
<Table.Cell class="w-[40px]">
<Checkbox
checked={selectedSeasons.has(season.id)}
onCheckedChange={() => toggleSeasonSelection(season.id)}
onclick={(e) => e.stopPropagation()}
/>
</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
S{String(season.number).padStart(2, '0')}
</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
<CheckmarkX state={season.downloaded} />
</Table.Cell>
<Table.Cell class="min-w-[50px]">{season.name}</Table.Cell>
<Table.Cell class="max-w-[300px] truncate">{season.overview}</Table.Cell>
<Table.Cell class="w-[64px] text-center">
<button
class="inline-flex cursor-pointer items-center
justify-center
rounded-md p-1
transition-colors
hover:bg-muted/95
focus-visible:ring-2
focus-visible:ring-ring focus-visible:outline-none"
onclick={(e) => {
e.stopPropagation();
goto(
resolve('/dashboard/tv/[showId]/[seasonId]', {
showId: show.id,
seasonId: season.id
})
);
}}
aria-label="Season details"
>
<Ellipsis size={16} class="text-muted-foreground" />
</button>
</Table.Cell>
</Table.Row>
{#if expandedSeasons.has(season.id)}
{#each season.episodes as episode (episode.id)}
<Table.Row class="bg-muted/20">
<Table.Cell class="w-[40px]">
<Checkbox
checked={selectedEpisodes.has(episode.id)}
onCheckedChange={() => toggleEpisodeSelection(episode.id)}
onclick={(e) => e.stopPropagation()}
/>
</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
E{String(episode.number).padStart(2, '0')}
</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
<CheckmarkX state={episode.downloaded} />
</Table.Cell>
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
<Table.Cell colspan={2} class="truncate">{episode.overview}</Table.Cell>
</Table.Row>
{/each}
{/if}
{/each}
{:else}
<Table.Row>

View File

@@ -11,9 +11,15 @@
import { resolve } from '$app/paths';
import * as Card from '$lib/components/ui/card/index.js';
let seasonFiles: components['schemas']['PublicSeasonFile'][] = $derived(page.data.files);
let episodeFiles: components['schemas']['PublicEpisodeFile'][] = $derived(page.data.files);
let season: components['schemas']['Season'] = $derived(page.data.season);
let show: components['schemas']['Show'] = $derived(page.data.showData);
let episodeById = $derived(
Object.fromEntries(
season.episodes.map((ep) => [ep.id, `E${String(ep.number).padStart(2, '0')}`])
)
);
</script>
<svelte:head>
@@ -59,7 +65,7 @@
</div>
</header>
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
{getFullyQualifiedMediaName(show)} Season {season.number}
{getFullyQualifiedMediaName(show)} - Season {season.number}
</h1>
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
<div class="flex flex-col gap-4 md:flex-row md:items-stretch">
@@ -68,13 +74,20 @@
</div>
<div class="h-full w-full flex-auto rounded-xl md:w-1/4">
<Card.Root class="h-full w-full">
<Card.Header>
<Card.Title>Overview</Card.Title>
</Card.Header>
<Card.Content>
<p class="leading-7 not-first:mt-6">
{show.overview}
</p>
<Card.Content class="flex flex-col gap-6">
<div>
<Card.Title class="mb-2 text-base">Series Overview</Card.Title>
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
{show.overview}
</p>
</div>
<div class="border-t border-border"></div>
<div>
<Card.Title class="mb-2 text-base">Season Overview</Card.Title>
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
{season.overview}
</p>
</div>
</Card.Content>
</Card.Root>
</div>
@@ -95,14 +108,18 @@
>
<Table.Header>
<Table.Row>
<Table.Head>Episode</Table.Head>
<Table.Head>Quality</Table.Head>
<Table.Head>File Path Suffix</Table.Head>
<Table.Head>Imported</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each seasonFiles as file (file)}
{#each episodeFiles as file (file)}
<Table.Row>
<Table.Cell class="w-[50px]">
{episodeById[file.episode_id] ?? 'E??'}
</Table.Cell>
<Table.Cell class="w-[50px]">
{getTorrentQualityString(file.quality)}
</Table.Cell>
@@ -114,7 +131,11 @@
</Table.Cell>
</Table.Row>
{:else}
<span class="font-semibold">You haven't downloaded this season yet.</span>
<Table.Row>
<Table.Cell colspan={4} class="text-center py-6 font-semibold">
You haven't downloaded episodes of this season yet.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
@@ -132,19 +153,23 @@
</Card.Description>
</Card.Header>
<Card.Content class="w-full overflow-x-auto">
<Table.Root>
<Table.Root class="w-full table-fixed">
<Table.Caption>A list of all episodes.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Number</Table.Head>
<Table.Head class="min-w-[50px]">Title</Table.Head>
<Table.Head class="w-[80px]">Number</Table.Head>
<Table.Head class="w-[240px]">Title</Table.Head>
<Table.Head>Overview</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each season.episodes as episode (episode.id)}
<Table.Row>
<Table.Cell class="w-[100px] font-medium">{episode.number}</Table.Cell>
<Table.Cell class="w-[100px] font-medium"
>E{String(episode.number).padStart(2, '0')}</Table.Cell
>
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
<Table.Cell class="truncate">{episode.overview}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>

View File

@@ -10,7 +10,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
}
}
});
const seasonFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', {
const episodeFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', {
fetch: fetch,
params: {
path: {
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
}
});
return {
files: await seasonFiles.then((x) => x.data),
files: await episodeFiles.then((x) => x.data),
season: await season.then((x) => x.data)
};
};

View File

@@ -1,49 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import RequestsTable from '$lib/components/requests/requests-table.svelte';
import { resolve } from '$app/paths';
import type { components } from '$lib/api/api';
let requests: components['schemas']['RichSeasonRequest'][] = $derived(page.data.requestsData);
</script>
<svelte:head>
<title>TV Show Requests - MediaManager</title>
<meta content="View and manage TV show download requests in MediaManager" name="description" />
</svelte:head>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator class="mr-2 h-4" orientation="vertical" />
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href={resolve('/dashboard', {})}>MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href={resolve('/dashboard', {})}>Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href={resolve('/dashboard/tv', {})}>Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Page>Season Requests</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
Season Requests
</h1>
<RequestsTable {requests} isShow={true} />
</main>

View File

@@ -1,9 +0,0 @@
import type { PageLoad } from './$types';
import client from '$lib/api';
export const load: PageLoad = async ({ fetch }) => {
const { data } = await client.GET('/api/v1/tv/seasons/requests', { fetch: fetch });
return {
requestsData: data
};
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 110 KiB