Files
MoviePilot/tests/test_db_transferhistory_queries.py
T

533 lines
23 KiB
Python

"""
整理历史表的查询行为。
整理历史同时承担三个职责:查重(这个文件整理过没有)、溯源(这个媒体库文件是从哪
来的)、统计。查重误判会重复整理或永久漏件——挂载故障那一类问题最终就落在这张表上;
溯源查错会让「重新整理」把不相干的文件搬走。
"""
import asyncio
import time as _time
import pytest
from app.db.models import transferhistory as transferhistory_module
from app.db.models.transferhistory import TransferHistory
from app.schemas.types import MediaSource, MediaType
TMDB = str(MediaSource.TMDB)
@pytest.fixture(autouse=True)
def _track(db):
"""把整理历史表纳入用例级回收。"""
db.watermark(TransferHistory)
def _hist(title: str = "片名", src: str = None, dest: str = None,
src_storage: str = "local", dest_storage: str = "local",
mode: str = "move", status: bool = True, mtype: str = None,
year: str = "2026", media_id: str = "6001", seasons: str = None,
episodes: str = None, date: str = "2026-08-13 10:00:00",
download_hash: str = None) -> TransferHistory:
"""构造一条整理历史。"""
return TransferHistory(src=src or f"/downloads/{title}.mkv", src_storage=src_storage,
dest=dest or f"/media/{title}.mkv", dest_storage=dest_storage,
mode=mode, type=mtype or MediaType.TV.value, title=title,
year=year, media_source=TMDB, media_id=media_id,
seasons=seasons, episodes=episodes, status=status,
date=date, download_hash=download_hash, files=[])
# --------------------------------------------------------------------------- #
# 按路径溯源
# --------------------------------------------------------------------------- #
def test_get_by_src_scopes_by_storage(db):
"""
按源路径查询可限定存储,不限定时取主键最大的那条。
同一路径在不同存储下是不同文件;不限定存储会把本地盘的记录当成网盘的。
表上有 (src, src_storage) 唯一约束,所以「最新一条」只在跨存储时才有分歧。
"""
db.add(_hist("本地", src="/data/same.mkv", src_storage="local"))
latest = db.add(_hist("网盘", src="/data/same.mkv", src_storage="alist"))
assert TransferHistory.get_by_src(db.session, "/data/same.mkv",
storage="alist").title == "网盘"
assert TransferHistory.get_by_src(db.session, "/data/same.mkv",
storage="local").title == "本地"
assert TransferHistory.get_by_src(db.session, "/data/same.mkv").id == latest.id
assert TransferHistory.get_by_src(db.session, "/data/none.mkv") is None
def test_get_success_by_src_matches_path_verbatim(db):
"""
成功记录按源路径原样精确匹配,不做归一化。
蓝光原盘目录的记录带尾斜杠,归一化后反而匹配不到自己。
"""
db.add(_hist("原盘", src="/data/BDMV/", status=True),
_hist("失败的", src="/data/fail.mkv", status=False))
assert TransferHistory.get_success_by_src(db.session, "/data/BDMV/").title == "原盘"
assert TransferHistory.get_success_by_src(db.session, "/data/BDMV") is None
assert TransferHistory.get_success_by_src(db.session, "/data/fail.mkv") is None
def test_get_success_by_src_can_scope_by_storage(db):
"""
成功记录查询同样支持按源存储收窄。
"""
db.add(_hist("本地", src="/data/x.mkv", src_storage="local"),
_hist("网盘", src="/data/x.mkv", src_storage="alist"))
assert TransferHistory.get_success_by_src(db.session, "/data/x.mkv",
storage="alist").title == "网盘"
def test_get_by_dest_scopes_by_storage_and_takes_newest(db):
"""
按目标路径查询用于从媒体库反查来源,可限定目标存储并取最新一条。
"""
db.add(_hist("旧", src="/data/d1.mkv", dest="/media/same.mkv", dest_storage="local"))
newest = db.add(_hist("新", src="/data/d2.mkv", dest="/media/same.mkv",
dest_storage="local"))
db.add(_hist("别的存储", src="/data/d3.mkv", dest="/media/same.mkv",
dest_storage="alist"))
assert TransferHistory.get_by_dest(db.session, "/media/same.mkv",
storage="local").id == newest.id
assert TransferHistory.get_by_dest(db.session, "/media/same.mkv",
storage="alist").title == "别的存储"
assert TransferHistory.get_by_dest(db.session, "/media/none.mkv") is None
def test_list_success_by_src_normalizes_the_path(db):
"""
列举成功记录时对入参路径做归一化(反斜杠、尾斜杠)。
Windows 侧传来的路径带反斜杠,不归一化会一条都匹配不到。
"""
db.add(_hist("目录", src="/data/dir"))
for probe in ("/data/dir", "/data/dir/", "\\data\\dir"):
assert [h.title for h in TransferHistory.list_success_by_src(db.session, probe)] == ["目录"]
def test_list_success_by_src_recursive_matches_children_only(db):
"""
递归模式匹配目录自身及其子项,但不能误伤同前缀的兄弟目录。
直接用 `like("前缀%")` 会把 /data/dir2 也算成 /data/dir 的子项,
重新整理时会把邻居目录一起搬走。
"""
db.add(_hist("自身", src="/data/dir"),
_hist("子项", src="/data/dir/a.mkv"),
_hist("兄弟", src="/data/dir2/b.mkv"))
titles = {h.title for h in TransferHistory.list_success_by_src(
db.session, "/data/dir", recursive=True)}
assert titles == {"自身", "子项"}
def test_list_success_by_src_escapes_like_wildcards(db):
"""
路径中的 % 与 _ 必须被转义,否则会被当成通配符匹配到别的目录。
"""
db.add(_hist("含下划线", src="/data/a_b/x.mkv"),
_hist("被误伤", src="/data/axb/y.mkv"))
titles = {h.title for h in TransferHistory.list_success_by_src(
db.session, "/data/a_b", recursive=True)}
assert titles == {"含下划线"}
def test_list_success_move_by_dest_only_returns_move_mode(db):
"""
从媒体库现址发起重新整理时只认「移动」模式的记录。
硬链接/复制的源文件仍在原处,把它们算作可回溯的移动记录会导致重复整理。
"""
db.add(_hist("移动", src="/data/m.mkv", dest="/media/m.mkv", mode="move"),
_hist("硬链", src="/data/l.mkv", dest="/media/l.mkv", mode="link"),
_hist("移动失败", src="/data/f.mkv", dest="/media/f.mkv", mode="move",
status=False))
titles = {h.title for h in TransferHistory.list_success_move_by_dest(db.session, "/media/m.mkv")}
assert titles == {"移动"}
assert TransferHistory.list_success_move_by_dest(db.session, "/media/l.mkv") == []
assert TransferHistory.list_success_move_by_dest(db.session, "/media/f.mkv") == []
def test_list_success_move_by_dest_recursive_matches_children_only(db):
"""
目标侧的递归匹配同样不能误伤同前缀的兄弟目录。
"""
db.add(_hist("自身", src="/data/w1.mkv", dest="/media/show", mode="move"),
_hist("子项", src="/data/w2.mkv", dest="/media/show/s01.mkv", mode="move"),
_hist("兄弟", src="/data/w3.mkv", dest="/media/show2/s01.mkv", mode="move"))
titles = {h.title for h in TransferHistory.list_success_move_by_dest(
db.session, "/media/show", recursive=True)}
assert titles == {"自身", "子项"}
def test_replace_by_src_keeps_one_record_per_source(db):
"""
同一源路径在同一存储中只保留最新一条记录,且不波及其他存储。
先删后插是在同一事务内完成的:旧的「查一条再删一条」在遗留重复数据下会留下
脏记录,查重随后就会命中旧行。
"""
db.add(_hist("旧一", src="/data/dup.mkv", src_storage="local"),
_hist("别的存储", src="/data/dup.mkv", src_storage="alist"))
TransferHistory.replace_by_src(db.session, src="/data/dup.mkv", src_storage="local",
dest="/media/new.mkv", type=MediaType.TV.value,
title="新记录", status=True, date="2026-08-13 12:00:00")
local_rows = TransferHistory.list_success_by_src(db.session, "/data/dup.mkv",
storage="local")
assert [h.title for h in local_rows] == ["新记录"]
assert TransferHistory.get_by_src(db.session, "/data/dup.mkv",
storage="alist").title == "别的存储"
def test_replace_by_src_defaults_storage_to_local(db):
"""
未指定源存储时按 local 处理——历史数据没有这一列,缺省值必须与写入侧一致。
"""
created = TransferHistory.replace_by_src(db.session, src="/data/nostorage.mkv",
type=MediaType.TV.value, title="无存储",
status=True, date="2026-08-13 12:00:00")
assert created.src_storage == "local"
# --------------------------------------------------------------------------- #
# 按 hash / 身份查询
# --------------------------------------------------------------------------- #
def test_hash_lookups_return_single_and_all(db):
"""
按下载 hash 既可取单条也可取全部,未命中时分别是 None 与空列表。
"""
db.add(_hist("文件一", download_hash="th-1", src="/data/1.mkv"),
_hist("文件二", download_hash="th-1", src="/data/2.mkv"))
assert TransferHistory.get_by_hash(db.session, "th-1") is not None
assert len(TransferHistory.list_by_hash(db.session, "th-1")) == 2
assert TransferHistory.get_by_hash(db.session, "th-none") is None
assert TransferHistory.list_by_hash(db.session, "th-none") == []
def test_get_by_media_identity_requires_matching_type(db):
"""
按媒体身份查询时类型必须参与匹配,否则电影会命中同 ID 的剧集记录。
"""
db.add(_hist("剧集", media_id="6100", mtype=MediaType.TV.value))
assert TransferHistory.get_by_media_identity(
db.session, MediaSource.TMDB, "6100", MediaType.TV.value) is not None
assert TransferHistory.get_by_media_identity(
db.session, MediaSource.TMDB, "6100", MediaType.MOVIE.value) is None
def test_update_download_hash_writes_only_the_target_row(db):
"""
补写下载 hash 只影响指定的那一条记录。
"""
target = db.add(_hist("目标", src="/data/t.mkv"))
db.add(_hist("其他", src="/data/o.mkv"))
TransferHistory.update_download_hash(db.session, historyid=target.id,
download_hash="new-hash")
assert TransferHistory.get_by_src(db.session, "/data/t.mkv").download_hash == "new-hash"
assert TransferHistory.get_by_src(db.session, "/data/o.mkv").download_hash is None
@pytest.mark.parametrize("season,episode,dest,expected", [
("S01", "E01", "/media/s1e1.mkv", {"季集"}),
("S01", None, None, {"季集", "整季"}),
(None, None, None, {"季集", "整季", "另一季"}),
])
def test_list_by_media_identity_narrows_by_season_episode_and_dest(
db, season, episode, dest, expected):
"""
按媒体身份查询时季、集、目标路径逐级收窄。
收窄失效会让「这一集整理过没有」误判成整季都整理过,剩余剧集被永久跳过。
"""
db.add(_hist("季集", src="/data/e1.mkv", seasons="S01", episodes="E01",
dest="/media/s1e1.mkv"),
_hist("整季", src="/data/s1.mkv", seasons="S01", episodes=None,
dest="/media/s1.mkv"),
_hist("另一季", src="/data/s2.mkv", seasons="S02", episodes=None,
dest="/media/s2.mkv"))
got = TransferHistory.list_by(db.session, mtype=MediaType.TV.value,
media_source=MediaSource.TMDB, media_id="6001",
season=season, episode=episode, dest=dest)
assert {h.title for h in got} == expected
def test_list_by_media_identity_uses_dest_for_movies(db):
"""
电影没有季集,靠目标路径区分不同版本;给出目标路径即须生效。
"""
db.add(_hist("4K 版", src="/data/m4k.mkv", mtype=MediaType.MOVIE.value,
media_id="6200", dest="/media/movie-4k.mkv"),
_hist("1080 版", src="/data/m1080.mkv", mtype=MediaType.MOVIE.value,
media_id="6200", dest="/media/movie-1080.mkv"))
got = TransferHistory.list_by(db.session, mtype=MediaType.MOVIE.value,
media_source=MediaSource.TMDB, media_id="6200",
dest="/media/movie-4k.mkv")
assert [h.title for h in got] == ["4K 版"]
def test_list_by_falls_back_to_title_and_year(db):
"""
没有媒体身份时退回「标题 + 年份」,服务于识别失败的历史数据。
"""
db.add(_hist("回退标题", src="/data/fb.mkv", media_id="6300", year="2020",
seasons="S01"))
assert [h.title for h in TransferHistory.list_by(
db.session, title="回退标题", year="2020")] == ["回退标题"]
assert [h.title for h in TransferHistory.list_by(
db.session, title="回退标题", year="2020", season="S01")] == ["回退标题"]
assert TransferHistory.list_by(db.session, title="回退标题", year="1999") == []
def test_list_by_supports_type_season_and_dest_prefix(db):
"""
媒体服务器 webhook 缺少远端身份时,按「类型 + 季 + 目标路径前缀」查询。
这是该场景下唯一能定位记录的路径,丢了会让 webhook 触发的刮削全部落空。
"""
db.add(_hist("剧集一", src="/data/wh1.mkv", mtype=MediaType.TV.value, seasons="S01",
dest="/media/Show/Season 01/e01.mkv"),
_hist("别的剧", src="/data/wh2.mkv", mtype=MediaType.TV.value, seasons="S01",
dest="/media/Other/Season 01/e01.mkv"))
got = TransferHistory.list_by(db.session, mtype=MediaType.TV.value, season="S01",
dest="/media/Show/")
assert [h.title for h in got] == ["剧集一"]
def test_list_by_without_usable_criteria_returns_empty(db):
"""
条件不足以定位时返回空列表,不能退化成返回全表。
"""
db.add(_hist("任意"))
assert TransferHistory.list_by(db.session) == []
assert TransferHistory.list_by(db.session, title="只有标题") == []
# --------------------------------------------------------------------------- #
# 列表、计数与统计
# --------------------------------------------------------------------------- #
def test_list_by_page_filters_status_and_supports_unbounded_count(db):
"""
分页可按成功状态收窄;count 为负表示不分页返回全部。
负数这条约定是「导出全部历史」依赖的,当成普通 limit 处理会返回空。
"""
db.add(_hist("成功一", src="/data/ok1.mkv", status=True, date="2026-08-13 10:00:01"),
_hist("成功二", src="/data/ok2.mkv", status=True, date="2026-08-13 10:00:02"),
_hist("失败", src="/data/ng.mkv", status=False, date="2026-08-13 10:00:03"))
assert [h.title for h in TransferHistory.list_by_page(db.session, page=1, count=1,
status=True)] == ["成功二"]
assert {h.title for h in TransferHistory.list_by_page(db.session, count=-1,
status=False)} == {"失败"}
assert len(TransferHistory.list_by_page(db.session, count=-1)) >= 3
def test_list_by_title_searches_title_source_and_destination(db):
"""
标题检索同时匹配标题、源路径与目标路径,且大小写不敏感。
只匹配标题会让用户按文件名搜不到任何记录。
"""
db.add(_hist("Alpha", src="/downloads/zzz.mkv", dest="/media/zzz.mkv"),
_hist("Beta", src="/downloads/AlphaFile.mkv", dest="/media/beta.mkv"),
_hist("Gamma", src="/downloads/g.mkv", dest="/media/alpha-dir/g.mkv"))
titles = {h.title for h in TransferHistory.list_by_title(db.session, "alpha", count=-1)}
assert titles == {"Alpha", "Beta", "Gamma"}
def test_list_by_title_wildcard_mode_takes_the_pattern_verbatim(db):
"""
通配模式下调用方自带 % 通配符,不再额外包裹。
"""
db.add(_hist("PrefixMatch", src="/downloads/p.mkv", dest="/media/p.mkv"),
_hist("NoPrefix", src="/downloads/n.mkv", dest="/media/n.mkv"))
titles = {h.title for h in TransferHistory.list_by_title(
db.session, "Prefix%", count=-1, wildcard=True)}
assert titles == {"PrefixMatch"}
def test_list_by_title_can_filter_status(db):
"""
检索结果同样支持按成功状态收窄。
"""
db.add(_hist("SearchOk", status=True, src="/downloads/ok.mkv"),
_hist("SearchFail", status=False, src="/downloads/fail.mkv"))
assert [h.title for h in TransferHistory.list_by_title(
db.session, "SearchFail", count=-1, status=False)] == ["SearchFail"]
def test_list_by_title_matches_async_twin(db):
"""
同步与异步的标题检索必须返回同一批记录。
"""
db.add(_hist("ParallelSearch", src="/downloads/par.mkv"))
sync_titles = [h.title for h in TransferHistory.list_by_title(
db.session, "ParallelSearch", count=-1)]
async_titles = [h.title for h in db.run_async_session(
lambda session: TransferHistory.async_list_by_title(
session, title="ParallelSearch", count=-1
)
)]
assert sync_titles == async_titles
def test_count_and_count_by_title_match_async_twins(db):
"""
计数与带条件计数的同步、异步结果必须一致——分页总数由它决定,
对不上就会出现「翻到最后一页是空的」。
"""
db.add(_hist("CountMe", status=True, src="/downloads/c1.mkv", dest="/media/c1.mkv"),
_hist("CountMe", status=False, src="/downloads/c2.mkv", dest="/media/c2.mkv"))
assert TransferHistory.count(db.session) == db.run_async_session(
TransferHistory.async_count
)
assert TransferHistory.count(db.session, status=True) == \
db.run_async_session(
lambda session: TransferHistory.async_count(session, status=True)
)
assert TransferHistory.count_by_title(db.session, "CountMe") == 2
assert TransferHistory.count_by_title(db.session, "CountMe", status=False) == 1
assert TransferHistory.count_by_title(db.session, "CountMe") == \
db.run_async_session(
lambda session: TransferHistory.async_count_by_title(
session, title="CountMe"
)
)
def test_statistic_groups_by_day_within_the_window(db):
"""
统计按日期分组,且只统计窗口内的记录。
"""
today = _time.strftime("%Y-%m-%d", _time.localtime())
db.add(_hist("今天一", src="/data/t1.mkv", date=f"{today} 10:00:00"),
_hist("今天二", src="/data/t2.mkv", date=f"{today} 11:00:00"),
_hist("很久以前", src="/data/t3.mkv", date="2000-01-01 10:00:00"))
rows = dict(TransferHistory.statistic(db.session, days=7))
assert rows.get(today, 0) >= 2
assert "2000-01-01" not in rows
def test_statistic_includes_the_window_start_boundary(db, frozen_now):
"""
统计窗口是闭区间起点(``date >= 起点``),正好落在起点的记录必须计入,同步异步一致。
起点由「调用时刻 - N 天」现算,不冻结时钟就摆不到边界上;上面那条用例用的是「今天」
与 2000 年两个极端值,起点比较符改成 ``>`` 照样绿。
"""
now = frozen_now(transferhistory_module)
window_start = _time.strftime("%Y-%m-%d %H:%M:%S", _time.localtime(now - 86400 * 7))
boundary_day = window_start[:10]
db.add(_hist("窗口起点上", src="/data/bstat.mkv", date=window_start))
rows = dict(TransferHistory.statistic(db.session, days=7))
async_rows = dict(db.run_async_session(
lambda session: TransferHistory.async_statistic(session, days=7)
))
assert rows.get(boundary_day, 0) == 1
assert async_rows.get(boundary_day, 0) == 1
def test_list_by_date_returns_newest_first(db):
"""
按时间查询返回该时间之后的记录,按主键倒序。
"""
db.add(_hist("旧", src="/data/od.mkv", date="2026-08-01 10:00:00"),
_hist("新", src="/data/nd.mkv", date="2026-08-12 10:00:00"))
titles = [h.title for h in TransferHistory.list_by_date(db.session, "2026-08-05")]
assert titles == ["新"]
def test_list_by_date_excludes_the_row_exactly_at_the_boundary(db):
"""
取的是「该时刻之后」的记录(``date > date``),正好等于该时刻的那条不算在内。
上面那条用例两侧数据各离边界四天与七天,比较符放宽成 ``>=`` 一样绿;
这里把行压在边界上,让开闭区间之差可观测。
"""
boundary = "2026-08-05 00:00:00"
db.add(_hist("边界上", src="/data/bd0.mkv", date=boundary),
_hist("边界后一秒", src="/data/bd1.mkv", date="2026-08-05 00:00:01"))
titles = [h.title for h in TransferHistory.list_by_date(db.session, boundary)]
assert titles == ["边界后一秒"]
def test_delete_before_is_batched_and_keeps_recent(db):
"""
历史清理分批执行且不碰保留期内的记录。
"""
for index in range(4):
db.add(_hist(f"old-{index}", src=f"/data/old{index}.mkv",
date=f"2026-01-01 10:00:0{index}"))
db.add(_hist("recent", src="/data/recent.mkv", date="2026-08-13 10:00:00"))
assert TransferHistory.delete_before(db.session, before_time="2026-08-01", limit=2) == 2
assert TransferHistory.delete_before(db.session, before_time="2026-08-01", limit=100) == 2
assert TransferHistory.delete_before(db.session, before_time="2026-08-01", limit=100) == 0
assert TransferHistory.get_by_src(db.session, "/data/recent.mkv") is not None
def test_delete_before_keeps_the_row_exactly_at_the_boundary(db):
"""
保留时间点上的整理历史属于「保留期内」,不能被清理(``date < before_time``)。
整理历史被删掉就等于丢失溯源,同一文件会被重新整理一次;
上面那条用例的数据离水位半年,``<`` 写成 ``<=`` 完全不可观测。
"""
boundary = "2026-05-01 00:00:00"
db.add(_hist("边界上", src="/data/bdel0.mkv", date=boundary),
_hist("边界前一秒", src="/data/bdel1.mkv", date="2026-04-30 23:59:59"))
assert TransferHistory.delete_before(db.session, before_time=boundary, limit=100) == 1
assert TransferHistory.get_by_src(db.session, "/data/bdel0.mkv") is not None
assert TransferHistory.get_by_src(db.session, "/data/bdel1.mkv") is None