fix(postgresql): support unix socket connections

Allow PostgreSQL socket paths without forcing a TCP port and reuse a single URL builder for sync, async, and migration flows. Document Redis socket URLs and close the socket connection request. Closes #5720
This commit is contained in:
jxxghp
2026-05-07 13:22:14 +08:00
parent ffbe348d66
commit c2c9950bb1
6 changed files with 170 additions and 19 deletions

View File

@@ -281,7 +281,10 @@ class PromptManager:
db_info = f"SQLite ({settings.CONFIG_PATH / 'db' / 'moviepilot.db'})"
else:
db_password = settings.DB_POSTGRESQL_PASSWORD or ""
db_info = f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE})"
db_info = (
f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@"
f"{settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE})"
)
info_lines = [
f"- 当前时间: {strftime('%Y-%m-%d %H:%M:%S')}",

View File

@@ -10,7 +10,7 @@ import threading
from asyncio import AbstractEventLoop
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Type
from urllib.parse import urlparse
from urllib.parse import quote, urlencode, urlparse
from dotenv import set_key
from pydantic import BaseModel, Field, ConfigDict, model_validator
@@ -126,8 +126,8 @@ class ConfigModel(BaseModel):
DB_SQLITE_MAX_OVERFLOW: int = 50
# PostgreSQL 主机地址
DB_POSTGRESQL_HOST: str = "localhost"
# PostgreSQL 端口
DB_POSTGRESQL_PORT: int = 5432
# PostgreSQL 端口;使用 Unix Socket 时可留空
DB_POSTGRESQL_PORT: str = "5432"
# PostgreSQL 数据库名
DB_POSTGRESQL_DATABASE: str = "moviepilot"
# PostgreSQL 用户名
@@ -142,7 +142,7 @@ class ConfigModel(BaseModel):
# ==================== 缓存配置 ====================
# 缓存类型,支持 cachetools 和 redis默认使用 cachetools
CACHE_BACKEND_TYPE: str = "cachetools"
# 缓存连接字符串,仅外部缓存(如 Redis、Memcached需要
# 缓存连接字符串,仅外部缓存(如 Redis、Memcached需要,支持 Redis Unix Socket URL
CACHE_BACKEND_URL: Optional[str] = "redis://localhost:6379"
# Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb"
CACHE_REDIS_MAXMEMORY: Optional[str] = None
@@ -921,6 +921,39 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
}
return None
@property
def DB_POSTGRESQL_SOCKET_MODE(self) -> bool:
host = (self.DB_POSTGRESQL_HOST or "").strip()
return host.startswith("/")
@property
def DB_POSTGRESQL_TARGET(self) -> str:
if self.DB_POSTGRESQL_SOCKET_MODE:
target = f"socket {self.DB_POSTGRESQL_HOST}"
if self.DB_POSTGRESQL_PORT:
target = f"{target} (port {self.DB_POSTGRESQL_PORT})"
return target
if self.DB_POSTGRESQL_PORT:
return f"{self.DB_POSTGRESQL_HOST}:{self.DB_POSTGRESQL_PORT}"
return self.DB_POSTGRESQL_HOST
def DB_POSTGRESQL_URL(self, driver: Optional[str] = None) -> str:
scheme = "postgresql" if not driver else f"postgresql+{driver}"
username = quote(str(self.DB_POSTGRESQL_USERNAME), safe="")
database = quote(str(self.DB_POSTGRESQL_DATABASE), safe="")
auth = username
if self.DB_POSTGRESQL_PASSWORD:
auth = f"{auth}:{quote(str(self.DB_POSTGRESQL_PASSWORD), safe='')}"
if self.DB_POSTGRESQL_SOCKET_MODE:
query = {"host": self.DB_POSTGRESQL_HOST}
if self.DB_POSTGRESQL_PORT:
query["port"] = self.DB_POSTGRESQL_PORT
return f"{scheme}://{auth}@/{database}?{urlencode(query)}"
port = f":{self.DB_POSTGRESQL_PORT}" if self.DB_POSTGRESQL_PORT else ""
return f"{scheme}://{auth}@{self.DB_POSTGRESQL_HOST}{port}/{database}"
@property
def PROXY_SERVER(self):
if self.PROXY_HOST:

View File

@@ -116,11 +116,7 @@ def _get_postgresql_engine(is_async: bool = False):
"""
获取PostgreSQL数据库引擎
"""
# 构建PostgreSQL连接URL
if settings.DB_POSTGRESQL_PASSWORD:
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
else:
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
db_url = settings.DB_POSTGRESQL_URL()
# PostgreSQL连接参数
_connect_args = {}
@@ -150,12 +146,11 @@ def _get_postgresql_engine(is_async: bool = False):
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}")
print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}")
return engine
else:
# 构建异步PostgreSQL连接URL
async_db_url = f"postgresql+asyncpg://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
async_db_url = settings.DB_POSTGRESQL_URL("asyncpg")
# 数据库参数,只能使用 NullPool
_db_kwargs = {
@@ -168,7 +163,7 @@ def _get_postgresql_engine(is_async: bool = False):
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}")
print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}")
return async_engine

View File

