Fix identity reconstruction metadata loss with v2 schema

Implements identities.json v2 schema with embedded video metadata to fix
duplicate resolution by quality, which previously failed due to VideoFile
objects being reconstructed with hardcoded defaults (size_bytes=0,
resolution=None, codec=None).

Changes:
- Add --inventory flag to vlm parse command to embed video metadata
- Update _video_file_from_record() to extract embedded metadata if present
- Add vlm_schema_version field to identities.json (v1.0 or v2.0)
- Maintain backward compatibility with v1 files (no metadata)

Schema v2 format:
- Embeds video_metadata object in each record (movies/series)
- Contains: size_bytes, modified_timestamp, resolution, codec,
  duration_seconds, bitrate_kbps
- Enables accurate quality comparison during duplicate analysis

Testing:
- Added comprehensive unit tests for io.py functions
- Added CLI integration tests for parse command
- Added end-to-end tests for duplicate quality comparison
- All 437 existing tests still pass (1 pre-existing failure in executor)

Documentation:
- Updated README.md with --inventory usage examples
- Updated CLAUDE.md with schema versioning details
- Added workflow examples showing metadata embedding

This fix resolves the critical P0 issue where duplicate resolution by_quality
strategy failed completely due to missing video metadata in reconstructed
VideoFile objects.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
windyboy
2026-02-13 09:44:51 +08:00
co-authored by Claude Sonnet 4.5
parent 1f55eab304
commit 065195b83b
7 changed files with 845 additions and 39 deletions
+171
View File
@@ -0,0 +1,171 @@
"""Tests for the parse CLI command."""
import json
from pathlib import Path
from click.testing import CliRunner
from vlm.cli import main
class TestParseCommand:
"""Tests for vlm parse command."""
def test_parse_without_inventory_v1_schema(self, tmp_path):
"""Test parse without --inventory flag produces v1 schema."""
# Create a simple inventory CSV (without metadata)
inventory_csv = tmp_path / "inventory.csv"
inventory_csv.write_text(
"# vlm inventory\n"
"path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n"
"/library/movie/Matrix (1999).mkv,Matrix (1999).mkv,1000000,2024-01-01T00:00:00,movie,,,\n"
"/library/series/Breaking Bad S01E01.mkv,Breaking Bad S01E01.mkv,500000,2024-01-02T00:00:00,series,,,\n"
)
output_json = tmp_path / "identities.json"
runner = CliRunner()
result = runner.invoke(
main,
[
"--config",
str(tmp_path / "config.yaml"),
"parse",
"--input",
str(inventory_csv),
"--output",
str(output_json),
],
)
# Command should succeed
assert result.exit_code == 0
# Load output and verify v1 schema (no version field or version is 1.0)
with open(output_json) as f:
data = json.load(f)
# v1 schema - no vlm_schema_version or it's "1.0"
assert data.get("vlm_schema_version", "1.0") == "1.0"
# Verify no video_metadata in records
if data.get("movies"):
for movie in data["movies"]:
assert "video_metadata" not in movie
if data.get("series"):
for series in data["series"]:
assert "video_metadata" not in series
def test_parse_with_inventory_v2_schema(self, tmp_path):
"""Test parse with --inventory flag produces v2 schema with embedded metadata."""
# Create inventory CSV with full metadata
inventory_csv = tmp_path / "inventory.csv"
inventory_csv.write_text(
"# vlm inventory\n"
"path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n"
"/library/movie/Matrix (1999).mkv,Matrix (1999).mkv,2000000000,2024-01-01T10:00:00+00:00,movie,1920x1080,h264,7200.5,5000\n"
"/library/series/Breaking Bad S01E01.mkv,Breaking Bad S01E01.mkv,500000000,2024-01-02T15:00:00+00:00,series,1280x720,h264,2700.0,3000\n"
)
output_json = tmp_path / "identities.json"
runner = CliRunner()
result = runner.invoke(
main,
[
"--config",
str(tmp_path / "config.yaml"),
"parse",
"--input",
str(inventory_csv),
"--output",
str(output_json),
"--inventory",
str(inventory_csv),
],
)
# Command should succeed
assert result.exit_code == 0
assert "Loading video metadata from:" in result.output
assert "Loaded metadata for 2 files" in result.output
# Load output and verify v2 schema
with open(output_json) as f:
data = json.load(f)
# v2 schema version
assert data["vlm_schema_version"] == "2.0"
# Verify video_metadata embedded in movie record
assert len(data["movies"]) == 1
movie = data["movies"][0]
assert "video_metadata" in movie
assert movie["video_metadata"]["size_bytes"] == 2000000000
assert movie["video_metadata"]["resolution"] == "1920x1080"
assert movie["video_metadata"]["codec"] == "h264"
assert movie["video_metadata"]["duration_seconds"] == 7200.5
assert movie["video_metadata"]["bitrate_kbps"] == 5000
# Verify video_metadata embedded in series record
assert len(data["series"]) == 1
series = data["series"][0]
assert "video_metadata" in series
assert series["video_metadata"]["size_bytes"] == 500000000
assert series["video_metadata"]["resolution"] == "1280x720"
assert series["video_metadata"]["codec"] == "h264"
assert series["video_metadata"]["duration_seconds"] == 2700.0
assert series["video_metadata"]["bitrate_kbps"] == 3000
def test_parse_with_inventory_partial_metadata(self, tmp_path):
"""Test parse with inventory that has partial metadata."""
# Create inventory with some files having metadata, some not
inventory_csv = tmp_path / "inventory.csv"
inventory_csv.write_text(
"# vlm inventory\n"
"path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n"
"/library/movie/Matrix (1999).mkv,Matrix (1999).mkv,2000000000,2024-01-01T10:00:00+00:00,movie,1920x1080,h264,,\n"
"/library/movie/Inception (2010).mkv,Inception (2010).mkv,1500000000,2024-01-02T10:00:00+00:00,movie,,,\n"
)
output_json = tmp_path / "identities.json"
runner = CliRunner()
result = runner.invoke(
main,
[
"--config",
str(tmp_path / "config.yaml"),
"parse",
"--input",
str(inventory_csv),
"--output",
str(output_json),
"--inventory",
str(inventory_csv),
],
)
# Command should succeed
assert result.exit_code == 0
# Load output and verify
with open(output_json) as f:
data = json.load(f)
assert data["vlm_schema_version"] == "2.0"
assert len(data["movies"]) == 2
# First movie has partial metadata
movie1 = data["movies"][0]
assert movie1["video_metadata"]["size_bytes"] == 2000000000
assert movie1["video_metadata"]["resolution"] == "1920x1080"
assert movie1["video_metadata"]["codec"] == "h264"
assert movie1["video_metadata"]["duration_seconds"] is None
assert movie1["video_metadata"]["bitrate_kbps"] is None
# Second movie has minimal metadata
movie2 = data["movies"][1]
assert movie2["video_metadata"]["size_bytes"] == 1500000000
assert movie2["video_metadata"]["resolution"] is None
assert movie2["video_metadata"]["codec"] is None
+252
View File
@@ -0,0 +1,252 @@
"""Integration tests for duplicate resolution with embedded video metadata."""
import json
from pathlib import Path
import pytest
from vlm.io import load_identities_json, identities_to_analysis_input
from vlm.analysis import detect_duplicates
class TestDuplicateQualityWithMetadata:
"""Test that embedded metadata enables accurate quality comparison."""
def test_duplicate_quality_comparison_with_v2_metadata(self, tmp_path):
"""Test that v2 identities with embedded metadata produce accurate quality comparison."""
# Create v2 identities.json with two duplicates having different quality
identities_file = tmp_path / "identities.json"
data = {
"vlm_schema_version": "2.0",
"metadata": {
"generated": "2024-02-13T12:00:00",
"source_inventory": "inventory.csv",
"total_files": 2,
},
"movies": [
{
"path": "/library/movie/Matrix (1999) 1080p.mkv",
"filename": "Matrix (1999) 1080p.mkv",
"category": "movie",
"title": "The Matrix",
"year": 1999,
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 2000000000,
"modified_timestamp": "2024-01-01T10:00:00+00:00",
"resolution": "1920x1080",
"codec": "h264",
"duration_seconds": 7200.0,
"bitrate_kbps": 5000,
},
},
{
"path": "/library/movie/Matrix (1999) 720p.mkv",
"filename": "Matrix (1999) 720p.mkv",
"category": "movie",
"title": "The Matrix",
"year": 1999,
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 1000000000,
"modified_timestamp": "2024-01-02T10:00:00+00:00",
"resolution": "1280x720",
"codec": "h264",
"duration_seconds": 7200.0,
"bitrate_kbps": 2500,
},
},
],
"series": [],
"anime": [],
"other": [],
}
# Save identities
with open(identities_file, "w") as f:
json.dump(data, f, indent=2)
# Load identities
loaded = load_identities_json(identities_file)
# Convert to analysis input
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
# Create identity-file pairs
identity_file_pairs = list(zip(movie_identities, video_files))
# Detect duplicates
duplicates = detect_duplicates(identity_file_pairs)
# Verify we found 1 duplicate group
assert len(duplicates) == 1
duplicate_group = duplicates[0]
# Verify duplicate group has 2 files
assert len(duplicate_group.files) == 2
# Verify quality comparison has actual metadata (not zeros/nulls)
quality_comparison = duplicate_group.quality_comparison
assert len(quality_comparison) == 2
# First file (1080p) should have correct metadata
file1 = quality_comparison[0]
assert file1["size_bytes"] == 2000000000
assert file1["resolution"] == "1920x1080"
assert file1["codec"] == "h264"
assert file1["bitrate_kbps"] == 5000
# Second file (720p) should have correct metadata
file2 = quality_comparison[1]
assert file2["size_bytes"] == 1000000000
assert file2["resolution"] == "1280x720"
assert file2["codec"] == "h264"
assert file2["bitrate_kbps"] == 2500
def test_duplicate_quality_comparison_with_v1_no_metadata(self, tmp_path):
"""Test that v1 identities without metadata produce limited quality comparison."""
# Create v1 identities.json without embedded metadata (backward compatibility)
identities_file = tmp_path / "identities.json"
data = {
# No vlm_schema_version (v1)
"metadata": {
"generated": "2024-02-13T12:00:00",
"source_inventory": "inventory.csv",
"total_files": 2,
},
"movies": [
{
"path": "/library/movie/Inception (2010) 1080p.mkv",
"filename": "Inception (2010) 1080p.mkv",
"category": "movie",
"title": "Inception",
"year": 2010,
"confidence": 0.9,
"needs_review": False,
# No video_metadata
},
{
"path": "/library/movie/Inception (2010) 720p.mkv",
"filename": "Inception (2010) 720p.mkv",
"category": "movie",
"title": "Inception",
"year": 2010,
"confidence": 0.9,
"needs_review": False,
# No video_metadata
},
],
"series": [],
"anime": [],
"other": [],
}
# Save identities
with open(identities_file, "w") as f:
json.dump(data, f, indent=2)
# Load identities
loaded = load_identities_json(identities_file)
# Convert to analysis input
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
# Create identity-file pairs
identity_file_pairs = list(zip(movie_identities, video_files))
# Detect duplicates
duplicates = detect_duplicates(identity_file_pairs)
# Verify we found 1 duplicate group
assert len(duplicates) == 1
duplicate_group = duplicates[0]
# Verify quality comparison exists but has limited data (v1 defaults)
quality_comparison = duplicate_group.quality_comparison
assert len(quality_comparison) == 2
# v1 files have size_bytes=0 and no resolution/codec
for quality_info in quality_comparison:
assert quality_info["size_bytes"] == 0
assert "resolution" not in quality_info # None values are not included
assert "codec" not in quality_info
def test_series_duplicate_quality_comparison_with_v2_metadata(self, tmp_path):
"""Test series duplicates with embedded metadata."""
identities_file = tmp_path / "identities.json"
data = {
"vlm_schema_version": "2.0",
"metadata": {
"generated": "2024-02-13T12:00:00",
"source_inventory": "inventory.csv",
"total_files": 2,
},
"movies": [],
"series": [
{
"path": "/library/series/Breaking Bad S01E01 1080p.mkv",
"filename": "Breaking Bad S01E01 1080p.mkv",
"category": "series",
"title": "Breaking Bad",
"season": 1,
"episodes": [1],
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 800000000,
"modified_timestamp": "2024-01-01T10:00:00+00:00",
"resolution": "1920x1080",
"codec": "hevc",
"duration_seconds": 2700.0,
"bitrate_kbps": 4000,
},
},
{
"path": "/library/series/Breaking Bad S01E01 720p.mkv",
"filename": "Breaking Bad S01E01 720p.mkv",
"category": "series",
"title": "Breaking Bad",
"season": 1,
"episodes": [1],
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 400000000,
"modified_timestamp": "2024-01-02T10:00:00+00:00",
"resolution": "1280x720",
"codec": "h264",
"duration_seconds": 2700.0,
"bitrate_kbps": 2000,
},
},
],
"anime": [],
"other": [],
}
# Save and load
with open(identities_file, "w") as f:
json.dump(data, f, indent=2)
loaded = load_identities_json(identities_file)
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
# Create identity-file pairs for series
identity_file_pairs = list(zip(series_identities, video_files))
# Detect duplicates
duplicates = detect_duplicates(identity_file_pairs)
# Verify duplicate detected
assert len(duplicates) == 1
duplicate_group = duplicates[0]
assert len(duplicate_group.files) == 2
# Verify quality comparison has metadata
quality_comparison = duplicate_group.quality_comparison
assert quality_comparison[0]["resolution"] == "1920x1080"
assert quality_comparison[0]["codec"] == "hevc"
assert quality_comparison[1]["resolution"] == "1280x720"
assert quality_comparison[1]["codec"] == "h264"
+262
View File
@@ -0,0 +1,262 @@
"""Tests for the I/O layer module."""
import json
from datetime import datetime, timezone
from pathlib import Path
import pytest
from vlm.io import (
_video_file_from_record,
load_identities_json,
save_identities_json,
)
from vlm.models import VideoFile
class TestVideoFileFromRecord:
"""Tests for _video_file_from_record function."""
def test_video_file_from_record_with_metadata_v2(self):
"""Test loading VideoFile from v2 record with embedded metadata."""
record = {
"path": "/library/movie/The Matrix (1999).mkv",
"filename": "The Matrix (1999).mkv",
"category": "movie",
"title": "The Matrix",
"year": 1999,
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 1234567890,
"modified_timestamp": "2024-01-01T10:00:00+00:00",
"resolution": "1920x1080",
"codec": "h264",
"duration_seconds": 7200.5,
"bitrate_kbps": 5000,
},
}
vf = _video_file_from_record(record)
assert vf.path == Path("/library/movie/The Matrix (1999).mkv")
assert vf.filename == "The Matrix (1999).mkv"
assert vf.category == "movie"
assert vf.size_bytes == 1234567890
assert vf.resolution == "1920x1080"
assert vf.codec == "h264"
assert vf.duration_seconds == 7200.5
assert vf.bitrate_kbps == 5000
assert isinstance(vf.modified_timestamp, datetime)
def test_video_file_from_record_without_metadata_v1(self):
"""Test loading VideoFile from v1 record without metadata (backward compatibility)."""
record = {
"path": "/library/movie/Inception (2010).mkv",
"filename": "Inception (2010).mkv",
"category": "movie",
"title": "Inception",
"year": 2010,
"confidence": 0.9,
"needs_review": False,
}
vf = _video_file_from_record(record)
assert vf.path == Path("/library/movie/Inception (2010).mkv")
assert vf.filename == "Inception (2010).mkv"
assert vf.category == "movie"
# v1 defaults
assert vf.size_bytes == 0
assert vf.resolution is None
assert vf.codec is None
assert vf.duration_seconds is None
assert vf.bitrate_kbps is None
assert isinstance(vf.modified_timestamp, datetime)
def test_video_file_from_record_partial_metadata(self):
"""Test loading VideoFile with partial metadata (some fields missing)."""
record = {
"path": "/library/series/Breaking Bad S01E01.mkv",
"filename": "Breaking Bad S01E01.mkv",
"category": "series",
"title": "Breaking Bad",
"season": 1,
"episodes": [1],
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 987654321,
"modified_timestamp": "2024-02-01T15:30:00+00:00",
"resolution": "1280x720",
# codec missing
# duration_seconds missing
# bitrate_kbps missing
},
}
vf = _video_file_from_record(record)
assert vf.size_bytes == 987654321
assert vf.resolution == "1280x720"
assert vf.codec is None
assert vf.duration_seconds is None
assert vf.bitrate_kbps is None
class TestIdentitiesJsonVersioning:
"""Tests for identities.json schema versioning."""
def test_load_identities_v2_with_metadata(self, tmp_path):
"""Test loading v2 identities.json with video metadata."""
identities_file = tmp_path / "identities.json"
data = {
"vlm_schema_version": "2.0",
"metadata": {
"generated": "2024-01-01T10:00:00",
"source_inventory": "inventory.csv",
"total_files": 1,
},
"movies": [
{
"path": "/library/movie/Test (2024).mkv",
"filename": "Test (2024).mkv",
"category": "movie",
"title": "Test",
"year": 2024,
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 1000000,
"modified_timestamp": "2024-01-01T00:00:00+00:00",
"resolution": "1920x1080",
"codec": "h264",
"duration_seconds": 3600.0,
"bitrate_kbps": 2500,
},
}
],
"series": [],
"anime": [],
"other": [],
}
save_identities_json(data, identities_file)
loaded = load_identities_json(identities_file)
assert loaded["vlm_schema_version"] == "2.0"
assert len(loaded["movies"]) == 1
assert "video_metadata" in loaded["movies"][0]
assert loaded["movies"][0]["video_metadata"]["size_bytes"] == 1000000
def test_load_identities_v1_backward_compatibility(self, tmp_path):
"""Test loading v1 identities.json without version field (backward compatibility)."""
identities_file = tmp_path / "identities.json"
data = {
# No vlm_schema_version field (v1)
"metadata": {
"generated": "2024-01-01T10:00:00",
"source_inventory": "inventory.csv",
"total_files": 1,
},
"movies": [
{
"path": "/library/movie/Old (2023).mkv",
"filename": "Old (2023).mkv",
"category": "movie",
"title": "Old",
"year": 2023,
"confidence": 0.9,
"needs_review": False,
# No video_metadata field
}
],
"series": [],
"anime": [],
"other": [],
}
save_identities_json(data, identities_file)
loaded = load_identities_json(identities_file)
# v1 files don't have version field
assert "vlm_schema_version" not in loaded or loaded.get("vlm_schema_version") is None
assert len(loaded["movies"]) == 1
assert "video_metadata" not in loaded["movies"][0]
class TestIdentitiesJsonRoundTrip:
"""Tests for identities.json save/load round-trip."""
def test_save_and_load_v2_identities(self, tmp_path):
"""Test saving and loading v2 identities with metadata."""
identities_file = tmp_path / "identities_v2.json"
original_data = {
"vlm_schema_version": "2.0",
"metadata": {
"generated": "2024-02-13T12:00:00",
"source_inventory": "test_inventory.csv",
"total_files": 2,
},
"movies": [
{
"path": "/library/movie/Movie1 (2024).mkv",
"filename": "Movie1 (2024).mkv",
"category": "movie",
"title": "Movie1",
"year": 2024,
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 2000000000,
"modified_timestamp": "2024-02-10T08:00:00+00:00",
"resolution": "3840x2160",
"codec": "hevc",
"duration_seconds": 7200.0,
"bitrate_kbps": 8000,
},
}
],
"series": [
{
"path": "/library/series/Show S01E01.mkv",
"filename": "Show S01E01.mkv",
"category": "series",
"title": "Show",
"season": 1,
"episodes": [1],
"confidence": 0.9,
"needs_review": False,
"video_metadata": {
"size_bytes": 500000000,
"modified_timestamp": "2024-02-11T10:00:00+00:00",
"resolution": "1920x1080",
"codec": "h264",
"duration_seconds": 2700.0,
"bitrate_kbps": 3000,
},
}
],
"anime": [],
"other": [],
}
save_identities_json(original_data, identities_file)
loaded_data = load_identities_json(identities_file)
# Verify structure
assert loaded_data["vlm_schema_version"] == "2.0"
assert len(loaded_data["movies"]) == 1
assert len(loaded_data["series"]) == 1
# Verify movie metadata preserved
movie = loaded_data["movies"][0]
assert movie["video_metadata"]["size_bytes"] == 2000000000
assert movie["video_metadata"]["resolution"] == "3840x2160"
assert movie["video_metadata"]["codec"] == "hevc"
# Verify series metadata preserved
series = loaded_data["series"][0]
assert series["video_metadata"]["size_bytes"] == 500000000
assert series["video_metadata"]["resolution"] == "1920x1080"
assert series["video_metadata"]["codec"] == "h264"