"""2.2.14 统一媒体身份字段并补齐 Bangumi/AniList ID Revision ID: f7b2d5c9a301 Revises: e6a1c4b8d2f0 Create Date: 2026-07-21 """ from typing import Iterable from alembic import op import sqlalchemy as sa revision = "f7b2d5c9a301" down_revision = "e6a1c4b8d2f0" branch_labels = None depends_on = None def _has_column( inspector: sa.Inspector, table_name: str, column_name: str, ) -> bool: """检查数据表是否已存在指定字段。""" if table_name not in inspector.get_table_names(): return False return any( column["name"] == column_name for column in inspector.get_columns(table_name) ) def _has_index( inspector: sa.Inspector, table_name: str, index_name: str, ) -> bool: """检查数据表是否已存在指定索引。""" if table_name not in inspector.get_table_names(): return False return any( index["name"] == index_name for index in inspector.get_indexes(table_name) ) def _column_names(table_name: str) -> set[str]: """读取数据表当前全部字段名。""" inspector = sa.inspect(op.get_bind()) if table_name not in inspector.get_table_names(): return set() return { column["name"] for column in inspector.get_columns(table_name) } def _add_columns(table_name: str, columns: Iterable[sa.Column]) -> None: """为指定表补充尚不存在的字段。""" for column in columns: inspector = sa.inspect(op.get_bind()) if not _has_column(inspector, table_name, column.name): op.add_column(table_name, column) def _create_index(table_name: str, index_name: str, columns: list[str]) -> None: """为指定表创建尚不存在的索引。""" inspector = sa.inspect(op.get_bind()) if not _has_index(inspector, table_name, index_name): op.create_index(index_name, table_name, columns) def _backfill_media_identity(table_name: str) -> None: """使用表中实际存在的兼容 ID 幂等回填统一媒体身份。""" existing_columns = _column_names(table_name) columns = [ sa.column("media_source", sa.String()), sa.column("media_id", sa.String()), ] identity_columns = { "mediaid": sa.String, "tmdbid": sa.Integer, "doubanid": sa.String, "bangumiid": sa.Integer, "anilistid": sa.Integer, } columns.extend( sa.column(column_name, column_type()) for column_name, column_type in identity_columns.items() if column_name in existing_columns ) table = sa.table(table_name, *columns) connection = op.get_bind() if "mediaid" in existing_columns: for prefix, source in ( ("tmdb", "themoviedb"), ("themoviedb", "themoviedb"), ("douban", "douban"), ("bangumi", "bangumi"), ("anilist", "anilist"), ): connection.execute( table.update() .where(table.c.media_id.is_(None)) .where(table.c.mediaid.like(f"{prefix}:%")) .values( media_source=source, media_id=sa.func.substr(table.c.mediaid, len(prefix) + 2), ) ) for source, field in ( ("themoviedb", "tmdbid"), ("douban", "doubanid"), ("bangumi", "bangumiid"), ("anilist", "anilistid"), ): if field not in existing_columns: continue identity_column = table.c[field] connection.execute( table.update() .where(table.c.media_id.is_(None)) .where(identity_column.is_not(None)) .values( media_source=source, media_id=sa.cast(identity_column, sa.String()), ) ) def upgrade() -> None: """升级媒体身份字段并迁移存量数据。""" _add_columns("subscribe", ( sa.Column("anilistid", sa.Integer(), nullable=True), sa.Column("media_source", sa.String(), nullable=True), sa.Column("media_id", sa.String(), nullable=True), )) _add_columns("subscribehistory", ( sa.Column("anilistid", sa.Integer(), nullable=True), sa.Column("media_source", sa.String(), nullable=True), sa.Column("media_id", sa.String(), nullable=True), )) _add_columns("downloadhistory", ( sa.Column("bangumiid", sa.Integer(), nullable=True), sa.Column("anilistid", sa.Integer(), nullable=True), sa.Column("media_source", sa.String(), nullable=True), sa.Column("media_id", sa.String(), nullable=True), )) _add_columns("transferhistory", ( sa.Column("bangumiid", sa.Integer(), nullable=True), sa.Column("anilistid", sa.Integer(), nullable=True), )) _add_columns("downloadfailure", ( sa.Column("bangumiid", sa.Integer(), nullable=True), sa.Column("anilistid", sa.Integer(), nullable=True), sa.Column("media_source", sa.String(), nullable=True), sa.Column("media_id", sa.String(), nullable=True), )) for table_name, fields in { "subscribe": ("anilistid", "media_source", "media_id"), "subscribehistory": ("anilistid", "media_source", "media_id"), "downloadhistory": ("bangumiid", "anilistid", "media_source", "media_id"), "transferhistory": ("bangumiid", "anilistid"), }.items(): for field in fields: _create_index(table_name, f"ix_{table_name}_{field}", [field]) for table_name in ("subscribe", "subscribehistory", "downloadhistory", "transferhistory"): _create_index( table_name, f"ix_{table_name}_media_identity", ["media_source", "media_id"], ) _create_index( "downloadfailure", "ix_downloadfailure_media_identity_site", ["type", "media_source", "media_id", "site"], ) _backfill_media_identity("subscribe") _backfill_media_identity("subscribehistory") _backfill_media_identity("downloadhistory") _backfill_media_identity("transferhistory") _backfill_media_identity("downloadfailure") def downgrade() -> None: """回滚统一媒体身份及 Bangumi/AniList 字段。""" for table_name, index_names in { "subscribe": ( "ix_subscribe_media_identity", "ix_subscribe_media_id", "ix_subscribe_media_source", "ix_subscribe_anilistid", ), "subscribehistory": ( "ix_subscribehistory_media_identity", "ix_subscribehistory_media_id", "ix_subscribehistory_media_source", "ix_subscribehistory_anilistid", ), "downloadhistory": ( "ix_downloadhistory_media_identity", "ix_downloadhistory_media_id", "ix_downloadhistory_media_source", "ix_downloadhistory_anilistid", "ix_downloadhistory_bangumiid", ), "transferhistory": ( "ix_transferhistory_media_identity", "ix_transferhistory_anilistid", "ix_transferhistory_bangumiid", ), "downloadfailure": ("ix_downloadfailure_media_identity_site",), }.items(): for index_name in index_names: inspector = sa.inspect(op.get_bind()) if _has_index(inspector, table_name, index_name): op.drop_index(index_name, table_name=table_name) for table_name, fields in { "subscribe": ("media_id", "media_source", "anilistid"), "subscribehistory": ("media_id", "media_source", "anilistid"), "downloadhistory": ("media_id", "media_source", "anilistid", "bangumiid"), "transferhistory": ("anilistid", "bangumiid"), "downloadfailure": ("media_id", "media_source", "anilistid", "bangumiid"), }.items(): for field in fields: inspector = sa.inspect(op.get_bind()) if _has_column(inspector, table_name, field): op.drop_column(table_name, field)