@@ -28,10 +28,7 @@ def update_db():
# 根据数据库类型设置不同的URL
if settings.DB_TYPE.lower() == "postgresql":
if settings.DB_POSTGRESQL_PASSWORD:
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
else:
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
db_url = settings.DB_POSTGRESQL_URL()
else:
db_location = settings.CONFIG_PATH / 'user.db'
db_url = f"sqlite:///{db_location}"

View File

@@ -24,7 +24,7 @@ DB_TYPE=postgresql
# PostgreSQL 主机地址
DB_POSTGRESQL_HOST=localhost
# PostgreSQL 端口
# PostgreSQL 端口;使用 Unix Socket 时可留空
DB_POSTGRESQL_PORT=5432
# PostgreSQL 数据库名
@@ -43,6 +43,21 @@ DB_POSTGRESQL_POOL_SIZE=20
DB_POSTGRESQL_MAX_OVERFLOW=30
```
### 3. Unix Socket 连接
如果 PostgreSQL 通过 Unix Socket 暴露,可以把 `DB_POSTGRESQL_HOST` 设置为套接字目录。
```bash
DB_TYPE=postgresql
DB_POSTGRESQL_HOST=/var/run/postgresql
DB_POSTGRESQL_PORT=
DB_POSTGRESQL_DATABASE=moviepilot
DB_POSTGRESQL_USERNAME=moviepilot
DB_POSTGRESQL_PASSWORD=moviepilot
```
如需显式指定 socket 端口,也可以保留 `DB_POSTGRESQL_PORT`,程序会生成带 `host=/path/to/socket` 查询参数的 PostgreSQL URL。
## Docker 部署
### 使用外部 PostgreSQL
@@ -60,6 +75,13 @@ DB_POSTGRESQL_USERNAME=your-username
DB_POSTGRESQL_PASSWORD=your-password
```
使用 Redis Unix Socket 时,可直接设置 `CACHE_BACKEND_URL`,例如:
```bash
CACHE_BACKEND_TYPE=redis
CACHE_BACKEND_URL=unix:///var/run/redis/redis.sock?db=0
```
## 数据迁移
### 从 SQLite 迁移到 PostgreSQL

View File

@@ -0,0 +1,101 @@
import sys
import unittest
from enum import Enum
from types import ModuleType
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
_stub_module(
"app.log",
logger=_DummyLogger(),
log_settings=_DummyLogger(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("psutil")
_schemas_module = _stub_module(
"app.schemas", MediaType=Enum("MediaType", {"Movie": "Movie", "TV": "TV"})
)
_schemas_module.__getattr__ = lambda name: type(name, (), {})
_stub_module("version", APP_VERSION="test")
from app.core.config import Settings
class PostgreSQLSocketConfigTests(unittest.TestCase):
def test_postgresql_tcp_url_keeps_host_and_port(self):
settings = Settings(
DB_POSTGRESQL_HOST="db",
DB_POSTGRESQL_PORT="5433",
DB_POSTGRESQL_DATABASE="moviepilot",
DB_POSTGRESQL_USERNAME="user",
DB_POSTGRESQL_PASSWORD="pass",
)
self.assertFalse(settings.DB_POSTGRESQL_SOCKET_MODE)
self.assertEqual(
settings.DB_POSTGRESQL_URL(),
"postgresql://user:pass@db:5433/moviepilot",
)
self.assertEqual(
settings.DB_POSTGRESQL_URL("asyncpg"),
"postgresql+asyncpg://user:pass@db:5433/moviepilot",
)
self.assertEqual(settings.DB_POSTGRESQL_TARGET, "db:5433")
def test_postgresql_socket_url_uses_host_query_param(self):
settings = Settings(
DB_POSTGRESQL_HOST="/var/run/postgresql",
DB_POSTGRESQL_PORT="",
DB_POSTGRESQL_DATABASE="moviepilot",
DB_POSTGRESQL_USERNAME="user",
DB_POSTGRESQL_PASSWORD="pass",
)
self.assertTrue(settings.DB_POSTGRESQL_SOCKET_MODE)
self.assertIsNone(settings.DB_POSTGRESQL_PORT_VALUE)
self.assertEqual(
settings.DB_POSTGRESQL_URL(),
"postgresql://user:pass@/moviepilot?host=%2Fvar%2Frun%2Fpostgresql",
)
self.assertEqual(
settings.DB_POSTGRESQL_URL("asyncpg"),
"postgresql+asyncpg://user:pass@/moviepilot?host=%2Fvar%2Frun%2Fpostgresql",
)
self.assertEqual(settings.DB_POSTGRESQL_TARGET, "socket /var/run/postgresql")
def test_postgresql_socket_url_can_keep_explicit_port(self):
settings = Settings(
DB_POSTGRESQL_HOST="/var/run/postgresql",
DB_POSTGRESQL_PORT="5432",
DB_POSTGRESQL_DATABASE="moviepilot",
DB_POSTGRESQL_USERNAME="user",
DB_POSTGRESQL_PASSWORD="",
)
self.assertEqual(
settings.DB_POSTGRESQL_URL(),
"postgresql://user@/moviepilot?host=%2Fvar%2Frun%2Fpostgresql&port=5432",
)
self.assertEqual(
settings.DB_POSTGRESQL_TARGET,
"socket /var/run/postgresql (port 5432)",
)
if __name__ == "__main__":
unittest.main()