Skip to content

Commit bdfd309

Browse files
FaiziNerdtulayha
andauthored
feat(media): add filename and readiness filters to shared listing (#126)
* Added Search filters,Added file names,Implemented CLI,MCP and HTTP projections * Fixed the issue of import in the file named mcp.py * Fixed the same import issue in the file named ports.py * Fixes of media_service.py and test_frontend.py as proposed by the Reviewer * Missed the service variable that was defined at the top of the function * align workspace actions, cursor scope, and ready-only frontend listing * Minor changes * fix(media): preserve filtered pagination and matching --------- Co-authored-by: Talha <tamjad91@gmail.com>
1 parent c17a884 commit bdfd309

17 files changed

Lines changed: 508 additions & 27 deletions

‎src/vidxp/api_routes/media.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from vidxp.api_models import UploadIntentResponse
2727
from vidxp.composition import HttpApplicationContext
2828
from vidxp.core.identifiers import MediaId
29+
from vidxp.core.media import MediaState
2930

3031

3132
router = APIRouter(prefix="/media", tags=["media"])
@@ -176,9 +177,22 @@ def list_media(
176177
str | None,
177178
Query(min_length=1, max_length=512),
178179
] = None,
180+
filename: Annotated[
181+
str | None,
182+
Query(min_length=1),
183+
] = None,
184+
state: Annotated[
185+
MediaState | None,
186+
Query(),
187+
] = None,
179188
) -> MediaPage:
180189
return service.application.list_media(
181-
ListMediaCommand(page_size=page_size, cursor=cursor)
190+
ListMediaCommand(
191+
page_size=page_size,
192+
cursor=cursor,
193+
filename=filename,
194+
state=state,
195+
)
182196
)
183197

184198

‎src/vidxp/api_routes/platform.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
WorkspaceOverview,
1212
)
1313
from vidxp.composition import HttpApplicationContext
14+
from vidxp.core.media import MediaState
1415

1516

1617
router = APIRouter(
@@ -32,9 +33,22 @@ def workspace(
3233
str | None,
3334
Query(min_length=1, max_length=512),
3435
] = None,
36+
filename: Annotated[
37+
str | None,
38+
Query(min_length=1),
39+
] = None,
40+
state: Annotated[
41+
MediaState | None,
42+
Query(),
43+
] = None,
3544
) -> WorkspaceOverview:
3645
return service.application.workspace(
37-
ListMediaCommand(page_size=page_size, cursor=cursor)
46+
ListMediaCommand(
47+
page_size=page_size,
48+
cursor=cursor,
49+
filename=filename,
50+
state=state,
51+
)
3852
)
3953

4054

‎src/vidxp/application_models.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,15 @@ class ListMediaCommand(ApplicationModel):
398398
max_length=512,
399399
description="Opaque next_cursor from the previous list_media page.",
400400
)
401+
filename: str | None = Field(
402+
default=None,
403+
min_length=1,
404+
description="Filter media records by filename.",
405+
)
406+
state: MediaState | None = Field(
407+
default=None,
408+
description="Filter media records by readiness/state.",
409+
)
401410

402411

403412
class MediaPage(Page[MediaAsset]):

‎src/vidxp/cli_commands/media.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
require_media_runtime,
1717
state_from_context,
1818
)
19+
from vidxp.core.media import MediaState
1920

2021

