Skip to content

Commit 738f172

Browse files
committed
refactor: move metadata extraction to server
1 parent 2546198 commit 738f172

17 files changed

Lines changed: 355 additions & 96 deletions

File tree

API.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,9 +818,45 @@ Each field object can have:
818818
- `min`, `max` (number, optional): Numeric value constraints
819819
- `regex` (string, optional): Regular expression pattern for string validation
820820
- `default` (any, optional): Default value if not provided
821+
- `extract_regex` (string, optional): Python regular expression used by `/api/metadata/extract` to prefill metadata from a filename
821822

822823
**Notes:**
823824
- Schema is loaded from `{config_path}/metadata.json`
825+
- `extract_regex` is interpreted with Python `re` syntax
826+
827+
---
828+
829+
### POST /api/metadata/extract
830+
831+
Extract metadata values from a filename using the configured schema.
832+
833+
**Authentication:** None
834+
835+
**Request Body:**
836+
```json
837+
{
838+
"filename": "240101 Example Show [youtube-dQw4w9WgXcQ].mp4"
839+
}
840+
```
841+
842+
**Response (200):**
843+
```json
844+
{
845+
"metadata": {
846+
"broadcast_date": "2024-01-01",
847+
"title": "Example Show",
848+
"source": "youtube",
849+
"source_id": "dQw4w9WgXcQ"
850+
}
851+
}
852+
```
853+
854+
**Extraction Rules:**
855+
1. Fields without `extract_regex` are ignored
856+
2. The regex is matched against the provided filename with case-insensitive search
857+
3. Capture group 1 is used when present; otherwise the full match is used
858+
4. For `date` fields, named groups `year`, `month`, and `day` are combined into `YYYY-MM-DD`
859+
5. Two-digit years are normalized to `20xx`
824860

825861
---
826862

backend/app/metadata_schema.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import re
23
from datetime import date, datetime
34
from pathlib import Path
45
from typing import Any
@@ -177,3 +178,41 @@ def validate_metadata(values: dict[str, Any]) -> dict[str, Any]:
177178
cleaned[key] = val.isoformat() if isinstance(val, (datetime, date)) else val
178179

179180
return cleaned
181+
182+
183+
def extract_metadata_from_filename(filename: str) -> dict[str, Any]:
184+
"""
185+
Extract metadata values from a filename using the configured schema.
186+
187+
Args:
188+
filename (str): Filename to inspect.
189+
190+
Returns:
191+
dict[str, Any]: Extracted metadata values.
192+
193+
"""
194+
schema: list[dict] = load_schema()
195+
extracted: dict[str, Any] = {}
196+
197+
for field in schema:
198+
regex: str | None = field.get("extract_regex")
199+
if not regex:
200+
continue
201+
202+
match = re.search(regex, filename, flags=re.IGNORECASE)
203+
if not match:
204+
continue
205+
206+
value: Any = match.group(1) if match.lastindex else match.group(0)
207+
if field.get("type") == "date":
208+
groups = match.groupdict()
209+
year = groups.get("year")
210+
month = groups.get("month")
211+
day = groups.get("day")
212+
if year and month and day:
213+
full_year = f"20{year}" if len(year) == 2 else year
214+
value = f"{full_year}-{month}-{day}"
215+
216+
extracted[field["key"]] = value
217+
218+
return extracted

backend/app/routers/metadata.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from fastapi import APIRouter
44

5-
from backend.app.metadata_schema import load_schema, validate_metadata
5+
from backend.app import schemas
6+
from backend.app.metadata_schema import extract_metadata_from_filename, load_schema, validate_metadata
67

78
router = APIRouter(prefix="/api/metadata", tags=["metadata"])
89

@@ -19,6 +20,21 @@ async def get_metadata_schema() -> dict[str, list[dict]]:
1920
return {"fields": load_schema()}
2021

2122

