Skip to content
15 changes: 14 additions & 1 deletion src/vidxp/api_routes/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from vidxp.api_models import UploadIntentResponse
from vidxp.composition import HttpApplicationContext
from vidxp.core.identifiers import MediaId
from vidxp.core.media import MediaState


router = APIRouter(prefix="/media", tags=["media"])
Expand Down Expand Up @@ -176,9 +177,21 @@ def list_media(
str | None,
Query(min_length=1, max_length=512),
] = None,
filename: Annotated[
str | None,
Query(min_length=1),
] = None,
state: Annotated[
MediaState | None,
Query(),
] = None,
) -> MediaPage:
return service.application.list_media(
ListMediaCommand(page_size=page_size, cursor=cursor)
ListMediaCommand(
page_size=page_size,
cursor=cursor,
filename = filename,
state = state)
)


Expand Down
16 changes: 15 additions & 1 deletion src/vidxp/api_routes/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
WorkspaceOverview,
)
from vidxp.composition import HttpApplicationContext
from vidxp.core.media import MediaState


router = APIRouter(
Expand All @@ -32,9 +33,22 @@ def workspace(
str | None,
Query(min_length=1, max_length=512),
] = None,
filename: Annotated[
str | None,
Query(min_length=1),
] = None,
state: Annotated[
MediaState | None,
Query(),
] = None,
) -> WorkspaceOverview:
return service.application.workspace(
ListMediaCommand(page_size=page_size, cursor=cursor)
ListMediaCommand(
page_size=page_size,
cursor=cursor,
filename=filename,
state=state,
)
)


Expand Down
9 changes: 9 additions & 0 deletions src/vidxp/application_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,15 @@ class ListMediaCommand(ApplicationModel):
max_length=512,
description="Opaque next_cursor from the previous list_media page.",
)
filename: str | None = Field(
default=None,
min_length=1,
description="Filter media records by filename.",
)
state: MediaState | None = Field(
default=None,
description="Filter media records by readiness/state.",
)


class MediaPage(Page[MediaAsset]):
Expand Down
16 changes: 15 additions & 1 deletion src/vidxp/cli_commands/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
require_media_runtime,
state_from_context,
)
from vidxp.core.media import MediaState


