mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
fix(notification): persist independent media posters
This commit is contained in:
@@ -817,8 +817,11 @@ class MessageTemplateHelper:
|
||||
@staticmethod
|
||||
def render(message: Message, *args: Any, **kwargs: Any) -> Optional[Message]:
|
||||
"""
|
||||
渲染消息模板
|
||||
从媒体上下文补充独立海报并渲染模板,保留渠道原有图片和显式海报。
|
||||
"""
|
||||
mediainfo = kwargs.get("mediainfo")
|
||||
if isinstance(message, Message) and not message.poster and isinstance(mediainfo, (MediaInfo, MusicInfo)):
|
||||
message.poster = mediainfo.get_poster_image() or None
|
||||
if not MessageTemplateHelper.is_instance_valid(message):
|
||||
if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs):
|
||||
logger.info("将使用模板渲染消息内容")
|
||||
|
||||
@@ -941,6 +941,7 @@ class DownloadSubmissionOwner(_DownloadResourceOwner):
|
||||
f"错误信息:{error_msg}"
|
||||
),
|
||||
image=prepared.media.get_message_image(),
|
||||
poster=prepared.media.get_poster_image() or None,
|
||||
userid=userid,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -316,6 +316,7 @@ class SubscribeNotificationOwner(_SubscribeOwnerBase):
|
||||
title=(f"{context.mediainfo.title_year} {context.metainfo.season} 添加订阅失败!"),
|
||||
text=err_msg,
|
||||
image=context.mediainfo.get_message_image(),
|
||||
poster=context.mediainfo.get_poster_image() or None,
|
||||
userid=context.userid,
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ class Message(Base):
|
||||
text: Mapped[Optional[str]] = mapped_column(String)
|
||||
# 图片
|
||||
image: Mapped[Optional[str]] = mapped_column(String)
|
||||
# 独立媒体海报,历史记录不从背景图猜测
|
||||
poster: Mapped[Optional[str]] = mapped_column(String)
|
||||
# 链接
|
||||
link: Mapped[Optional[str]] = mapped_column(String)
|
||||
# 用户ID
|
||||
|
||||
@@ -68,6 +68,7 @@ class MessageHistoryItem(BaseModel):
|
||||
title: Optional[str] = Field(default=None, description="标题")
|
||||
text: Optional[str] = Field(default=None, description="文本内容")
|
||||
image: Optional[str] = Field(default=None, description="图片")
|
||||
poster: Optional[str] = Field(default=None, description="独立媒体海报,不使用背景图回退")
|
||||
link: Optional[str] = Field(default=None, description="链接")
|
||||
userid: Optional[str] = Field(default=None, description="用户ID")
|
||||
reg_time: Optional[str] = Field(default=None, description="登记时间")
|
||||
@@ -274,6 +275,7 @@ class Message(BaseModel):
|
||||
title: Optional[str] = Field(default=None, description="标题")
|
||||
text: Optional[str] = Field(default=None, description="文本内容")
|
||||
image: Optional[str] = Field(default=None, description="图片")
|
||||
poster: Optional[str] = Field(default=None, description="独立媒体海报,不使用背景图回退")
|
||||
voice_path: Optional[str] = Field(default=None, description="语音文件路径")
|
||||
file_path: Optional[str] = Field(default=None, description="本地文件路径")
|
||||
file_name: Optional[str] = Field(default=None, description="发送时展示的文件名")
|
||||
|
||||
42
database/versions/e4a8c7b29016_3_1_9.py
Normal file
42
database/versions/e4a8c7b29016_3_1_9.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
3.1.9 为通知历史增加独立媒体海报。
|
||||
|
||||
版本标识:e4a8c7b29016
|
||||
前置版本:da73b6f20418
|
||||
创建时间:2026-10-24
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e4a8c7b29016"
|
||||
down_revision = "da73b6f20418"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_poster() -> bool:
|
||||
"""
|
||||
检查通知表是否已经由新 ORM 或迁移创建海报字段。
|
||||
"""
|
||||
return any(
|
||||
column["name"] == "poster"
|
||||
for column in sa.inspect(op.get_bind()).get_columns("message")
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
增加可空海报,不将历史渠道图片误认为媒体海报。
|
||||
"""
|
||||
if not _has_poster():
|
||||
op.add_column("message", sa.Column("poster", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
移除独立海报字段,保留原有图片与通知内容。
|
||||
"""
|
||||
if _has_poster():
|
||||
op.drop_column("message", "poster")
|
||||
@@ -28,6 +28,10 @@
|
||||
|
||||
### 通知配置与交互身份
|
||||
|
||||
`GET /api/v1/message/notification` 的可空 `poster` 字段提供独立媒体海报。
|
||||
新通知从已有媒体上下文提取海报;`image` 继续保留原有渠道图片语义。
|
||||
旧通知及没有海报的通知返回 `poster: null`,客户端不应将背景图裁切成海报。
|
||||
|
||||
`POST /api/v1/notification/config` 保存通知渠道时要求名称和稳定 `id` 各自唯一。
|
||||
改名应保留原 `id`;复制为新的渠道实例应使用新的 `id`,避免共享登录凭据。
|
||||
通过消息渠道发起普通重启时,完成回执保留发起时的 `source` 配置。
|
||||
|
||||
@@ -193,7 +193,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
|
||||
- Purpose: Stores inbound and outbound messages, channels, content, attachments, users, and timestamps.
|
||||
- Useful queries: Paging notification history, distinguishing direction, or tracing duplicates by source.
|
||||
- Write boundary: Written by messaging and notification services; clean it through the message API or retention job.
|
||||
- Columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `link`, `userid`, `reg_time`, `action`, `note`
|
||||
- Columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `poster`, `link`, `userid`, `reg_time`, `action`, `note`
|
||||
|
||||
### `outboxmessage`
|
||||
- Purpose: Stores externally visible side-effect intents committed atomically with business transactions.
|
||||
|
||||
122
tests/test_message_posters.py
Normal file
122
tests/test_message_posters.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
验证通知独立海报经过渲染、持久化、查询和事件投影,且不改变渠道图片。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
from app.api.endpoints.message import get_notification_message
|
||||
from app.application.messaging.message import MessageQueryService, MessageTemplateHelper
|
||||
from app.chain.base import ChainBase
|
||||
from app.db.oper.message import MessageOper
|
||||
from app.db.session import AsyncSessionFactory
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.schemas.message import Message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_poster", [True, False])
|
||||
def test_render_preserves_backdrop_and_projects_only_real_poster(has_poster: bool) -> None:
|
||||
"""
|
||||
即使消息已经有正文也应提取独立海报,缺失时不得从背景图回退。
|
||||
"""
|
||||
media = MediaInfo(
|
||||
title="测试影片",
|
||||
backdrop_path="https://images.example.com/backdrop.jpg",
|
||||
poster_path="https://images.example.com/poster.jpg" if has_poster else None,
|
||||
)
|
||||
message = Message(title="已完成整理", image=media.get_message_image())
|
||||
|
||||
rendered = MessageTemplateHelper.render(message, mediainfo=media)
|
||||
|
||||
assert rendered is message
|
||||
assert message.image == "https://images.example.com/backdrop.jpg"
|
||||
assert message.poster == ("https://images.example.com/poster.jpg" if has_poster else None)
|
||||
event = ChainBase._build_notice_message_data(message)
|
||||
assert event["poster"] == message.poster
|
||||
assert event["image"] == message.image
|
||||
|
||||
|
||||
def test_render_keeps_explicit_poster_and_supports_music_cover() -> None:
|
||||
"""
|
||||
显式海报优先,音乐媒体使用已有封面,无媒体上下文不猜测图片类型。
|
||||
"""
|
||||
music = MusicInfo(title="测试专辑", cover_url="https://images.example.com/album.jpg")
|
||||
message = Message(title="入库完成", poster="https://images.example.com/custom.jpg")
|
||||
assert MessageTemplateHelper.render(message, mediainfo=music).poster == message.poster
|
||||
assert MessageTemplateHelper.render(Message(title="专辑通知"), mediainfo=music).poster == music.cover_url
|
||||
assert MessageTemplateHelper.render(Message(title="普通通知", image=music.cover_url)).poster is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("async_write", [False, True])
|
||||
def test_notification_history_preserves_poster_and_legacy_null(async_write: bool) -> None:
|
||||
"""
|
||||
同步和异步写入均应通过真实通知查询返回独立海报,同时兼容旧消息。
|
||||
"""
|
||||
title = f"poster-contract-{uuid4().hex}"
|
||||
payload = {
|
||||
"title": title,
|
||||
"image": "https://images.example.com/backdrop.jpg",
|
||||
"poster": "https://images.example.com/poster.jpg",
|
||||
}
|
||||
|
||||
async def write_and_query() -> None:
|
||||
"""
|
||||
仅使用测试临时数据库保存消息,并经 API schema 读取结果。
|
||||
"""
|
||||
async with AsyncSessionFactory() as db:
|
||||
if async_write:
|
||||
await MessageOper(db).async_add(**payload)
|
||||
await MessageOper(db).async_add(title=f"{title}-legacy", image=payload["image"])
|
||||
await db.commit()
|
||||
else:
|
||||
MessageOper().add(**payload)
|
||||
MessageOper().add(title=f"{title}-legacy", image=payload["image"])
|
||||
messages = await get_notification_message(service=MessageQueryService(MessageOper(db)))
|
||||
current = next(item for item in messages if item.title == title)
|
||||
legacy = next(item for item in messages if item.title == f"{title}-legacy")
|
||||
assert current.image == payload["image"]
|
||||
assert current.poster == payload["poster"]
|
||||
assert legacy.image == payload["image"]
|
||||
assert legacy.poster is None
|
||||
|
||||
asyncio.run(write_and_query())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("already_created", [False, True])
|
||||
def test_message_poster_migration_preserves_existing_images(monkeypatch, already_created: bool) -> None:
|
||||
"""
|
||||
迁移支持旧表和新 ORM 已建表,重复执行及回滚不会改写历史背景图。
|
||||
"""
|
||||
migration = importlib.import_module("database.versions.e4a8c7b29016_3_1_9")
|
||||
engine = sa.create_engine("sqlite://")
|
||||
metadata = sa.MetaData()
|
||||
columns = [sa.Column("id", sa.Integer(), primary_key=True), sa.Column("image", sa.String())]
|
||||
if already_created:
|
||||
columns.append(sa.Column("poster", sa.String(), nullable=True))
|
||||
messages = sa.Table("message", metadata, *columns)
|
||||
with engine.begin() as connection:
|
||||
metadata.create_all(connection)
|
||||
connection.execute(messages.insert(), {"id": 1, "image": "legacy-backdrop.jpg"})
|
||||
monkeypatch.setattr(migration, "op", Operations(MigrationContext.configure(connection)))
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
migrated = sa.Table("message", sa.MetaData(), autoload_with=connection)
|
||||
row = connection.execute(sa.select(migrated)).mappings().one()
|
||||
assert row["image"] == "legacy-backdrop.jpg"
|
||||
assert row["poster"] is None
|
||||
connection.execute(migrated.insert(), {"id": 2, "image": "backdrop.jpg", "poster": "poster.jpg"})
|
||||
assert connection.execute(sa.select(migrated.c.poster).where(migrated.c.id == 2)).scalar_one() == "poster.jpg"
|
||||
migration.downgrade()
|
||||
migration.downgrade()
|
||||
restored = sa.Table("message", sa.MetaData(), autoload_with=connection)
|
||||
assert "poster" not in restored.c
|
||||
assert connection.execute(sa.select(restored.c.image).order_by(restored.c.id)).scalars().all() == [
|
||||
"legacy-backdrop.jpg", "backdrop.jpg",
|
||||
]
|
||||
engine.dispose()
|
||||
@@ -87,7 +87,8 @@ def test_identity_upgrade_chain_preserves_plugin_installation_snapshot(monkeypat
|
||||
config = Config()
|
||||
config.set_main_option("script_location", "database")
|
||||
script = ScriptDirectory.from_config(config)
|
||||
assert script.get_heads() == ["da73b6f20418"]
|
||||
assert script.get_heads() == ["e4a8c7b29016"]
|
||||
assert script.get_revision("e4a8c7b29016").down_revision == "da73b6f20418"
|
||||
assert script.get_revision("da73b6f20418").down_revision == "c9e2f6a04173"
|
||||
assert script.get_revision("c9e2f6a04173").down_revision == "b8d2f5a01962"
|
||||
predecessor = importlib.import_module("database.versions.b8d2f5a01962_plugin_data_identity")
|
||||
|
||||
Reference in New Issue
Block a user