mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 08:43:37 +08:00
feat(media): support extensible media sources
This commit is contained in:
@@ -7,6 +7,7 @@ Create Date: 2026-08-12
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
import re
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
@@ -69,16 +70,15 @@ SOURCE_ALIASES = {
|
||||
"tencentvideodiscover": "tencentvideodiscover",
|
||||
"tencent_video": "tencentvideodiscover",
|
||||
}
|
||||
MEDIA_SOURCE_VALUES = frozenset(SOURCE_ALIASES.values())
|
||||
MEDIA_SOURCE_SQL_VALUES = ", ".join(
|
||||
f"'{source}'" for source in sorted(MEDIA_SOURCE_VALUES)
|
||||
)
|
||||
MEDIA_IDENTITY_CHECK_SQL = (
|
||||
"(media_source IS NULL AND media_id IS NULL) OR "
|
||||
"(media_source IS NOT NULL AND "
|
||||
f"media_source IN ({MEDIA_SOURCE_SQL_VALUES}) AND "
|
||||
"trim(media_source) <> '' AND media_source = lower(trim(media_source)) AND "
|
||||
"length(media_source) <= 64 AND media_source NOT LIKE '%:%' AND "
|
||||
"media_source NOT LIKE '% %' AND "
|
||||
"media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')"
|
||||
)
|
||||
MEDIA_SOURCE_PATTERN = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
|
||||
|
||||
|
||||
def _inspector() -> sa.Inspector:
|
||||
@@ -118,7 +118,7 @@ def _identity_missing(table: sa.TableClause):
|
||||
|
||||
|
||||
def _normalize_existing_sources(table_name: str) -> None:
|
||||
"""把旧版本允许的来源别名规范化为当前枚举值。"""
|
||||
"""规范内置来源别名,并保留插件注册的扩展来源标识。"""
|
||||
table = sa.table(
|
||||
table_name,
|
||||
sa.column("media_source", sa.String()),
|
||||
@@ -133,7 +133,7 @@ def _normalize_existing_sources(table_name: str) -> None:
|
||||
connection.execute(
|
||||
table.update()
|
||||
.where(table.c.media_source.is_not(None))
|
||||
.values(media_source=sa.func.trim(table.c.media_source))
|
||||
.values(media_source=sa.func.lower(sa.func.trim(table.c.media_source)))
|
||||
)
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ def _clear_invalid_or_partial_identity(table_name: str) -> None:
|
||||
invalid_identity = sa.or_(
|
||||
table.c.media_source.is_(None),
|
||||
sa.func.trim(table.c.media_source) == "",
|
||||
sa.func.lower(sa.func.trim(table.c.media_source)).not_in(
|
||||
MEDIA_SOURCE_VALUES
|
||||
),
|
||||
sa.func.length(sa.func.trim(table.c.media_source)) > 64,
|
||||
sa.func.trim(table.c.media_source).contains(":"),
|
||||
sa.func.trim(table.c.media_source).contains(" "),
|
||||
table.c.media_id.is_(None),
|
||||
sa.func.trim(table.c.media_id) == "",
|
||||
sa.func.trim(table.c.media_id) == "0",
|
||||
@@ -167,11 +167,12 @@ def _clear_invalid_or_partial_identity(table_name: str) -> None:
|
||||
|
||||
|
||||
def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None:
|
||||
"""从旧的 ``prefix:id`` 组合字段回填规范身份。"""
|
||||
"""从旧的 ``prefix:id`` 组合字段回填内置或插件扩展身份。"""
|
||||
if "mediaid" not in columns:
|
||||
return
|
||||
table = sa.table(
|
||||
table_name,
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column("mediaid", sa.String()),
|
||||
sa.column("media_source", sa.String()),
|
||||
sa.column("media_id", sa.String()),
|
||||
@@ -205,6 +206,32 @@ def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# 插件来源无法预先枚举,已知别名批量回填后再解析剩余合法前缀。
|
||||
connection = op.get_bind()
|
||||
rows = connection.execute(
|
||||
sa.select(table.c.id, table.c.mediaid)
|
||||
.where(_identity_missing(table))
|
||||
.where(table.c.mediaid.is_not(None))
|
||||
).mappings().all()
|
||||
for row in rows:
|
||||
raw_media_id = str(row["mediaid"]).strip()
|
||||
raw_source, separator, raw_native_id = raw_media_id.partition(":")
|
||||
media_source = raw_source.strip().casefold()
|
||||
media_id = raw_native_id.strip()
|
||||
if (
|
||||
not separator
|
||||
or not MEDIA_SOURCE_PATTERN.fullmatch(media_source)
|
||||
or not media_id
|
||||
or media_id == "0"
|
||||
):
|
||||
continue
|
||||
connection.execute(
|
||||
table.update()
|
||||
.where(table.c.id == row["id"])
|
||||
.where(_identity_missing(table))
|
||||
.values(media_source=media_source, media_id=media_id)
|
||||
)
|
||||
|
||||
|
||||
def _backfill_source_columns(table_name: str, columns: set[str]) -> None:
|
||||
"""按确定优先级从旧的来源专用字段回填规范身份。"""
|
||||
@@ -302,7 +329,7 @@ def _ensure_identity_indexes() -> None:
|
||||
|
||||
|
||||
def _ensure_identity_constraints() -> None:
|
||||
"""为六张通用媒体表建立来源枚举与身份成对数据库约束。"""
|
||||
"""为六张通用媒体表建立可扩展来源与身份成对数据库约束。"""
|
||||
for table_name in LEGACY_COLUMNS:
|
||||
if not _has_table(table_name):
|
||||
continue
|
||||
|
||||
74
database/versions/b3d7e9f1a2c4_3_0_0.py
Normal file
74
database/versions/b3d7e9f1a2c4_3_0_0.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""3.0.0
|
||||
允许插件扩展媒体来源
|
||||
|
||||
Revision ID: b3d7e9f1a2c4
|
||||
Revises: e3d9f4b7c806
|
||||
Create Date: 2026-08-13
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b3d7e9f1a2c4"
|
||||
down_revision = "e3d9f4b7c806"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
MEDIA_TABLES = (
|
||||
"subscribe",
|
||||
"subscribehistory",
|
||||
"downloadhistory",
|
||||
"transferhistory",
|
||||
"downloadfailure",
|
||||
"mediaserveritem",
|
||||
)
|
||||
EXTENSIBLE_IDENTITY_CHECK_SQL = (
|
||||
"(media_source IS NULL AND media_id IS NULL) OR "
|
||||
"(media_source IS NOT NULL AND "
|
||||
"trim(media_source) <> '' AND media_source = lower(trim(media_source)) AND "
|
||||
"length(media_source) <= 64 AND media_source NOT LIKE '%:%' AND "
|
||||
"media_source NOT LIKE '% %' AND "
|
||||
"media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')"
|
||||
)
|
||||
BUILTIN_IDENTITY_CHECK_SQL = (
|
||||
"(media_source IS NULL AND media_id IS NULL) OR "
|
||||
"(media_source IS NOT NULL AND media_source IN ("
|
||||
"'anilist', 'bangumi', 'bilibili', 'douban', 'doubanmusic', 'imdb', "
|
||||
"'mangguodiscover', 'migu', 'musicbrainz', 'tencentvideodiscover', "
|
||||
"'theaudiodb', 'themoviedb', 'tvdb') AND "
|
||||
"media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')"
|
||||
)
|
||||
|
||||
|
||||
def _inspector() -> sa.Inspector:
|
||||
"""返回使用当前迁移连接的数据库检查器。"""
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _replace_constraints(check_sql: str) -> None:
|
||||
"""在现有媒体表上以批处理方式替换统一身份约束。"""
|
||||
table_names = set(_inspector().get_table_names())
|
||||
for table_name in MEDIA_TABLES:
|
||||
if table_name not in table_names:
|
||||
continue
|
||||
constraint_name = f"ck_{table_name}_media_identity"
|
||||
existing = {
|
||||
constraint.get("name")
|
||||
for constraint in _inspector().get_check_constraints(table_name)
|
||||
}
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
if constraint_name in existing:
|
||||
batch_op.drop_constraint(constraint_name, type_="check")
|
||||
batch_op.create_check_constraint(constraint_name, check_sql)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""把固定内置来源白名单替换为允许插件来源的格式约束。"""
|
||||
_replace_constraints(EXTENSIBLE_IDENTITY_CHECK_SQL)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""恢复只允许当前内置来源的旧约束。"""
|
||||
_replace_constraints(BUILTIN_IDENTITY_CHECK_SQL)
|
||||
Reference in New Issue
Block a user