2122
app = typer.Typer(no_args_is_help=True, help="Import and inspect local media.")
@@ -68,6 +69,14 @@ def list_media(
6869
str | None,
6970
typer.Option("--cursor", help="Cursor returned by the previous page."),
7071
] = None,
72+
filename: Annotated[
73+
str | None,
74+
typer.Option("--filename", help="Filter media by filename."),
75+
] = None,
76+
media_state: Annotated[
77+
MediaState | None,
78+
typer.Option("--state", help="Filter media by readiness/state."),
79+
] = None,
7180
json_output: Annotated[
7281
bool,
7382
typer.Option("--json", help="Emit machine-readable JSON."),
@@ -77,7 +86,12 @@ def list_media(
7786

7887
state = state_from_context(ctx)
7988
page = state.service.list_media(
80-
ListMediaCommand(page_size=limit, cursor=cursor)
89+
ListMediaCommand(
90+
page_size=limit,
91+
cursor=cursor,
92+
filename=filename,
93+
state=media_state,
94+
)
8195
)
8296
assets = page.items
8397
payload = page.model_dump(mode="json")

‎src/vidxp/control_plane.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ def list_media(self, command: ListMediaCommand) -> MediaPage:
117117
@application_boundary
118118
def workspace(self, command: ListMediaCommand) -> WorkspaceOverview:
119119
page = self.list_media(command)
120+
# Workspace actions are repository-level guidance, so compare against
121+
# repository-wide totals rather than a potentially filtered page total.
122+
repository_media_total = self.list_media(ListMediaCommand(page_size=1)).total
120123
index = self.index_status()
121124
snapshot = self._read_active_snapshot()
122125
capabilities = self.list_capabilities()
@@ -166,7 +169,7 @@ def workspace(self, command: ListMediaCommand) -> WorkspaceOverview:
166169
next_actions = []
167170
if page.total == 0:
168171
next_actions.append("register_media")
169-
if page.total > len(indexed_media) or any(
172+
if repository_media_total > len(indexed_media) or any(
170173
item.media_id not in indexed_media for item in page.items
171174
):
172175
next_actions.append("index_media")

‎src/vidxp/frontend.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ def _select_video(busy, media_id, media_page):
754754
assets = tuple(
755755
asset
756756
for asset in (media_page.items if media_page is not None else ())
757-
if asset.state == MediaState.ready
757+
if getattr(asset, "state", None) == MediaState.ready
758758
)
759759
media_id = _default_media_id(media_id, assets)
760760
if media_id is not None:
@@ -998,7 +998,9 @@ def run():
998998
media_id = indexed_media[0]
999999
st.session_state[MEDIA_ID_KEY] = media_id
10001000
try:
1001-
media_page = service.list_media(ListMediaCommand(page_size=100))
1001+
media_page = service.list_media(
1002+
ListMediaCommand(page_size=100, state=MediaState.ready)
1003+
)
10021004
except ApplicationError:
10031005
media_page = None
10041006
st.warning(

‎src/vidxp/infrastructure/sql_catalog.py‎

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,41 @@ def _record(model: Any, value: Any) -> Any:
6868
return model.model_validate(_payload(value), strict=False)
6969

7070

71+
def _escape_like_pattern(value: str) -> str:
72+
escaped = (
73+
value.replace("\\", "\\\\")
74+
.replace("%", "\\%")
75+
.replace("_", "\\_")
76+
)
77+
return f"%{escaped}%"
78+
79+
80+
def _media_payload_text(key: str):
81+
return media.c.payload[key].as_string()
82+
83+
84+
def _media_list_conditions(
85+
*,
86+
dialect_name: str,
87+
filename: str | None,
88+
state: MediaState | None,
89+
) -> tuple[Any, ...]:
90+
conditions: list[Any] = []
91+
if state is not None:
92+
conditions.append(_media_payload_text("state") == state.value)
93+
if filename is not None:
94+
filename_text = _media_payload_text("original_filename")
95+
if dialect_name == "postgresql":
96+
filename_text = filename_text.collate("pg_unicode_fast")
97+
conditions.append(
98+
func.casefold(filename_text).like(
99+
_escape_like_pattern(filename.casefold()),
100+
escape="\\",
101+
)
102+
)
103+
return tuple(conditions)
104+
105+
71106
def _upload_record(row: Any) -> UploadIntentRecord:
72107
return UploadIntentRecord(
73108
intent_id=row.intent_id,
@@ -180,6 +215,12 @@ def transaction(self) -> Iterator[Connection]:
180215

181216
@staticmethod
182217
def _configure_sqlite(dbapi_connection: Any, _record: Any) -> None:
218+
dbapi_connection.create_function(
219+
"casefold",
220+
1,
221+
str.casefold,
222+
deterministic=True,
223+
)
183224
cursor = dbapi_connection.cursor()
184225
try:
185226
cursor.execute("PRAGMA foreign_keys = ON")
@@ -316,28 +357,44 @@ def list_media(
316357
*,
317358
limit: int,
318359
offset: int = 0,
360+
filename: str | None = None,
361+
state: MediaState | None = None,
319362
) -> tuple[MediaRecord, ...]:
320363
if limit <= 0 or offset < 0:
321364
raise ValueError("limit must be positive and offset nonnegative")
365+
conditions = _media_list_conditions(
366+
dialect_name=self.engine.dialect.name,
367+
filename=filename,
368+
state=state,
369+
)
370+
query = select(media.c.payload).order_by(media.c.created_at, media.c.media_id)
371+
if conditions:
372+
query = query.where(and_(*conditions))
322373
with self.engine.connect() as connection:
323374
payloads = connection.execute(
324-
select(media.c.payload)
325-
.order_by(media.c.created_at, media.c.media_id)
326-
.limit(limit)
327-
.offset(offset)
375+
query.limit(limit).offset(offset)
328376
).scalars()
329377
return tuple(
330378
_record(MediaRecord, payload)
331379
for payload in payloads
332380
)
333381

334-
def count_media(self) -> int:
382+
def count_media(
383+
self,
384+
*,
385+
filename: str | None = None,
386+
state: MediaState | None = None,
387+
) -> int:
388+
conditions = _media_list_conditions(
389+
dialect_name=self.engine.dialect.name,
390+
filename=filename,
391+
state=state,
392+
)
393+
query = select(func.count()).select_from(media)
394+
if conditions:
395+
query = query.where(and_(*conditions))
335396
with self.engine.connect() as connection:
336-
return int(
337-
connection.execute(
338-
select(func.count()).select_from(media)
339-
).scalar_one()
340-
)
397+
return int(connection.execute(query).scalar_one())
341398

342399
def reserve_media_import(
343400
self,

‎src/vidxp/mcp.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
load_mcp_app_html,
110110
)
111111
from vidxp.core.identifiers import ArtifactId
112+
from vidxp.core.media import MediaState
112113
from vidxp.evidence_delivery import (
113114
EvidenceDeliveryService,
114115
require_completed_evidence_result,
@@ -1277,13 +1278,23 @@ async def get_workspace(
12771278
str | None,
12781279
Field(min_length=1, max_length=512),
12791280
] = None,
1281+
filename: Annotated[
1282+
str | None,
1283+
Field(min_length=1),
1284+
] = None,
1285+
state: MediaState | None = None,
12801286
) -> WorkspaceOverview:
12811287
return await _invoke_async(
12821288
context,
12831289
default_principal=default_principal,
12841290
permission=RepositoryPermission.read,
12851291
operation=lambda _actor: context.application.workspace(
1286-
ListMediaCommand(page_size=page_size, cursor=cursor)
1292+
ListMediaCommand(
1293+
page_size=page_size,
1294+
cursor=cursor,
1295+
filename=filename,
1296+
state=state,
1297+
)
12871298
),
12881299
)
12891300

@@ -1349,13 +1360,23 @@ async def list_media(
13491360
str | None,
13501361
Field(min_length=1, max_length=512),
13511362
] = None,
1363+
filename: Annotated[
1364+
str | None,
1365+
Field(min_length=1),
1366+
] = None,
1367+
state: MediaState | None = None,
13521368
) -> MediaPage:
13531369
return await _invoke_async(
13541370
context,
13551371
default_principal=default_principal,
13561372
permission=RepositoryPermission.read,
13571373
operation=lambda _actor: context.application.list_media(
1358-
ListMediaCommand(page_size=page_size, cursor=cursor)
1374+
ListMediaCommand(
1375+
page_size=page_size,
1376+
cursor=cursor,
1377+
filename=filename,
1378+
state=state,
1379+
)
13591380
),
13601381
)
13611382

‎src/vidxp/media_service.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import annotations
22

3+
import hashlib
34
import json
45
from pathlib import Path
5-
import hashlib
66
from uuid import uuid4
77

88
from vidxp.application_models import (
@@ -274,21 +274,38 @@ def get(self, media_id: str) -> MediaAsset:
274274
return media_asset(record)
275275

276276
def list(self, command: ListMediaCommand) -> MediaPage:
277-
scope = hashlib.sha256(
277+
repository_scope = hashlib.sha256(
278278
str(self.settings.repository_root.resolve()).encode()
279279
).hexdigest()
280+
scope = repository_scope
281+
if command.filename is not None or command.state is not None:
282+
scope = hashlib.sha256(
283+
json.dumps(
284+
[
285+
repository_scope,
286+
command.filename,
287+
command.state.value if command.state is not None else None,
288+
],
289+
separators=(",", ":"),
290+
).encode()
291+
).hexdigest()
280292
try:
281293
offset = decode_offset_cursor(command.cursor, scope=scope)
282294
except CursorError as exc:
283295
raise ValueError("The media cursor is invalid.") from exc
284-
total = self.catalog.count_media()
296+
total = self.catalog.count_media(
297+
filename=command.filename,
298+
state=command.state,
299+
)
285300
if offset > total:
286301
raise ValueError("The media cursor is outside the result set.")
287302
items = tuple(
288303
media_asset(record)
289304
for record in self.catalog.list_media(
290305
limit=command.page_size,
291306
offset=offset,
307+
filename=command.filename,
308+
state=command.state,
292309
)
293310
)
294311
next_offset = offset + len(items)

0 commit comments

Comments
 (0)