import asyncio import pytest from mjr_am_backend.features.metadata import metadata_service_impl as m from mjr_am_backend.shared import ErrorCode, Result class _Exif: def __init__(self, single=None, batch=None): self.single = single if single is not None else Result.Ok({}) self.batch = batch if batch is not None else {} async def aread(self, _path): return self.single def read(self, _path): return self.single async def aread_batch(self, _paths): return self.batch class _FF: def __init__(self, single=None, batch=None): self.single = single if single is not None else Result.Ok({}) self.batch = batch if batch is not None else {} async def aread(self, _path): return self.single def read(self, _path): return self.single async def aread_batch(self, _paths): return self.batch def read_batch(self, _paths): return self.batch class _Settings: def __init__(self, mode="auto", prefs=None, explode=False): self.mode = mode self.prefs = prefs if prefs is not None else {"image": True, "media": True} self.explode = explode async def get_probe_backend(self): return self.mode async def get_metadata_fallback_prefs(self): if self.explode: raise RuntimeError("x") return self.prefs def _svc(exif=None, ff=None, settings=None): return m.MetadataService(exif or _Exif(), ff or _FF(), settings or _Settings()) def test_top_level_param_helpers(): assert m._clean_model_name("C:/m/model.safetensors") == "model" assert m._clean_model_name("") is None out = m._build_geninfo_from_parameters({"prompt": "p", "steps": "20", "model": "a.ckpt"}) assert out and out["steps"]["value"] == 20 assert m._build_geninfo_from_parameters({"x": 1}) == {} merged = m._merge_parsed_params({"parameters": "x"}) assert isinstance(merged, dict) assert m._has_any_parameter_signal(None, "") is False d = {} m._apply_prompt_fields(d, "p", "n") m._apply_sampler_fields(d, "euler", "normal") m._apply_numeric_fields(d, "20", "7.5", "1") m._apply_size_field(d, "512", "768") m._apply_checkpoint_fields(d, "x.safetensors") assert "positive" in d and "size" in d and "checkpoint" in d def test_workflow_detection_metadata_from_embedded_workflow(): svc = _svc() combined = { "workflow": { "nodes": [ {"id": 1, "type": "LoadImage", "widgets_values": ["input.png"]}, {"id": 2, "type": "WanVideoSampler"}, {"id": 3, "type": "VHS_VideoCombine"}, ] } } svc._apply_workflow_detection_metadata(combined, combined["workflow"]) assert combined["workflow_type"] == "I2V" assert combined["workflow_detection"]["task"] == "I2V" assert combined["workflow_detection"]["model_family"] == "Wan" assert combined["workflow_detection"]["source"] == "graph" def test_media_pipeline_helpers(): g = {"1": {"class_type": "LoadVideo"}, "2": {"class_type": "VHS_VideoCombine"}} assert m._looks_like_media_pipeline(g) is True assert m._looks_like_media_pipeline({"1": {"class_type": "KSampler"}}) is False types = m._collect_prompt_graph_types(g) assert "loadvideo" in types assert m._has_generation_sampler(["ksampler"]) is True assert m._classify_media_nodes(types)[0] is True assert m._should_parse_geninfo({"prompt": {"1": {}}, "workflow": None}) is True assert m._should_parse_geninfo({"majoor_geninfo": {"prompt": "forced prompt"}}) is True def test_group_and_batch_targets(monkeypatch): monkeypatch.setattr(m, "classify_file", lambda p: "image" if p.endswith(".png") else ("video" if p.endswith(".mp4") else ("audio" if p.endswith(".mp3") else "unknown"))) monkeypatch.setattr(m.os.path, "exists", lambda p: not p.endswith("missing")) i, v, a, o = m._group_existing_paths(["a.png", "b.mp4", "c.mp3", "d.bin", "missing"]) assert i and v and a and o monkeypatch.setattr(m, "pick_probe_backend", lambda p, settings_override=None: ["exiftool", "ffprobe"] if p.endswith(".mp4") else ["exiftool"]) ex, ff = m._build_batch_probe_targets(["a.png", "b.mp4", "b.mp4"], "auto") assert ex == ["a.png", "b.mp4"] assert ff == ["b.mp4"] def test_batch_tool_data_and_resolution_helpers(): mp = {"a": Result.Ok({"x": 1}), "b": Result.Err("E", "x")} assert m._batch_tool_data(mp, "a") == {"x": 1} assert m._batch_tool_data(mp, "b") is None payload = {"resolution": (512, 768)} m._expand_resolution_scalars(payload) assert payload["width"] == 512 and payload["height"] == 768 assert m._coerce_resolution_pair((1, 2)) == (1, 2) assert m._coerce_resolution_pair(1) is None assert m._coerce_dimension_value("5") == 5 @pytest.mark.asyncio async def test_probe_mode_and_backends(monkeypatch): s = _svc(settings=_Settings(mode="ffprobe")) assert await s._resolve_probe_mode("AUTO") == "auto" assert await s._resolve_probe_mode("bad") == "ffprobe" monkeypatch.setattr(m, "pick_probe_backend", lambda p, settings_override=None: ["exiftool"]) mode, backends = await s._probe_backends("a.png", None) assert mode == "ffprobe" and backends == ["exiftool"] @pytest.mark.asyncio async def test_resolve_fallback_prefs(): s1 = _svc(settings=_Settings(prefs={"image": False, "media": True})) assert await s1._resolve_fallback_prefs() == (False, True) s2 = _svc(settings=_Settings(explode=True)) assert await s2._resolve_fallback_prefs() == (True, True) @pytest.mark.asyncio async def test_read_with_transient_retry(monkeypatch): s = _svc() seq = [Result.Err("E", "x"), Result.Ok({"a": 1})] async def _once(): return seq.pop(0) monkeypatch.setattr(s, "_is_transient_metadata_read_error", lambda result, fp: True) out = await s._read_with_transient_retry("a.png", _once) assert out.ok @pytest.mark.asyncio async def test_enrich_with_geninfo_async(monkeypatch): s = _svc() monkeypatch.setattr(m, "parse_geninfo_from_prompt", lambda *args, **kwargs: Result.Ok({"g": 1})) c = {"prompt": {"1": {}}, "workflow": {"nodes": []}} await s._enrich_with_geninfo_async(c) assert c["geninfo"] == {"g": 1} c_override = { "raw": { "PNG:Majoor_geninfo": '{"mode":"override","prompt":"forced","seed":"9","custom_info":[{"title":"T","content":"C","color":"#123ABC"}]}' }, "prompt": {"1": {}}, } await s._enrich_with_geninfo_async(c_override) assert c_override["geninfo"]["engine"]["parser_version"] == "geninfo-override-v1" assert c_override["geninfo"]["g"] == 1 assert c_override["geninfo"]["positive"]["value"] == "forced" assert c_override["geninfo"]["seed"]["value"] == 9 assert c_override["geninfo"]["custom_info"][0]["color"] == "#123ABC" monkeypatch.setattr(m, "parse_geninfo_from_prompt", lambda *args, **kwargs: Result.Err("E", "x")) monkeypatch.setattr(m, "registry_build_geninfo_from_parameters", lambda combined: None) monkeypatch.setattr(m, "registry_looks_like_media_pipeline", lambda p: True) c2 = {"prompt": {"1": {}}, "workflow": {"nodes": []}} await s._enrich_with_geninfo_async(c2) assert c2["geninfo"] == {} assert c2["geninfo_status"]["kind"] == "media_pipeline" @pytest.mark.asyncio async def test_get_metadata_not_found(tmp_path): s = _svc() out = await s.get_metadata(str(tmp_path / "missing.png")) assert out.code == ErrorCode.NOT_FOUND @pytest.mark.asyncio async def test_get_metadata_impl_dispatch(monkeypatch, tmp_path): p = tmp_path / "a.bin" p.write_text("x") s = _svc() monkeypatch.setattr(m, "classify_file", lambda _p: "image") monkeypatch.setattr(s, "_probe_backends", lambda *args, **kwargs: asyncio.sleep(0, result=("auto", ["exiftool"]))) monkeypatch.setattr(s, "_extract_image_metadata", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"k": 1}))) out1 = await s._get_metadata_impl(str(p)) assert out1.ok monkeypatch.setattr(m, "classify_file", lambda _p: "video") monkeypatch.setattr(s, "_extract_video_metadata", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"k": 2}))) out2 = await s._get_metadata_impl(str(p)) assert out2.ok monkeypatch.setattr(m, "classify_file", lambda _p: "audio") monkeypatch.setattr(s, "_extract_audio_metadata", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"k": 3}))) out3 = await s._get_metadata_impl(str(p)) assert out3.ok monkeypatch.setattr(m, "classify_file", lambda _p: "unknown") out4 = await s._get_metadata_impl(str(p)) assert out4.code == ErrorCode.UNSUPPORTED @pytest.mark.asyncio async def test_workflow_only_paths(monkeypatch, tmp_path): p = tmp_path / "a.png" p.write_text("x") s = _svc() monkeypatch.setattr(m, "classify_file", lambda _p: "image") monkeypatch.setattr(s, "_resolve_fallback_prefs", lambda: asyncio.sleep(0, result=(True, True))) monkeypatch.setattr(s, "_read_workflow_only_exif", lambda **kwargs: asyncio.sleep(0, result={"a": 1})) monkeypatch.setattr(s, "_extract_workflow_only_payload", lambda *args, **kwargs: Result.Ok({"workflow": {"w": 1}, "prompt": {"p": 1}, "quality": "partial"})) out = await s.get_workflow_only(str(p)) assert out.ok and out.data["quality"] == "partial" out_nf = await s.get_workflow_only(str(tmp_path / "missing.png")) assert out_nf.code == ErrorCode.NOT_FOUND @pytest.mark.asyncio async def test_read_workflow_only_exif_fallback(monkeypatch): s = _svc() monkeypatch.setattr(s, "_exif_read", lambda _p: asyncio.sleep(0, result=Result.Err("E", "x"))) monkeypatch.setattr(m, "read_image_exif_like", lambda _p: {"fallback": 1}) out = await s._read_workflow_only_exif(file_path="a.png", kind="image", image_fallback_enabled=True, scan_id=None) assert out == {"fallback": 1} @pytest.mark.asyncio async def test_image_video_audio_extractors(monkeypatch, tmp_path): p_img = tmp_path / "a.png" p_img.write_text("x") p_vid = tmp_path / "a.mp4" p_vid.write_text("x") p_aud = tmp_path / "a.mp3" p_aud.write_text("x") s = _svc() monkeypatch.setattr(s, "_resolve_image_exif_data", lambda *args, **kwargs: asyncio.sleep(0, result={"x": 1})) monkeypatch.setattr(s, "_extract_image_by_extension", lambda fp, ext, ex: Result.Ok({"quality": "partial", "workflow": None, "prompt": None})) monkeypatch.setattr(s, "_build_image_metadata_payload", lambda *args, **kwargs: {"workflow": None, "prompt": None}) monkeypatch.setattr(s, "_enrich_with_geninfo_async", lambda combined: asyncio.sleep(0)) out_img = await s._extract_image_metadata(str(p_img)) assert out_img.ok monkeypatch.setattr(s, "_resolve_fallback_prefs", lambda: asyncio.sleep(0, result=(True, True))) monkeypatch.setattr(s, "_read_video_exif_if_allowed", lambda *args, **kwargs: asyncio.sleep(0, result=({}, 0.1))) monkeypatch.setattr(s, "_read_video_ffprobe_if_allowed", lambda *args, **kwargs: asyncio.sleep(0, result=({}, 0.1))) monkeypatch.setattr(m, "extract_video_metadata", lambda *args, **kwargs: Result.Ok({"quality": "partial", "resolution": (100, 200)})) monkeypatch.setattr(s, "_enrich_with_geninfo_async", lambda combined: asyncio.sleep(0)) out_vid = await s._extract_video_metadata(str(p_vid)) assert out_vid.ok monkeypatch.setattr(s, "_read_audio_exif_if_allowed", lambda *args, **kwargs: asyncio.sleep(0, result=({}, 0.1))) monkeypatch.setattr(s, "_read_audio_ffprobe_if_allowed", lambda *args, **kwargs: asyncio.sleep(0, result=({}, 0.1))) monkeypatch.setattr(s, "_maybe_audio_ffprobe_fallback", lambda fp, data: asyncio.sleep(0, result=data)) monkeypatch.setattr(m, "extract_audio_metadata", lambda *args, **kwargs: Result.Ok({"quality": "partial"})) monkeypatch.setattr(s, "_finalize_audio_metadata_ok", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"ok": 1}))) out_aud = await s._extract_audio_metadata(str(p_aud)) assert out_aud.ok @pytest.mark.asyncio async def test_process_image_batch_item_supports_avif(monkeypatch, tmp_path): p_avif = tmp_path / "a.avif" p_avif.write_text("x") s = _svc() monkeypatch.setattr(m, "extract_avif_metadata", lambda *args, **kwargs: Result.Ok({"quality": "partial", "workflow": {"w": 1}, "prompt": None})) monkeypatch.setattr(s, "_get_file_info", lambda _path: {"path": _path}) monkeypatch.setattr(s, "_normalize_visual_dimensions", lambda *args, **kwargs: None) monkeypatch.setattr(s, "_finalize_batch_ok", lambda combined, metadata_result: asyncio.sleep(0, result=Result.Ok(combined, quality=metadata_result.meta.get("quality", "none")))) out = await s._process_image_batch_item(str(p_avif), {"x": 1}, image_fallback_enabled=False) assert out.ok assert out.data["workflow"] == {"w": 1} @pytest.mark.asyncio async def test_audio_fallback_only_and_reads(monkeypatch, tmp_path): p = tmp_path / "a.mp3" p.write_text("x") s = _svc() monkeypatch.setattr(s, "_resolve_fallback_prefs", lambda: asyncio.sleep(0, result=(True, True))) monkeypatch.setattr(m, "read_media_probe_like", lambda _p: {"d": 1}) monkeypatch.setattr(m, "extract_audio_metadata", lambda *args, **kwargs: Result.Ok({"quality": "partial"})) out = await s._extract_audio_metadata_fallback_only(str(p)) assert out.ok monkeypatch.setattr(s, "_exif_read", lambda _p: asyncio.sleep(0, result=Result.Err("E", "x"))) monkeypatch.setattr(s, "_ffprobe_read", lambda _p: asyncio.sleep(0, result=Result.Err("E", "x"))) ex, _ = await s._read_audio_exif_if_allowed(str(p), None, True) ff, _ = await s._read_audio_ffprobe_if_allowed(str(p), None, True) assert ex is None and ff is None @pytest.mark.asyncio async def test_batch_impl_and_fill_helpers(monkeypatch, tmp_path): p_img = tmp_path / "a.png" p_vid = tmp_path / "a.mp4" p_aud = tmp_path / "a.mp3" p_oth = tmp_path / "a.bin" for p in (p_img, p_vid, p_aud, p_oth): p.write_text("x") s = _svc(exif=_Exif(batch={str(p_img): Result.Ok({"ex": 1})}), ff=_FF(batch={str(p_vid): Result.Ok({"ff": 1}), str(p_aud): Result.Ok({"ff": 2})})) monkeypatch.setattr(m, "registry_group_existing_paths", lambda fps: ([str(p_img)], [str(p_vid)], [str(p_aud)], [str(p_oth)])) monkeypatch.setattr(s, "_resolve_probe_mode", lambda ov: asyncio.sleep(0, result="auto")) monkeypatch.setattr(s, "_resolve_fallback_prefs", lambda: asyncio.sleep(0, result=(True, True))) monkeypatch.setattr(m, "registry_build_batch_probe_targets", lambda paths, mode: (paths, paths)) monkeypatch.setattr(s, "_process_image_batch_item", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"i": 1}))) monkeypatch.setattr(s, "_process_video_batch_item", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"v": 1}))) monkeypatch.setattr(s, "_process_audio_batch_item", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"a": 1}))) monkeypatch.setattr(s, "_fill_other_batch_results", lambda results, others: results.update({o: Result.Ok({"o": 1}) for o in others})) out = await s._get_metadata_batch_impl([str(p_img), str(p_vid), str(p_aud), str(p_oth)]) assert all(k in out for k in (str(p_img), str(p_vid), str(p_aud), str(p_oth))) def test_dimension_helpers(): svc = _svc() w, h = svc._fill_dims_from_resolution({"resolution": (1, 2)}, None, None, lambda x: int(x)) assert (w, h) == (1, 2) ff = {"video_stream": {"width": "3", "height": "4"}} w2, h2 = svc._fill_dims_from_ffprobe(ff, None, None, lambda x: int(x)) assert (w2, h2) == (3, 4) ex = {"Image:ImageWidth": "5", "Image:ImageHeight": "6"} w3, h3 = svc._fill_dims_from_exif(ex, None, None, lambda x: int(x)) assert (w3, h3) == (5, 6) assert svc._pick_first_coerced(ex, ("bad", "Image:ImageWidth"), lambda x: int(x) if x else None) == 5 def test_proxy_methods(monkeypatch, tmp_path): svc = _svc() monkeypatch.setattr(m, "retry_extract_rating_tags_only", lambda exif, path, logger, scan_id=None: Result.Ok({"rating": 5})) assert svc.extract_rating_tags_only("a").ok p = tmp_path / "a.bin" p.write_text("x") monkeypatch.setattr(m, "retry_fill_other_batch_results", lambda results, paths, get_info: results.update({p: Result.Ok(get_info(p)) for p in paths})) r = {} svc._fill_other_batch_results(r, [str(p)]) assert str(p) in r