app = typer.Typer(no_args_is_help=True, help="Import and inspect local media.")
Expand Down Expand Up @@ -68,6 +69,14 @@ def list_media(
str | None,
typer.Option("--cursor", help="Cursor returned by the previous page."),
] = None,
filename: Annotated[
str | None,
typer.Option("--filename", help="Filter media by filename."),
] = None,
media_state: Annotated[
MediaState | None,
typer.Option("--state", help="Filter media by readiness/state."),
] = None,
json_output: Annotated[
bool,
typer.Option("--json", help="Emit machine-readable JSON."),
Expand All @@ -77,7 +86,12 @@ def list_media(

state = state_from_context(ctx)
page = state.service.list_media(
ListMediaCommand(page_size=limit, cursor=cursor)
ListMediaCommand(
page_size=limit,
cursor=cursor,
filename=filename,
state=media_state,
)
)
assets = page.items
payload = page.model_dump(mode="json")
Expand Down
5 changes: 4 additions & 1 deletion src/vidxp/control_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ def list_media(self, command: ListMediaCommand) -> MediaPage:
@application_boundary
def workspace(self, command: ListMediaCommand) -> WorkspaceOverview:
page = self.list_media(command)
# Workspace actions are repository-level guidance, so compare against
# repository-wide totals rather than a potentially filtered page total.
repository_media_total = self.list_media(ListMediaCommand(page_size=1)).total
index = self.index_status()
snapshot = self._read_active_snapshot()
capabilities = self.list_capabilities()
Expand Down Expand Up @@ -166,7 +169,7 @@ def workspace(self, command: ListMediaCommand) -> WorkspaceOverview:
next_actions = []
if page.total == 0:
next_actions.append("register_media")
if page.total > len(indexed_media) or any(
if repository_media_total > len(indexed_media) or any(
item.media_id not in indexed_media for item in page.items
):
next_actions.append("index_media")
Expand Down
6 changes: 4 additions & 2 deletions src/vidxp/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@ def _select_video(busy, media_id, media_page):
assets = tuple(
asset
for asset in (media_page.items if media_page is not None else ())
if asset.state == MediaState.ready
if getattr(asset, "state", None) == MediaState.ready
)
media_id = _default_media_id(media_id, assets)
if media_id is not None:
Expand Down Expand Up @@ -998,7 +998,9 @@ def run():
media_id = indexed_media[0]
st.session_state[MEDIA_ID_KEY] = media_id
try:
media_page = service.list_media(ListMediaCommand(page_size=100))
media_page = service.list_media(
ListMediaCommand(page_size=100, state=MediaState.ready)
)
except ApplicationError:
media_page = None
st.warning(
Expand Down
60 changes: 50 additions & 10 deletions src/vidxp/infrastructure/sql_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,38 @@ def _record(model: Any, value: Any) -> Any:
return model.model_validate(_payload(value), strict=False)


def _escape_like_pattern(value: str) -> str:
escaped = (
value.lower()
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
return f"%{escaped}%"


def _media_payload_text(key: str):
return media.c.payload[key].as_string()


def _media_list_conditions(
*,
filename: str | None,
state: MediaState | None,
) -> tuple[Any, ...]:
conditions: list[Any] = []
if state is not None:
conditions.append(_media_payload_text("state") == state.value)
if filename is not None:
conditions.append(
func.lower(_media_payload_text("original_filename")).like(
_escape_like_pattern(filename),
escape="\\",
)
)
return tuple(conditions)


def _upload_record(row: Any) -> UploadIntentRecord:
return UploadIntentRecord(
intent_id=row.intent_id,
Expand Down Expand Up @@ -316,28 +348,36 @@ def list_media(
*,
limit: int,
offset: int = 0,
filename: str | None = None,
state: MediaState | None = None,
) -> tuple[MediaRecord, ...]:
if limit <= 0 or offset < 0:
raise ValueError("limit must be positive and offset nonnegative")
conditions = _media_list_conditions(filename=filename, state=state)
query = select(media.c.payload).order_by(media.c.created_at, media.c.media_id)
if conditions:
query = query.where(and_(*conditions))
with self.engine.connect() as connection:
payloads = connection.execute(
select(media.c.payload)
.order_by(media.c.created_at, media.c.media_id)
.limit(limit)
.offset(offset)
query.limit(limit).offset(offset)
).scalars()
return tuple(
_record(MediaRecord, payload)
for payload in payloads
)

def count_media(self) -> int:
def count_media(
self,
*,
filename: str | None = None,
state: MediaState | None = None,
) -> int:
conditions = _media_list_conditions(filename=filename, state=state)
query = select(func.count()).select_from(media)
if conditions:
query = query.where(and_(*conditions))
with self.engine.connect() as connection:
return int(
connection.execute(
select(func.count()).select_from(media)
).scalar_one()
)
return int(connection.execute(query).scalar_one())

def reserve_media_import(
self,
Expand Down
37 changes: 29 additions & 8 deletions src/vidxp/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
load_mcp_app_html,
)
from vidxp.core.identifiers import ArtifactId
from vidxp.core.media import MediaState
from vidxp.evidence_delivery import (
EvidenceDeliveryService,
require_completed_evidence_result,
Expand Down Expand Up @@ -1277,13 +1278,23 @@ async def get_workspace(
str | None,
Field(min_length=1, max_length=512),
] = None,
filename: Annotated[
str | None,
Field(min_length=1),
] = None,
state: MediaState | None = None
) -> WorkspaceOverview:
return await _invoke_async(
context,
default_principal=default_principal,
permission=RepositoryPermission.read,
operation=lambda _actor: context.application.workspace(
ListMediaCommand(page_size=page_size, cursor=cursor)
ListMediaCommand(
page_size=page_size,
cursor=cursor,
filename = filename,
state = state,
)
),
)

Expand Down Expand Up @@ -1349,15 +1360,25 @@ async def list_media(
str | None,
Field(min_length=1, max_length=512),
] = None,
filename: Annotated[
str | None,
Field(min_length=1),
] = None,
state: MediaState | None = None,
) -> MediaPage:
return await _invoke_async(
context,
default_principal=default_principal,
permission=RepositoryPermission.read,
operation=lambda _actor: context.application.list_media(
ListMediaCommand(page_size=page_size, cursor=cursor)
),
)
context,
default_principal=default_principal,
permission=RepositoryPermission.read,
operation=lambda _actor: context.application.list_media(
ListMediaCommand(
page_size=page_size,
cursor=cursor,
filename=filename,
state=state,
)
),
)

@server.tool(
title="Get media",
Expand Down
20 changes: 16 additions & 4 deletions src/vidxp/media_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,21 +274,33 @@ def get(self, media_id: str) -> MediaAsset:
return media_asset(record)

def list(self, command: ListMediaCommand) -> MediaPage:
scope = hashlib.sha256(
str(self.settings.repository_root.resolve()).encode()
).hexdigest()
scope = json.dumps(
[
hashlib.sha256(
str(self.settings.repository_root.resolve()).encode()
).hexdigest(),
command.filename,
command.state.value if command.state is not None else None,
],
separators=(",", ":"),
)
try:
offset = decode_offset_cursor(command.cursor, scope=scope)
except CursorError as exc:
raise ValueError("The media cursor is invalid.") from exc
total = self.catalog.count_media()
total = self.catalog.count_media(
filename=command.filename,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cursor is scoped only to the repository, so it can be reused with different filters. For example, a cursor from filename=clip can be sent with state=failed, and the failed-media results will start at the old offset and skip items. Please include filename and state in the cursor scope so a cursor is valid only for the query that created it.

state=command.state,
)
if offset > total:
raise ValueError("The media cursor is outside the result set.")
items = tuple(
media_asset(record)
for record in self.catalog.list_media(
limit=command.page_size,
offset=offset,
filename=command.filename,
state=command.state
)
)
next_offset = offset + len(items)
Expand Down
19 changes: 14 additions & 5 deletions src/vidxp/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from vidxp.core.media import (
MediaProbe,
MediaRecord,
MediaState,
StagedMedia,
StoredMedia,
)
Expand Down Expand Up @@ -100,13 +101,21 @@ def put_media(self, record: MediaRecord) -> MediaRecord: ...
def replace_media(self, record: MediaRecord) -> MediaRecord: ...

def list_media(
self,
*,
limit: int,
offset: int = 0,
self,
*,
limit: int,
offset: int = 0,
filename: str | None = None,
state: MediaState | None = None,
) -> tuple[MediaRecord, ...]: ...

def count_media(self) -> int: ...

def count_media(
self,
*,
filename: str | None = None,
state: MediaState | None = None,
) -> int: ...

def reserve_media_import(
self,
Expand Down
Loading