diff --git a/apps/podfetch/README.md b/apps/podfetch/README.md
new file mode 100644
index 000000000..37c6eb912
--- /dev/null
+++ b/apps/podfetch/README.md
@@ -0,0 +1,34 @@
+# PodFetch
+
+## 产品介绍
+
+PodFetch 是一个自托管的播客管理器和下载器,提供 Web 界面、全文检索以及多种移动端和订阅阅读器集成。
+
+## 主要功能
+
+- 订阅、刷新和下载公开播客源。
+- 使用 SQLite 保存订阅、用户和播放状态。
+- 将播客媒体保存到独立目录,便于备份或迁移。
+- 支持基本认证、OIDC、GPodder 和 Audiobookshelf 兼容接口。
+
+## 访问说明
+
+- 默认启用基本认证,请使用安装表单中的管理员用户名和密码登录。
+- `播客目录` 和 `数据库目录` 都是持久化边界,卸载应用时不会自动删除。
+- 如使用反向代理,请启用 WebSocket 并转发标准的 `X-Forwarded-*` 头。
+
+## Introduction
+
+PodFetch is a self-hosted podcast manager and downloader with a web interface, full-text search, and integrations for mobile apps and feed readers.
+
+## Features
+
+- Subscribe to, refresh, and download public podcast feeds.
+- Store subscriptions, users, and playback state in SQLite.
+- Keep downloaded media in a separate directory for backup and migration.
+- Supports Basic authentication, OIDC, GPodder, and Audiobookshelf-compatible APIs.
+
+## 参考资料
+
+- 文档:
+- 源码:
diff --git a/apps/podfetch/README_en.md b/apps/podfetch/README_en.md
new file mode 100644
index 000000000..957d7eeae
--- /dev/null
+++ b/apps/podfetch/README_en.md
@@ -0,0 +1,3 @@
+# PodFetch
+
+See `README.md` for the bilingual product description, configuration, and persistence notes.
diff --git a/apps/podfetch/data.yml b/apps/podfetch/data.yml
new file mode 100644
index 000000000..2082fd363
--- /dev/null
+++ b/apps/podfetch/data.yml
@@ -0,0 +1,31 @@
+name: PodFetch
+tags:
+ - 多媒体
+title: 自托管的播客管理器和下载器
+description: 自托管的播客管理器和下载器
+additionalProperties:
+ key: podfetch
+ name: PodFetch
+ tags:
+ - Media
+ shortDescZh: 自托管的播客管理器和下载器
+ shortDescEn: Self-hosted podcast manager and downloader
+ description:
+ en: Self-hosted podcast manager and downloader with web, mobile, and feed-reader integrations.
+ zh: 支持 Web、移动端和订阅阅读器集成的自托管播客管理器与下载器。
+ zh-Hant: 支援 Web、行動端和訂閱閱讀器整合的自託管播客管理器與下載器。
+ ja: Web、モバイル、フィードリーダー連携に対応したセルフホスト型ポッドキャスト管理・ダウンロードツールです。
+ ko: 웹, 모바일, 피드 리더 연동을 지원하는 자체 호스팅 팟캐스트 관리자 및 다운로더입니다.
+ ru: Самостоятельно размещаемый менеджер и загрузчик подкастов с интеграциями.
+ ms: Pengurus dan pemuat turun podcast dihos sendiri dengan integrasi web dan mudah alih.
+ pt-br: Gerenciador e baixador de podcasts auto-hospedado com integracoes web e moveis.
+ type: tool
+ crossVersionUpdate: true
+ limit: 0
+ recommend: 0
+ website: https://samtv12345.github.io/PodFetch/
+ github: https://github.com/SamTV12345/PodFetch
+ document: https://samtv12345.github.io/PodFetch/
+ architectures:
+ - amd64
+ - arm64
diff --git a/apps/podfetch/latest/.env.sample b/apps/podfetch/latest/.env.sample
new file mode 100644
index 000000000..882d05454
--- /dev/null
+++ b/apps/podfetch/latest/.env.sample
@@ -0,0 +1,11 @@
+CONTAINER_NAME=podfetch
+PANEL_APP_BIND_ADDRESS=0.0.0.0
+PANEL_APP_PORT_HTTP=40100
+PUID=1000
+PGID=1000
+POLLING_INTERVAL=60
+BASIC_AUTH=true
+PODFETCH_USERNAME=admin
+PODFETCH_PASSWORD=replace-with-a-strong-password
+PODCASTS_DIR=./data/podcasts
+DATABASE_DIR=./data/db
diff --git a/apps/podfetch/latest/data.yml b/apps/podfetch/latest/data.yml
new file mode 100644
index 000000000..ca119e9f9
--- /dev/null
+++ b/apps/podfetch/latest/data.yml
@@ -0,0 +1,170 @@
+additionalProperties:
+ formFields:
+ - default: 0.0.0.0
+ edit: true
+ envKey: PANEL_APP_BIND_ADDRESS
+ labelEn: Bind Address
+ labelZh: 绑定地址
+ label:
+ en: Bind Address
+ zh: 绑定地址
+ zh-Hant: 綁定位址
+ ja: バインドアドレス
+ ko: 바인드 주소
+ ru: Адрес привязки
+ ms: Alamat ikatan
+ pt-br: Endereco de vinculacao
+ required: true
+ type: text
+ - default: 40100
+ edit: true
+ envKey: PANEL_APP_PORT_HTTP
+ labelEn: HTTP Port
+ labelZh: HTTP 端口
+ label:
+ en: HTTP Port
+ zh: HTTP 端口
+ zh-Hant: HTTP 連接埠
+ ja: HTTP ポート
+ ko: HTTP 포트
+ ru: HTTP-порт
+ ms: Port HTTP
+ pt-br: Porta HTTP
+ required: true
+ rule: paramPort
+ type: number
+ - default: 1000
+ edit: true
+ envKey: PUID
+ labelEn: User ID
+ labelZh: 用户 ID
+ label:
+ en: User ID
+ zh: 用户 ID
+ zh-Hant: 使用者 ID
+ ja: ユーザー ID
+ ko: 사용자 ID
+ ru: Идентификатор пользователя
+ ms: ID pengguna
+ pt-br: ID do usuario
+ required: true
+ type: number
+ - default: 1000
+ edit: true
+ envKey: PGID
+ labelEn: Group ID
+ labelZh: 用户组 ID
+ label:
+ en: Group ID
+ zh: 用户组 ID
+ zh-Hant: 使用者群組 ID
+ ja: グループ ID
+ ko: 그룹 ID
+ ru: Идентификатор группы
+ ms: ID kumpulan
+ pt-br: ID do grupo
+ required: true
+ type: number
+ - default: 60
+ edit: true
+ envKey: POLLING_INTERVAL
+ labelEn: Polling Interval
+ labelZh: 检查间隔
+ label:
+ en: Polling Interval
+ zh: 检查间隔
+ zh-Hant: 檢查間隔
+ ja: 確認間隔
+ ko: 확인 간격
+ ru: Интервал проверки
+ ms: Selang semakan
+ pt-br: Intervalo de verificacao
+ required: true
+ type: number
+ - default: "true"
+ edit: true
+ envKey: BASIC_AUTH
+ labelEn: Enable Basic Authentication
+ labelZh: 启用基本认证
+ label:
+ en: Enable Basic Authentication
+ zh: 启用基本认证
+ zh-Hant: 啟用基本驗證
+ ja: Basic 認証を有効化
+ ko: 기본 인증 사용
+ ru: Включить базовую аутентификацию
+ ms: Dayakan pengesahan asas
+ pt-br: Ativar autenticacao basica
+ required: true
+ type: select
+ values:
+ - label: "true"
+ value: "true"
+ - label: "false"
+ value: "false"
+ - default: admin
+ edit: true
+ envKey: PODFETCH_USERNAME
+ labelEn: Administrator Username
+ labelZh: 管理员用户名
+ label:
+ en: Administrator Username
+ zh: 管理员用户名
+ zh-Hant: 管理員使用者名稱
+ ja: 管理者ユーザー名
+ ko: 관리자 사용자 이름
+ ru: Имя администратора
+ ms: Nama pengguna pentadbir
+ pt-br: Nome do administrador
+ required: true
+ type: text
+ - default: ""
+ edit: true
+ envKey: PODFETCH_PASSWORD
+ labelEn: Administrator Password
+ labelZh: 管理员密码
+ label:
+ en: Administrator Password
+ zh: 管理员密码
+ zh-Hant: 管理員密碼
+ ja: 管理者パスワード
+ ko: 관리자 비밀번호
+ ru: Пароль администратора
+ ms: Kata laluan pentadbir
+ pt-br: Senha do administrador
+ random: true
+ required: true
+ rule: paramComplexity
+ type: password
+ - default: ./data/podcasts
+ edit: true
+ envKey: PODCASTS_DIR
+ labelEn: Podcast Directory
+ labelZh: 播客目录
+ label:
+ en: Podcast Directory
+ zh: 播客目录
+ zh-Hant: Podcast 目錄
+ ja: ポッドキャストディレクトリ
+ ko: 팟캐스트 디렉터리
+ ru: Каталог подкастов
+ ms: Direktori podcast
+ pt-br: Diretorio de podcasts
+ required: true
+ type: text
+ - default: ./data/db
+ edit: true
+ envKey: DATABASE_DIR
+ labelEn: Database Directory
+ labelZh: 数据库目录
+ label:
+ en: Database Directory
+ zh: 数据库目录
+ zh-Hant: 資料庫目錄
+ ja: データベースディレクトリ
+ ko: 데이터베이스 디렉터리
+ ru: Каталог базы данных
+ ms: Direktori pangkalan data
+ pt-br: Diretorio do banco de dados
+ required: true
+ type: text
diff --git a/apps/podfetch/latest/docker-compose.yml b/apps/podfetch/latest/docker-compose.yml
new file mode 100644
index 000000000..0c48389b7
--- /dev/null
+++ b/apps/podfetch/latest/docker-compose.yml
@@ -0,0 +1,27 @@
+services:
+ podfetch:
+ image: "docker.io/samuel19982/podfetch:latest"
+ container_name: ${CONTAINER_NAME}
+ restart: unless-stopped
+ user: "${PUID}:${PGID}"
+ networks:
+ - 1panel-network
+ ports:
+ - "${PANEL_APP_BIND_ADDRESS}:${PANEL_APP_PORT_HTTP}:8000"
+ environment:
+ - PORT=8000
+ - POLLING_INTERVAL=${POLLING_INTERVAL}
+ - DATABASE_URL=sqlite:///app/db/podcast.db
+ - PODFETCH_FOLDER=/app/podcasts
+ - BASIC_AUTH=${BASIC_AUTH}
+ - USERNAME=${PODFETCH_USERNAME}
+ - PASSWORD=${PODFETCH_PASSWORD}
+ volumes:
+ - "${PODCASTS_DIR}:/app/podcasts"
+ - "${DATABASE_DIR}:/app/db"
+ labels:
+ createdBy: Apps
+
+networks:
+ 1panel-network:
+ external: true
diff --git a/apps/podfetch/latest/scripts/init.sh b/apps/podfetch/latest/scripts/init.sh
new file mode 100755
index 000000000..fb1055e5e
--- /dev/null
+++ b/apps/podfetch/latest/scripts/init.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}"
+
+fail() {
+ printf '%s\n' "$1" >&2
+ exit 1
+}
+
+read_env_value() {
+ local key="$1"
+ local value=""
+
+ value="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2-)"
+ case "$value" in
+ \"*\" | \'*\') value="${value:1:${#value}-2}" ;;
+ esac
+ printf '%s\n' "$value"
+}
+
+prepare_directory() {
+ local key="$1"
+ local raw="$2"
+ local absolute=""
+
+ [[ -n "$raw" ]] || fail "${key} must not be empty"
+ case "$raw" in
+ *$'\n'* | *$'\r'* | *\\* | *'$'* | *'#'* | *'"'* | *"'"*) fail "${key} contains unsupported dotenv characters" ;;
+ esac
+ if [[ "$raw" = /* ]]; then
+ [[ ! -L "$raw" ]] || fail "${key} must not be a symbolic link"
+ absolute="$(realpath -m -- "$raw")"
+ [[ "$absolute" != "/" ]] || fail "${key} must not be the filesystem root"
+ if [[ -e "$absolute" ]]; then
+ [[ -d "$absolute" ]] || fail "${key} must be a directory"
+ [[ "$(stat -c '%u:%g' "$absolute")" == "${PUID}:${PGID}" ]] || fail "Existing ${key} must be owned by ${PUID}:${PGID}"
+ else
+ install -d -m 0750 -- "$absolute"
+ chown "${PUID}:${PGID}" -- "$absolute"
+ fi
+ else
+ absolute="$(realpath -m -- "${ROOT_DIR}/${raw#./}")"
+ case "$absolute" in
+ "${ROOT_DIR}"/*) ;;
+ *) fail "Relative ${key} must remain inside the application version directory" ;;
+ esac
+ [[ ! -L "$absolute" ]] || fail "${key} must not be a symbolic link"
+ install -d -m 0750 -- "$absolute"
+ chown "${PUID}:${PGID}" -- "$absolute"
+ fi
+}
+
+[[ "$(id -u)" -eq 0 ]] || fail "PodFetch init must run as root"
+[[ -f "$ENV_FILE" ]] || fail "Environment file not found: ${ENV_FILE}"
+[[ ! -L "$ENV_FILE" ]] || fail "Environment file must not be a symbolic link"
+
+PUID="${PUID:-$(read_env_value PUID)}"
+PGID="${PGID:-$(read_env_value PGID)}"
+[[ "$PUID" =~ ^[0-9]+$ && "$PGID" =~ ^[0-9]+$ ]] || fail "PUID and PGID must be numeric"
+
+prepare_directory PODCASTS_DIR "${PODCASTS_DIR:-$(read_env_value PODCASTS_DIR)}"
+prepare_directory DATABASE_DIR "${DATABASE_DIR:-$(read_env_value DATABASE_DIR)}"
diff --git a/apps/podfetch/latest/scripts/uninstall.sh b/apps/podfetch/latest/scripts/uninstall.sh
new file mode 100755
index 000000000..552de26c5
--- /dev/null
+++ b/apps/podfetch/latest/scripts/uninstall.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Podcast media and the SQLite database are retained for recovery.
+exit 0
diff --git a/apps/podfetch/latest/scripts/upgrade.sh b/apps/podfetch/latest/scripts/upgrade.sh
new file mode 100755
index 000000000..4b02f0ad7
--- /dev/null
+++ b/apps/podfetch/latest/scripts/upgrade.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# PodFetch applies its database migrations when the new container starts.
+exit 0
diff --git a/apps/podfetch/logo-LICENSE.txt b/apps/podfetch/logo-LICENSE.txt
new file mode 100644
index 000000000..4126e031a
--- /dev/null
+++ b/apps/podfetch/logo-LICENSE.txt
@@ -0,0 +1,9 @@
+PodFetch logo
+
+Source: https://github.com/SamTV12345/PodFetch/blob/b28002a56bd0291df3a4b184cf5673dcd99aceec/ui/public/logo.png
+Source commit: b28002a56bd0291df3a4b184cf5673dcd99aceec
+Source SHA-256: 3ad03a34777eff296167d19f38f0084c54096f7b78e725958953d05d7e8d58c4
+License: Apache License 2.0 (Apache-2.0)
+License text: https://github.com/SamTV12345/PodFetch/blob/b28002a56bd0291df3a4b184cf5673dcd99aceec/LICENSE
+
+The store asset is a proportional rendering of the official project logo.
diff --git a/apps/podfetch/logo.png b/apps/podfetch/logo.png
new file mode 100644
index 000000000..8fdc79005
Binary files /dev/null and b/apps/podfetch/logo.png differ
diff --git a/apps/podfetch/v5.2.2/.env.sample b/apps/podfetch/v5.2.2/.env.sample
new file mode 100644
index 000000000..882d05454
--- /dev/null
+++ b/apps/podfetch/v5.2.2/.env.sample
@@ -0,0 +1,11 @@
+CONTAINER_NAME=podfetch
+PANEL_APP_BIND_ADDRESS=0.0.0.0
+PANEL_APP_PORT_HTTP=40100
+PUID=1000
+PGID=1000
+POLLING_INTERVAL=60
+BASIC_AUTH=true
+PODFETCH_USERNAME=admin
+PODFETCH_PASSWORD=replace-with-a-strong-password
+PODCASTS_DIR=./data/podcasts
+DATABASE_DIR=./data/db
diff --git a/apps/podfetch/v5.2.2/data.yml b/apps/podfetch/v5.2.2/data.yml
new file mode 100644
index 000000000..ca119e9f9
--- /dev/null
+++ b/apps/podfetch/v5.2.2/data.yml
@@ -0,0 +1,170 @@
+additionalProperties:
+ formFields:
+ - default: 0.0.0.0
+ edit: true
+ envKey: PANEL_APP_BIND_ADDRESS
+ labelEn: Bind Address
+ labelZh: 绑定地址
+ label:
+ en: Bind Address
+ zh: 绑定地址
+ zh-Hant: 綁定位址
+ ja: バインドアドレス
+ ko: 바인드 주소
+ ru: Адрес привязки
+ ms: Alamat ikatan
+ pt-br: Endereco de vinculacao
+ required: true
+ type: text
+ - default: 40100
+ edit: true
+ envKey: PANEL_APP_PORT_HTTP
+ labelEn: HTTP Port
+ labelZh: HTTP 端口
+ label:
+ en: HTTP Port
+ zh: HTTP 端口
+ zh-Hant: HTTP 連接埠
+ ja: HTTP ポート
+ ko: HTTP 포트
+ ru: HTTP-порт
+ ms: Port HTTP
+ pt-br: Porta HTTP
+ required: true
+ rule: paramPort
+ type: number
+ - default: 1000
+ edit: true
+ envKey: PUID
+ labelEn: User ID
+ labelZh: 用户 ID
+ label:
+ en: User ID
+ zh: 用户 ID
+ zh-Hant: 使用者 ID
+ ja: ユーザー ID
+ ko: 사용자 ID
+ ru: Идентификатор пользователя
+ ms: ID pengguna
+ pt-br: ID do usuario
+ required: true
+ type: number
+ - default: 1000
+ edit: true
+ envKey: PGID
+ labelEn: Group ID
+ labelZh: 用户组 ID
+ label:
+ en: Group ID
+ zh: 用户组 ID
+ zh-Hant: 使用者群組 ID
+ ja: グループ ID
+ ko: 그룹 ID
+ ru: Идентификатор группы
+ ms: ID kumpulan
+ pt-br: ID do grupo
+ required: true
+ type: number
+ - default: 60
+ edit: true
+ envKey: POLLING_INTERVAL
+ labelEn: Polling Interval
+ labelZh: 检查间隔
+ label:
+ en: Polling Interval
+ zh: 检查间隔
+ zh-Hant: 檢查間隔
+ ja: 確認間隔
+ ko: 확인 간격
+ ru: Интервал проверки
+ ms: Selang semakan
+ pt-br: Intervalo de verificacao
+ required: true
+ type: number
+ - default: "true"
+ edit: true
+ envKey: BASIC_AUTH
+ labelEn: Enable Basic Authentication
+ labelZh: 启用基本认证
+ label:
+ en: Enable Basic Authentication
+ zh: 启用基本认证
+ zh-Hant: 啟用基本驗證
+ ja: Basic 認証を有効化
+ ko: 기본 인증 사용
+ ru: Включить базовую аутентификацию
+ ms: Dayakan pengesahan asas
+ pt-br: Ativar autenticacao basica
+ required: true
+ type: select
+ values:
+ - label: "true"
+ value: "true"
+ - label: "false"
+ value: "false"
+ - default: admin
+ edit: true
+ envKey: PODFETCH_USERNAME
+ labelEn: Administrator Username
+ labelZh: 管理员用户名
+ label:
+ en: Administrator Username
+ zh: 管理员用户名
+ zh-Hant: 管理員使用者名稱
+ ja: 管理者ユーザー名
+ ko: 관리자 사용자 이름
+ ru: Имя администратора
+ ms: Nama pengguna pentadbir
+ pt-br: Nome do administrador
+ required: true
+ type: text
+ - default: ""
+ edit: true
+ envKey: PODFETCH_PASSWORD
+ labelEn: Administrator Password
+ labelZh: 管理员密码
+ label:
+ en: Administrator Password
+ zh: 管理员密码
+ zh-Hant: 管理員密碼
+ ja: 管理者パスワード
+ ko: 관리자 비밀번호
+ ru: Пароль администратора
+ ms: Kata laluan pentadbir
+ pt-br: Senha do administrador
+ random: true
+ required: true
+ rule: paramComplexity
+ type: password
+ - default: ./data/podcasts
+ edit: true
+ envKey: PODCASTS_DIR
+ labelEn: Podcast Directory
+ labelZh: 播客目录
+ label:
+ en: Podcast Directory
+ zh: 播客目录
+ zh-Hant: Podcast 目錄
+ ja: ポッドキャストディレクトリ
+ ko: 팟캐스트 디렉터리
+ ru: Каталог подкастов
+ ms: Direktori podcast
+ pt-br: Diretorio de podcasts
+ required: true
+ type: text
+ - default: ./data/db
+ edit: true
+ envKey: DATABASE_DIR
+ labelEn: Database Directory
+ labelZh: 数据库目录
+ label:
+ en: Database Directory
+ zh: 数据库目录
+ zh-Hant: 資料庫目錄
+ ja: データベースディレクトリ
+ ko: 데이터베이스 디렉터리
+ ru: Каталог базы данных
+ ms: Direktori pangkalan data
+ pt-br: Diretorio do banco de dados
+ required: true
+ type: text
diff --git a/apps/podfetch/v5.2.2/docker-compose.yml b/apps/podfetch/v5.2.2/docker-compose.yml
new file mode 100644
index 000000000..d6964385b
--- /dev/null
+++ b/apps/podfetch/v5.2.2/docker-compose.yml
@@ -0,0 +1,27 @@
+services:
+ podfetch:
+ image: "docker.io/samuel19982/podfetch:v5.2.2@sha256:d8b8e546d3ae40c6e7821343c3d0f0a238b47cb772295e95c7f8683ca419663f"
+ container_name: ${CONTAINER_NAME}
+ restart: unless-stopped
+ user: "${PUID}:${PGID}"
+ networks:
+ - 1panel-network
+ ports:
+ - "${PANEL_APP_BIND_ADDRESS}:${PANEL_APP_PORT_HTTP}:8000"
+ environment:
+ - PORT=8000
+ - POLLING_INTERVAL=${POLLING_INTERVAL}
+ - DATABASE_URL=sqlite:///app/db/podcast.db
+ - PODFETCH_FOLDER=/app/podcasts
+ - BASIC_AUTH=${BASIC_AUTH}
+ - USERNAME=${PODFETCH_USERNAME}
+ - PASSWORD=${PODFETCH_PASSWORD}
+ volumes:
+ - "${PODCASTS_DIR}:/app/podcasts"
+ - "${DATABASE_DIR}:/app/db"
+ labels:
+ createdBy: Apps
+
+networks:
+ 1panel-network:
+ external: true
diff --git a/apps/podfetch/v5.2.2/scripts/init.sh b/apps/podfetch/v5.2.2/scripts/init.sh
new file mode 100755
index 000000000..fb1055e5e
--- /dev/null
+++ b/apps/podfetch/v5.2.2/scripts/init.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}"
+
+fail() {
+ printf '%s\n' "$1" >&2
+ exit 1
+}
+
+read_env_value() {
+ local key="$1"
+ local value=""
+
+ value="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2-)"
+ case "$value" in
+ \"*\" | \'*\') value="${value:1:${#value}-2}" ;;
+ esac
+ printf '%s\n' "$value"
+}
+
+prepare_directory() {
+ local key="$1"
+ local raw="$2"
+ local absolute=""
+
+ [[ -n "$raw" ]] || fail "${key} must not be empty"
+ case "$raw" in
+ *$'\n'* | *$'\r'* | *\\* | *'$'* | *'#'* | *'"'* | *"'"*) fail "${key} contains unsupported dotenv characters" ;;
+ esac
+ if [[ "$raw" = /* ]]; then
+ [[ ! -L "$raw" ]] || fail "${key} must not be a symbolic link"
+ absolute="$(realpath -m -- "$raw")"
+ [[ "$absolute" != "/" ]] || fail "${key} must not be the filesystem root"
+ if [[ -e "$absolute" ]]; then
+ [[ -d "$absolute" ]] || fail "${key} must be a directory"
+ [[ "$(stat -c '%u:%g' "$absolute")" == "${PUID}:${PGID}" ]] || fail "Existing ${key} must be owned by ${PUID}:${PGID}"
+ else
+ install -d -m 0750 -- "$absolute"
+ chown "${PUID}:${PGID}" -- "$absolute"
+ fi
+ else
+ absolute="$(realpath -m -- "${ROOT_DIR}/${raw#./}")"
+ case "$absolute" in
+ "${ROOT_DIR}"/*) ;;
+ *) fail "Relative ${key} must remain inside the application version directory" ;;
+ esac
+ [[ ! -L "$absolute" ]] || fail "${key} must not be a symbolic link"
+ install -d -m 0750 -- "$absolute"
+ chown "${PUID}:${PGID}" -- "$absolute"
+ fi
+}
+
+[[ "$(id -u)" -eq 0 ]] || fail "PodFetch init must run as root"
+[[ -f "$ENV_FILE" ]] || fail "Environment file not found: ${ENV_FILE}"
+[[ ! -L "$ENV_FILE" ]] || fail "Environment file must not be a symbolic link"
+
+PUID="${PUID:-$(read_env_value PUID)}"
+PGID="${PGID:-$(read_env_value PGID)}"
+[[ "$PUID" =~ ^[0-9]+$ && "$PGID" =~ ^[0-9]+$ ]] || fail "PUID and PGID must be numeric"
+
+prepare_directory PODCASTS_DIR "${PODCASTS_DIR:-$(read_env_value PODCASTS_DIR)}"
+prepare_directory DATABASE_DIR "${DATABASE_DIR:-$(read_env_value DATABASE_DIR)}"
diff --git a/apps/podfetch/v5.2.2/scripts/uninstall.sh b/apps/podfetch/v5.2.2/scripts/uninstall.sh
new file mode 100755
index 000000000..552de26c5
--- /dev/null
+++ b/apps/podfetch/v5.2.2/scripts/uninstall.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Podcast media and the SQLite database are retained for recovery.
+exit 0
diff --git a/apps/podfetch/v5.2.2/scripts/upgrade.sh b/apps/podfetch/v5.2.2/scripts/upgrade.sh
new file mode 100755
index 000000000..4b02f0ad7
--- /dev/null
+++ b/apps/podfetch/v5.2.2/scripts/upgrade.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# PodFetch applies its database migrations when the new container starts.
+exit 0