23+
@router.post("/extract", name="metadata_schema_extract")
24+
async def extract_metadata_payload(payload: schemas.MetadataExtractRequest) -> schemas.MetadataExtractResponse:
25+
"""
26+
Extract metadata values from a filename.
27+
28+
Args:
29+
payload: Filename payload to inspect.
30+
31+
Returns:
32+
dict: A dictionary containing the extracted metadata.
33+
34+
"""
35+
return schemas.MetadataExtractResponse(metadata=extract_metadata_from_filename(payload.filename))
36+
37+
2238
@router.post("/validate", name="metadata_schema_validate")
2339
async def validate_metadata_payload(payload: dict) -> dict[str, dict[str, Any]]:
2440
"""

backend/app/schemas.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ class UploadRequest(BaseModel):
100100
size_bytes: int | None = Field(None, gt=0)
101101

102102

103+
class MetadataExtractRequest(BaseModel):
104+
filename: str = Field(..., min_length=1)
105+
106+
107+
class MetadataExtractResponse(BaseModel):
108+
metadata: dict[str, Any] = Field(default_factory=dict)
109+
110+
103111
class TokenListResponse(BaseModel):
104112
tokens: list[TokenAdmin]
105113
total: int

backend/tests/test_metadata.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,73 @@ async def test_metadata_validation_rejects_invalid_option():
3939
status_code, result = await validate_metadata(client, {"source": "radio"})
4040
assert status_code == status.HTTP_422_UNPROCESSABLE_CONTENT, "Invalid select option should return 422"
4141
assert result["detail"]["field"] == "source", "Error should indicate 'source' field"
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_metadata_extraction_returns_matching_fields():
46+
schema = [
47+
{
48+
"key": "broadcast_date",
49+
"label": "Broadcast Date",
50+
"type": "date",
51+
"extract_regex": "(?P<year>\\d{2})(?P<month>\\d{2})(?P<day>\\d{2})",
52+
},
53+
{
54+
"key": "title",
55+
"label": "Title",
56+
"type": "string",
57+
"extract_regex": "^(?:.*?\\b\\d{6,8}\\b\\s*)?(.+?)(?=\\s*[\\[(]|\\.\\w+$)",
58+
},
59+
{
60+
"key": "source",
61+
"label": "Source",
62+
"type": "select",
63+
"extract_regex": "(?:^|[^A-Za-z0-9])((?:youtube|tver))(?:[^A-Za-z0-9]|$)",
64+
},
65+
{
66+
"key": "source_id",
67+
"label": "Source ID",
68+
"type": "string",
69+
"extract_regex": "(?:^|[^A-Za-z0-9])\\[(?:youtube|tver)-([^\\]]+)\\](?:[^A-Za-z0-9]|$)",
70+
},
71+
]
72+
seed_schema(schema)
73+
74+
transport = ASGITransport(app=app)
75+
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
76+
resp = await client.post(
77+
app.url_path_for("metadata_schema_extract"),
78+
json={"filename": "240101 Example Show [youtube-dQw4w9WgXcQ].mp4"},
79+
)
80+
81+
assert resp.status_code == status.HTTP_200_OK, "Extraction endpoint should return 200"
82+
assert resp.json()["metadata"] == {
83+
"broadcast_date": "2024-01-01",
84+
"title": "Example Show",
85+
"source": "youtube",
86+
"source_id": "dQw4w9WgXcQ",
87+
}, "Extraction endpoint should return parsed metadata"
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_metadata_extraction_returns_empty_metadata_when_nothing_matches():
92+
seed_schema(
93+
[
94+
{
95+
"key": "title",
96+
"label": "Title",
97+
"type": "string",
98+
"extract_regex": "source-(\\w+)",
99+
},
100+
]
101+
)
102+
103+
transport = ASGITransport(app=app)
104+
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
105+
resp = await client.post(
106+
app.url_path_for("metadata_schema_extract"),
107+
json={"filename": "plain-file.mp4"},
108+
)
109+
110+
assert resp.status_code == status.HTTP_200_OK, "Extraction endpoint should return 200"
111+
assert resp.json()["metadata"] == {}, "Extraction endpoint should return an empty object when nothing matches"

frontend/app/composables/useMetadata.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,17 @@ export function useMetadata() {
2222
}
2323
}
2424

25-
return { isLoading, metadataSchema, fetchMetadata };
25+
async function extractMetadata(filename: string): Promise<Record<string, any>> {
26+
try {
27+
const data = await $fetch<{ metadata: Record<string, any> }>('/api/metadata/extract', {
28+
method: 'POST',
29+
body: { filename },
30+
});
31+
return data.metadata || {};
32+
} catch {
33+
return {};
34+
}
35+
}
36+
37+
return { isLoading, metadataSchema, fetchMetadata, extractMetadata };
2638
}

frontend/app/composables/useMetadataParser.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.

frontend/app/pages/t/[token].vue

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ import type {
132132
} from '~/types/uploads';
133133
import { useTokenInfo } from '~/composables/useTokenInfo';
134134
import { useMetadata } from '~/composables/useMetadata';
135-
import { useMetadataParser } from '~/composables/useMetadataParser';
136135
import { validateSlot } from '~/utils/validation';
137136
import { useTusUpload } from '~/composables/useTusUpload';
138137
import { useUploadSlots } from '~/composables/useUploadSlots';
@@ -145,8 +144,7 @@ const token = ref<string>((route.params.token as string) || '');
145144
146145
const { tokenInfo, notFound, tokenError, isExpired, isDisabled, shareLinkText, fetchTokenInfo } =
147146
useTokenInfo(token);
148-
const { metadataSchema, fetchMetadata } = useMetadata();
149-
const { applyParsedMeta } = useMetadataParser();
147+
const { metadataSchema, fetchMetadata, extractMetadata } = useMetadata();
150148
const { startTusUpload, pauseUpload, resumeUpload } = useTusUpload();
151149
const { slots, seedSlots, addSlot, unintiatedSlots } = useUploadSlots(metadataSchema);
152150
const { pollUploadStatus, stopPolling, stopAllPolling } = useUploadPolling();
@@ -211,11 +209,35 @@ async function refreshAll() {
211209
seedSlots(tokenInfo.value);
212210
}
213211
214-
function onFile(slot: Slot, e: Event) {
212+
function buildDefaultMetadataValues() {
213+
return Object.fromEntries(metadataSchema.value.map((field) => [field.key, field.default ?? '']));
214+
}
215+
216+
async function onFile(slot: Slot, e: Event) {
215217
const target = e.target as HTMLInputElement;
216-
slot.file = target.files?.[0] || null;
217-
metadataSchema.value.forEach((f) => (slot.values[f.key] = f.default ?? ''));
218-
if (slot.file) applyParsedMeta(slot, slot.file.name, metadataSchema.value);
218+
const selectedFile = target.files?.[0] || null;
219+
const defaultValues = buildDefaultMetadataValues();
220+
221+
slot.file = selectedFile;
222+
slot.values = defaultValues;
223+
slot.errors = validateSlot(slot, metadataSchema.value, tokenInfo.value);
224+
225+
if (!selectedFile) {
226+
return;
227+
}
228+
229+
const extractedValues = await extractMetadata(selectedFile.name);
230+
if (slot.file !== selectedFile) {
231+
return;
232+
}
233+
234+
const nextValues = { ...slot.values };
235+
Object.entries(extractedValues).forEach(([key, value]) => {
236+
if (slot.values[key] === defaultValues[key]) {
237+
nextValues[key] = value;
238+
}
239+
});
240+
slot.values = nextValues;
219241
slot.errors = validateSlot(slot, metadataSchema.value, tokenInfo.value);
220242
}
221243

frontend/app/tests/useMetadata.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,31 @@ describe('useMetadata', () => {
4949

5050
expect(metadataSchema.value).toEqual([]);
5151
});
52+
53+
it('extracts metadata from the backend endpoint', async () => {
54+
const fetchMock = mock(async () => ({ metadata: { title: 'Example Show' } }));
55+
testGlobals.$fetch = fetchMock;
56+
const { extractMetadata } = useMetadata();
57+
58+
const metadata = await extractMetadata('example.mp4');
59+
60+
expect(fetchMock).toHaveBeenCalledTimes(1);
61+
expect(fetchMock).toHaveBeenCalledWith('/api/metadata/extract', {
62+
method: 'POST',
63+
body: { filename: 'example.mp4' },
64+
});
65+
expect(metadata).toEqual({ title: 'Example Show' });
66+
});
67+
68+
it('returns empty metadata when extraction fails', async () => {
69+
const fetchMock = mock(async () => {
70+
throw new Error('network');
71+
});
72+
testGlobals.$fetch = fetchMock;
73+
const { extractMetadata } = useMetadata();
74+
75+
const metadata = await extractMetadata('example.mp4');
76+
77+
expect(metadata).toEqual({});
78+
});
5279
});

0 commit comments

Comments
 (0)