textflowkit 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. textflowkit/__init__.py +8 -0
  2. textflowkit/adapters/__init__.py +11 -0
  3. textflowkit/adapters/http_server.py +465 -0
  4. textflowkit/adapters/mcp_server.py +554 -0
  5. textflowkit/cli.py +473 -0
  6. textflowkit/core/__init__.py +5 -0
  7. textflowkit/core/batch.py +186 -0
  8. textflowkit/core/bind.py +66 -0
  9. textflowkit/core/cancel.py +19 -0
  10. textflowkit/core/checkpoint.py +389 -0
  11. textflowkit/core/diarize.py +229 -0
  12. textflowkit/core/engine.py +127 -0
  13. textflowkit/core/executor.py +301 -0
  14. textflowkit/core/jobs.py +241 -0
  15. textflowkit/core/model.py +98 -0
  16. textflowkit/core/paths.py +226 -0
  17. textflowkit/core/pipeline.py +368 -0
  18. textflowkit/core/retrieval.py +148 -0
  19. textflowkit/core/runner.py +146 -0
  20. textflowkit/core/service.py +146 -0
  21. textflowkit/core/sqlite_store.py +233 -0
  22. textflowkit/core/submission.py +220 -0
  23. textflowkit/core/timeutil.py +20 -0
  24. textflowkit/core/translate.py +244 -0
  25. textflowkit/render/__init__.py +191 -0
  26. textflowkit/render/docx.py +71 -0
  27. textflowkit/render/fonts/NotoSans.ttf +0 -0
  28. textflowkit/render/fonts/NotoSansArabic.ttf +0 -0
  29. textflowkit/render/fonts/NotoSansSC.ttf +0 -0
  30. textflowkit/render/fonts/OFL-NotoSans.txt +94 -0
  31. textflowkit/render/fonts/OFL-NotoSansSC.txt +93 -0
  32. textflowkit/render/fonts/README.md +19 -0
  33. textflowkit/render/markdown.py +33 -0
  34. textflowkit/render/pdf.py +136 -0
  35. textflowkit/render/srt.py +22 -0
  36. textflowkit/render/txt.py +19 -0
  37. textflowkit/render/vtt.py +20 -0
  38. textflowkit/sources/__init__.py +16 -0
  39. textflowkit/sources/acquire.py +437 -0
  40. textflowkit/sources/detect.py +200 -0
  41. textflowkit/sources/scratch.py +32 -0
  42. textflowkit-0.1.3.dist-info/METADATA +266 -0
  43. textflowkit-0.1.3.dist-info/RECORD +46 -0
  44. textflowkit-0.1.3.dist-info/WHEEL +4 -0
  45. textflowkit-0.1.3.dist-info/entry_points.txt +4 -0
  46. textflowkit-0.1.3.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,554 @@
1
+ """MCP server adapter.
2
+
3
+ Exposes the textflowkit core over the Model Context Protocol.
4
+
5
+ Transport decision (2026-09-21): stdio first, Streamable HTTP second. Both are
6
+ served from this one module so the tool definitions cannot drift apart. Every
7
+ tool calls straight into `core.runner`; no pipeline logic lives here.
8
+
9
+ Verified harness support:
10
+ - DSH (@deepseek-ai/dsh-mcp-client) - stdio + streamable-http
11
+ - Claude Code - stdio + http (+ sse)
12
+ - Codex CLI - stdio (add via `codex mcp add`)
13
+ - OpenCode - remote (http) + local (stdio)
14
+
15
+ Tools are named `transcribe_media`, `get_transcript`, `list_jobs`,
16
+ `get_job_status`, and `export_transcript`, and are deliberately job-based: a
17
+ long video returns a job id immediately rather than blocking the call.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+ from typing import Any
24
+
25
+ from textflowkit import __version__
26
+ from textflowkit.core.bind import ENV_ALLOW_REMOTE, UnsafeBindError, check_bind_safety
27
+ from textflowkit.core.executor import QueueFullError, get_default_executor
28
+ from textflowkit.core.jobs import Job, JobState, get_default_store, validate_list_limit
29
+ from textflowkit.core.model import Transcript
30
+ from textflowkit.core.paths import (
31
+ UnsafeOutputPathError,
32
+ ensure_output_dir,
33
+ server_input_root,
34
+ )
35
+ from textflowkit.core.retrieval import page_segments, search_segments
36
+ from textflowkit.core.runner import transcript_for
37
+ from textflowkit.core.submission import (
38
+ SubmissionRequest,
39
+ submit_batch,
40
+ submit_request,
41
+ )
42
+ from textflowkit.core.submission import (
43
+ resume_job as core_resume_job,
44
+ )
45
+ from textflowkit.render import (
46
+ SUPPORTED_FORMATS,
47
+ TEXT_FORMATS,
48
+ atomic_write_bytes,
49
+ render,
50
+ render_bytes,
51
+ )
52
+ from textflowkit.sources.detect import PLATFORMS
53
+
54
+ try: # the MCP SDK is an optional extra
55
+ # mcp 2.x renamed FastMCP to MCPServer (mcp.server.mcpserver).
56
+ from mcp.server.mcpserver import MCPServer
57
+ from mcp.types import ToolAnnotations
58
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
59
+ raise ImportError(
60
+ "The MCP adapter requires the 'mcp' extra. Install with: pip install 'textflowkit[mcp]'"
61
+ ) from exc
62
+
63
+ # Tools that only read state.
64
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, openWorldHint=False)
65
+ # Tools that reach the network or write files.
66
+ OPEN_WORLD = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=True)
67
+ # Tools that change local job state without touching the network or disk.
68
+ MUTATING = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=False)
69
+
70
+
71
+ INSTRUCTIONS = """\
72
+ Transcribe media from a URL or local file into timestamped text and subtitles.
73
+
74
+ Typical flow:
75
+ 1. transcribe_media(source=...) -> returns a job id
76
+ 2. get_job_status(job_id=...) -> poll until state is done
77
+ 3. get_transcript(job_id=...) -> read the transcript (or export_transcript for files)
78
+
79
+ Transcription runs locally. Long videos are processed in the background, so never
80
+ block on transcribe_media; poll the job instead.
81
+ """
82
+
83
+ mcp = MCPServer("textflowkit", instructions=INSTRUCTIONS, version=__version__)
84
+
85
+
86
+ def _job_payload(job: Job) -> dict[str, Any]:
87
+ return job.to_dict()
88
+
89
+
90
+ def _resolve_job(job_id: str) -> tuple[Job | None, str | None]:
91
+ store = get_default_store()
92
+ job = store.get(job_id)
93
+ if job is None:
94
+ return None, f"error: no job with id '{job_id}'. Use list_jobs to see known jobs."
95
+ return job, None
96
+
97
+
98
+ @mcp.tool(annotations=READ_ONLY)
99
+ def list_sources() -> dict[str, Any]:
100
+ """List the media platforms and input kinds textflowkit can transcribe.
101
+
102
+ Returns recognised platform names plus 'local' (filesystem paths) and
103
+ 'direct' (direct media URLs).
104
+ """
105
+ return {
106
+ "platforms": sorted(PLATFORMS),
107
+ "input_kinds": ["local", "direct"],
108
+ "formats": list(SUPPORTED_FORMATS),
109
+ }
110
+
111
+
112
+ @mcp.tool(annotations=OPEN_WORLD)
113
+ def transcribe_media(
114
+ source: str,
115
+ language: str | None = None,
116
+ formats: str = "json,srt,txt",
117
+ output_dir: str | None = None,
118
+ model: str = "small",
119
+ device: str | None = None,
120
+ cookies_from_browser: str | None = None,
121
+ diarize: bool = False,
122
+ translate_to: str | None = None,
123
+ ) -> dict[str, Any]:
124
+ """Start transcribing a media URL or local file. Returns immediately with a job id.
125
+
126
+ The work runs in the background; poll get_job_status until state is 'done',
127
+ then read get_transcript. Do not expect a transcript in this response.
128
+
129
+ Args:
130
+ source: A media URL (YouTube, TikTok, Facebook, Instagram, Vimeo,
131
+ Twitch, Bilibili, Rumble, Kick, Zoom, Medal, Loom, Dropbox, or a
132
+ direct media link) or a path to a local file.
133
+ language: Optional ISO language code (e.g. 'en'). Auto-detected if omitted.
134
+ formats: Comma-separated outputs to write when output_dir is set.
135
+ Available: txt, srt, vtt, md, json.
136
+ output_dir: Directory to write rendered files into. Omit to keep the
137
+ transcript in memory only.
138
+ model: Whisper model size - tiny, base, small, medium, or large.
139
+ Larger is more accurate and slower. Default small.
140
+ device: Torch device ('cuda' or 'cpu'). Auto-detected when omitted.
141
+ cookies_from_browser: Pass cookies to yt-dlp from a browser, e.g.
142
+ 'firefox'. Only for media you are authorised to access.
143
+ diarize: Label speakers. Requires the optional diarize extra and a gated
144
+ Hugging Face model; the job fails with a clear error if unavailable
145
+ rather than returning empty speakers.
146
+ translate_to: Target language code (e.g. 'es'). Translates the transcript
147
+ with the configured backend; fails loudly if it is unreachable.
148
+ """
149
+ fmt_list = [f.strip().lower().lstrip(".") for f in formats.split(",") if f.strip()]
150
+ bad = [f for f in fmt_list if f not in SUPPORTED_FORMATS]
151
+ if bad:
152
+ return {
153
+ "error": f"unsupported format(s): {', '.join(bad)}",
154
+ "available_formats": list(SUPPORTED_FORMATS),
155
+ }
156
+
157
+ try:
158
+ request = SubmissionRequest(
159
+ source=source,
160
+ language=language,
161
+ formats=fmt_list,
162
+ output_dir=output_dir,
163
+ model=model,
164
+ device=device,
165
+ cookies_from_browser=cookies_from_browser,
166
+ input_root=server_input_root(),
167
+ diarize=diarize,
168
+ translate_to=translate_to,
169
+ )
170
+ job = submit_request(get_default_store(), request)
171
+ except ValueError as exc:
172
+ return {"error": str(exc)}
173
+ except QueueFullError as exc:
174
+ return {"error": str(exc), "retryable": True}
175
+ return {
176
+ "job_id": job.id,
177
+ "state": job.state.value,
178
+ "source": job.source,
179
+ "next": f"Poll get_job_status with job_id='{job.id}' until state is 'done'.",
180
+ }
181
+
182
+
183
+ @mcp.tool(annotations=OPEN_WORLD)
184
+ def submit_batch_media(
185
+ sources: list[str],
186
+ language: str | None = None,
187
+ formats: str = "json,srt,txt",
188
+ output_dir: str | None = None,
189
+ model: str = "small",
190
+ device: str | None = None,
191
+ diarize: bool = False,
192
+ translate_to: str | None = None,
193
+ resume: bool = False,
194
+ ) -> dict[str, Any]:
195
+ """Queue multiple independent media jobs and return each job handle."""
196
+ fmt_list = [f.strip().lower().lstrip(".") for f in formats.split(",") if f.strip()]
197
+ try:
198
+ requests = [SubmissionRequest(
199
+ source=source, language=language, formats=fmt_list,
200
+ output_dir=output_dir, model=model, device=device,
201
+ diarize=diarize, translate_to=translate_to,
202
+ input_root=server_input_root(),
203
+ ) for source in sources]
204
+ except ValueError as exc:
205
+ return {"error": str(exc)}
206
+ results = submit_batch(get_default_store(), requests, resume=resume)
207
+ return {"count": len(results), "jobs": results}
208
+
209
+
210
+ @mcp.tool(annotations=MUTATING)
211
+ def resume_job(job_id: str) -> dict[str, Any]:
212
+ """Resume an interrupted job using its saved request and transcript checkpoint."""
213
+ try:
214
+ job = core_resume_job(get_default_store(), job_id)
215
+ except QueueFullError as exc:
216
+ return {"error": str(exc), "retryable": True}
217
+ except ValueError as exc:
218
+ return {"error": str(exc)}
219
+ return {"job_id": job.id, "state": job.state.value}
220
+
221
+
222
+ @mcp.tool(annotations=READ_ONLY)
223
+ def get_job_status(job_id: str) -> dict[str, Any]:
224
+ """Check the state of a transcription job.
225
+
226
+ Args:
227
+ job_id: The id returned by transcribe_media.
228
+ """
229
+ job, err = _resolve_job(job_id)
230
+ if err:
231
+ return {"error": err}
232
+ assert job is not None
233
+ payload = _job_payload(job)
234
+ if job.state is JobState.DONE:
235
+ payload["next"] = f"Read the transcript with get_transcript(job_id='{job.id}')."
236
+ elif job.state is JobState.ERROR:
237
+ payload["next"] = "The job failed; see the 'error' field."
238
+ else:
239
+ payload["next"] = "Still working. Poll again."
240
+ return payload
241
+
242
+
243
+ @mcp.tool(annotations=READ_ONLY)
244
+ def get_transcript(
245
+ job_id: str,
246
+ fmt: str = "txt",
247
+ offset: int = 0,
248
+ limit: int | None = None,
249
+ start: float | None = None,
250
+ end: float | None = None,
251
+ ) -> dict[str, Any]:
252
+ """Read the transcript for a completed job, optionally a slice of it.
253
+
254
+ For long transcripts do not request everything: page with offset/limit, or
255
+ ask for a time range with start/end (seconds). The response reports
256
+ total_segments and has_more so you know whether to continue.
257
+
258
+ Args:
259
+ job_id: The id returned by transcribe_media.
260
+ fmt: How to render the text - txt, srt, vtt, md, or json.
261
+ offset: Skip this many segments within the selected range.
262
+ limit: Return at most this many segments.
263
+ start: Only segments ending at or after this time (seconds).
264
+ end: Only segments starting at or before this time (seconds).
265
+ """
266
+ job, err = _resolve_job(job_id)
267
+ if err:
268
+ return {"error": err}
269
+ assert job is not None
270
+ if job.state is not JobState.DONE:
271
+ return {
272
+ "error": f"job is not finished (state: {job.state.value})",
273
+ "state": job.state.value,
274
+ "next": "Poll get_job_status until state is 'done'.",
275
+ }
276
+ tr = transcript_for(job)
277
+ if tr is None:
278
+ return {"error": "job completed but contains no transcript"}
279
+
280
+ norm = fmt.lower().lstrip(".")
281
+ if norm not in TEXT_FORMATS:
282
+ # docx/pdf are export-only: they are binary and cannot be returned as
283
+ # inline text. Say so rather than failing deeper down.
284
+ return {
285
+ "error": f"'{fmt}' cannot be returned inline",
286
+ "available_formats": list(TEXT_FORMATS),
287
+ "hint": "binary formats (docx, pdf) are written to disk with export_transcript",
288
+ }
289
+
290
+ try:
291
+ page = page_segments(tr, offset=offset, limit=limit, start=start, end=end)
292
+ except ValueError as exc:
293
+ return {"error": str(exc)}
294
+
295
+ sliced = Transcript(
296
+ source=tr.source,
297
+ language=tr.language,
298
+ platform=tr.platform,
299
+ duration=tr.duration,
300
+ engine=tr.engine,
301
+ metadata=tr.metadata,
302
+ segments=page.segments,
303
+ )
304
+ payload = {
305
+ "job_id": job.id,
306
+ "format": norm,
307
+ "language": tr.language,
308
+ "platform": tr.platform,
309
+ **page.as_dict(),
310
+ "content": render(sliced, norm),
311
+ }
312
+ if page.has_more:
313
+ payload["next"] = (
314
+ f"More segments remain. Request offset={page.offset + page.returned} "
315
+ f"for the next page."
316
+ )
317
+ return payload
318
+
319
+
320
+ @mcp.tool(annotations=READ_ONLY)
321
+ def search_transcript(
322
+ job_id: str,
323
+ query: str,
324
+ limit: int = 20,
325
+ context: int = 1,
326
+ case_sensitive: bool = False,
327
+ ) -> dict[str, Any]:
328
+ """Search a completed transcript for a phrase.
329
+
330
+ Returns matching segments with their timestamps, newest-first order
331
+ preserved from the transcript. Use this instead of paging through a long
332
+ transcript looking for a topic.
333
+
334
+ Args:
335
+ job_id: The id returned by transcribe_media.
336
+ query: Substring to find (not fuzzy).
337
+ limit: Maximum number of matches to return.
338
+ context: How many neighbouring segments to include either side.
339
+ case_sensitive: Match case-sensitively (default false).
340
+ """
341
+ job, err = _resolve_job(job_id)
342
+ if err:
343
+ return {"error": err}
344
+ assert job is not None
345
+ if job.state is not JobState.DONE:
346
+ return {
347
+ "error": f"job is not finished (state: {job.state.value})",
348
+ "state": job.state.value,
349
+ }
350
+ tr = transcript_for(job)
351
+ if tr is None:
352
+ return {"error": "job completed but contains no transcript"}
353
+
354
+ try:
355
+ matches = search_segments(
356
+ tr, query, limit=limit, context=context, case_sensitive=case_sensitive
357
+ )
358
+ except ValueError as exc:
359
+ return {"error": str(exc)}
360
+
361
+ return {
362
+ "job_id": job.id,
363
+ "query": query,
364
+ "match_count": len(matches),
365
+ "matches": [
366
+ {
367
+ "index": m.index,
368
+ "start": m.segment.start,
369
+ "end": m.segment.end,
370
+ "text": m.segment.display_text(),
371
+ "context_before": [c.display_text() for c in m.context_before],
372
+ "context_after": [c.display_text() for c in m.context_after],
373
+ }
374
+ for m in matches
375
+ ],
376
+ }
377
+
378
+
379
+ @mcp.tool(annotations=OPEN_WORLD)
380
+ def export_transcript(
381
+ job_id: str,
382
+ output_dir: str,
383
+ formats: str = "srt,vtt,txt,json",
384
+ ) -> dict[str, Any]:
385
+ """Write a completed transcript to files on disk.
386
+
387
+ Args:
388
+ job_id: The id returned by transcribe_media.
389
+ output_dir: Directory to write into. Created if missing.
390
+ formats: Comma-separated formats to write - txt, srt, vtt, md, json.
391
+ """
392
+ job, err = _resolve_job(job_id)
393
+ if err:
394
+ return {"error": err}
395
+ assert job is not None
396
+ if job.state is not JobState.DONE:
397
+ return {"error": f"job is not finished (state: {job.state.value})"}
398
+
399
+ tr = transcript_for(job)
400
+ if tr is None:
401
+ return {"error": "job contains no transcript"}
402
+
403
+ fmt_list = [f.strip().lower().lstrip(".") for f in formats.split(",") if f.strip()]
404
+ bad = [f for f in fmt_list if f not in SUPPORTED_FORMATS]
405
+ if bad:
406
+ return {"error": f"unsupported format(s): {', '.join(bad)}"}
407
+ if len(fmt_list) != len(set(fmt_list)):
408
+ return {"error": "duplicate output format"}
409
+ try:
410
+ rendered = [(f, render_bytes(tr, f, title=job.id)) for f in fmt_list]
411
+ except (ValueError, ImportError) as exc:
412
+ return {"error": str(exc)}
413
+
414
+ try:
415
+ out = ensure_output_dir(output_dir)
416
+ except UnsafeOutputPathError as exc:
417
+ return {"error": str(exc)}
418
+
419
+ written = []
420
+ for f, content in rendered:
421
+ path = out / f"{job.id}.{f}"
422
+ atomic_write_bytes(path, content, replace=True)
423
+ written.append(str(path))
424
+ return {"job_id": job.id, "written": written}
425
+
426
+
427
+ @mcp.tool(annotations=READ_ONLY)
428
+ def list_jobs(limit: int = 20, state: str | None = None) -> dict[str, Any]:
429
+ """List recent transcription jobs, newest first.
430
+
431
+ Args:
432
+ limit: Maximum number of jobs to return.
433
+ state: Optional filter - pending, running, done, error, or cancelled.
434
+ """
435
+ try:
436
+ validate_list_limit(limit)
437
+ except ValueError as exc:
438
+ return {"error": str(exc)}
439
+ store = get_default_store()
440
+ filter_state = None
441
+ if state:
442
+ try:
443
+ filter_state = JobState(state.lower())
444
+ except ValueError:
445
+ return {
446
+ "error": f"unknown state '{state}'",
447
+ "available_states": [s.value for s in JobState],
448
+ }
449
+ jobs = store.list(limit=limit, state=filter_state)
450
+ return {"count": len(jobs), "jobs": [j.to_dict() for j in jobs]}
451
+
452
+
453
+ @mcp.tool(annotations=MUTATING)
454
+ def cancel_job(job_id: str) -> dict[str, Any]:
455
+ """Request cancellation of a pending or running job.
456
+
457
+ A queued job is cancelled immediately and never starts. A running job stops
458
+ at its next stage boundary, so it stays 'running' with cancel_requested set
459
+ until the boundary is reached. Poll get_job_status until state is
460
+ 'cancelled'.
461
+
462
+ Args:
463
+ job_id: The id returned by transcribe_media.
464
+ """
465
+ job, err = _resolve_job(job_id)
466
+ if err:
467
+ return {"error": err}
468
+ assert job is not None
469
+
470
+ if job.is_terminal:
471
+ return {
472
+ "job_id": job_id,
473
+ "cancelled": False,
474
+ "state": job.state.value,
475
+ "reason": f"job is already {job.state.value}",
476
+ }
477
+
478
+ executor = get_default_executor()
479
+ accepted = executor.cancel(job_id)
480
+ latest = executor.store.get(job_id)
481
+ state = latest.state.value if latest is not None else job.state.value
482
+
483
+ next_step = (
484
+ "Job cancelled."
485
+ if state == "cancelled"
486
+ else "Cancellation requested; poll get_job_status until state is 'cancelled'."
487
+ )
488
+ return {
489
+ "job_id": job_id,
490
+ "cancelled": accepted,
491
+ "state": state,
492
+ "next": next_step,
493
+ }
494
+
495
+
496
+ def run_stdio() -> None:
497
+ """Run the server over stdio (the default for local harnesses)."""
498
+ mcp.run(transport="stdio")
499
+
500
+
501
+ def run_http(host: str = "127.0.0.1", port: int = 8766, path: str = "/mcp") -> None:
502
+ """Run the server over Streamable HTTP (for remote/multi-client use).
503
+
504
+ Binds to localhost by default. Exposing this beyond localhost requires your
505
+ own auth layer; the server ships none.
506
+ """
507
+ mcp.run(transport="streamable-http", host=host, port=port, streamable_http_path=path)
508
+
509
+
510
+ def main(argv: list[str] | None = None) -> int:
511
+ import argparse
512
+
513
+ parser = argparse.ArgumentParser(
514
+ prog="textflowkit-mcp",
515
+ description="textflowkit MCP server (stdio by default).",
516
+ )
517
+ parser.add_argument(
518
+ "--transport",
519
+ choices=("stdio", "http"),
520
+ default="stdio",
521
+ help="Transport to serve on (default: stdio).",
522
+ )
523
+ parser.add_argument("--host", default="127.0.0.1", help="Bind host for http (default 127.0.0.1)")
524
+ parser.add_argument("--port", type=int, default=8766, help="Bind port for http (default 8766)")
525
+ parser.add_argument("--path", default="/mcp", help="URL path for http (default /mcp)")
526
+ parser.add_argument(
527
+ "--allow-remote",
528
+ action="store_true",
529
+ help=(
530
+ "permit binding HTTP beyond loopback. This surface has no "
531
+ f"authentication. ({ENV_ALLOW_REMOTE}=1 also works)"
532
+ ),
533
+ )
534
+ parser.add_argument("--version", action="version", version=f"textflowkit-mcp {__version__}")
535
+ args = parser.parse_args(argv)
536
+
537
+ if args.transport == "stdio":
538
+ run_stdio()
539
+ else:
540
+ try:
541
+ check_bind_safety(args.host, allow_remote=args.allow_remote or None)
542
+ except UnsafeBindError as exc:
543
+ print(f"error: {exc}", file=sys.stderr)
544
+ return 2
545
+ run_http(host=args.host, port=args.port, path=args.path)
546
+ return 0
547
+
548
+
549
+ if __name__ == "__main__":
550
+ raise SystemExit(main())
551
+
552
+
553
+
554
+