classifyre-cli 0.4.52__tar.gz → 0.4.53__tar.gz

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 (243) hide show
  1. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/.turbo/turbo-build.log +1 -1
  2. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/PKG-INFO +1 -1
  3. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/package.json +1 -1
  4. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/pyproject.toml +13 -1
  5. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/models/generated_input.py +0 -12
  6. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/pipeline/detector_pipeline.py +7 -10
  7. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/base.py +0 -16
  8. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/object_storage/base.py +5 -10
  9. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/youtube/source.py +131 -75
  10. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/dependency_groups.py +0 -7
  11. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/file_parser.py +59 -41
  12. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/transcription.py +161 -58
  13. classifyre_cli-0.4.53/src/utils/video_processing.py +299 -0
  14. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/pipeline/test_detector_pipeline.py +12 -2
  15. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_dependency_groups.py +7 -13
  16. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_jira_source.py +7 -7
  17. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_s3_compatible_storage_source.py +2 -10
  18. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_youtube_source.py +69 -13
  19. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/utils/test_file_parser.py +39 -64
  20. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/utils/test_transcription.py +26 -1
  21. classifyre_cli-0.4.53/tests/utils/test_video_processing.py +141 -0
  22. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/uv.lock +78 -82
  23. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/.gitignore +0 -0
  24. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/.python-version +0 -0
  25. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/README.md +0 -0
  26. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/main.py +0 -0
  27. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/scripts/generate_models.py +0 -0
  28. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/__init__.py +0 -0
  29. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/config.py +0 -0
  30. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/__init__.py +0 -0
  31. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/base.py +0 -0
  32. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/broken_links/__init__.py +0 -0
  33. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/broken_links/detector.py +0 -0
  34. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/config.py +0 -0
  35. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/content/__init__.py +0 -0
  36. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/__init__.py +0 -0
  37. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/detector.py +0 -0
  38. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/extractor.py +0 -0
  39. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/__init__.py +0 -0
  40. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_base.py +0 -0
  41. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_factory.py +0 -0
  42. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_feature_extraction.py +0 -0
  43. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_gliner2.py +0 -0
  44. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_image_classification.py +0 -0
  45. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_llm.py +0 -0
  46. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_object_detection.py +0 -0
  47. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_regex.py +0 -0
  48. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/runners/_text_classification.py +0 -0
  49. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/custom/trainer.py +0 -0
  50. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/dependencies.py +0 -0
  51. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/pii/__init__.py +0 -0
  52. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/pii/detector.py +0 -0
  53. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/secrets/__init__.py +0 -0
  54. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/secrets/detector.py +0 -0
  55. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/threat/__init__.py +0 -0
  56. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/threat/code_security_detector.py +0 -0
  57. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/detectors/threat/yara_detector.py +0 -0
  58. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/main.py +0 -0
  59. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/models/generated_detectors.py +0 -0
  60. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/models/generated_single_asset_scan_results.py +0 -0
  61. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/outputs/__init__.py +0 -0
  62. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/outputs/base.py +0 -0
  63. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/outputs/console.py +0 -0
  64. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/outputs/factory.py +0 -0
  65. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/outputs/file.py +0 -0
  66. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/outputs/rest.py +0 -0
  67. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/pipeline/__init__.py +0 -0
  68. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/pipeline/content_provider.py +0 -0
  69. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/pipeline/parsed_content_provider.py +0 -0
  70. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/pipeline/worker_pool.py +0 -0
  71. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sandbox/__init__.py +0 -0
  72. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sandbox/runner.py +0 -0
  73. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/__init__.py +0 -0
  74. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/asset_metadata.py +0 -0
  75. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/atlassian_common.py +0 -0
  76. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/azure_blob_storage/__init__.py +0 -0
  77. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/azure_blob_storage/source.py +0 -0
  78. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/confluence/__init__.py +0 -0
  79. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/confluence/source.py +0 -0
  80. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/databricks/__init__.py +0 -0
  81. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/databricks/source.py +0 -0
  82. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/delta_lake/__init__.py +0 -0
  83. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/delta_lake/source.py +0 -0
  84. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/dependencies.py +0 -0
  85. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/elasticsearch/__init__.py +0 -0
  86. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/elasticsearch/source.py +0 -0
  87. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/email/__init__.py +0 -0
  88. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/email/source.py +0 -0
  89. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/google_cloud_storage/__init__.py +0 -0
  90. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/google_cloud_storage/source.py +0 -0
  91. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/google_workspace/__init__.py +0 -0
  92. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/google_workspace/source.py +0 -0
  93. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/hive/__init__.py +0 -0
  94. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/hive/source.py +0 -0
  95. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/iceberg/__init__.py +0 -0
  96. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/iceberg/source.py +0 -0
  97. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/jira/__init__.py +0 -0
  98. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/jira/source.py +0 -0
  99. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/kafka/__init__.py +0 -0
  100. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/kafka/source.py +0 -0
  101. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/lakehouse_base.py +0 -0
  102. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/local_folder/__init__.py +0 -0
  103. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/local_folder/source.py +0 -0
  104. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/meilisearch/__init__.py +0 -0
  105. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/meilisearch/source.py +0 -0
  106. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/microsoft_365/__init__.py +0 -0
  107. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/microsoft_365/source.py +0 -0
  108. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/mongodb/__init__.py +0 -0
  109. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/mongodb/source.py +0 -0
  110. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/mssql/__init__.py +0 -0
  111. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/mssql/source.py +0 -0
  112. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/mysql/__init__.py +0 -0
  113. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/mysql/source.py +0 -0
  114. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/neo4j/__init__.py +0 -0
  115. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/neo4j/source.py +0 -0
  116. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/notion/__init__.py +0 -0
  117. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/notion/client.py +0 -0
  118. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/notion/source.py +0 -0
  119. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/opensearch/__init__.py +0 -0
  120. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/opensearch/source.py +0 -0
  121. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/oracle/__init__.py +0 -0
  122. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/oracle/source.py +0 -0
  123. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/postgresql/__init__.py +0 -0
  124. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/postgresql/source.py +0 -0
  125. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/powerbi/__init__.py +0 -0
  126. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/powerbi/source.py +0 -0
  127. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/recipe_normalizer.py +0 -0
  128. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/s3_client.py +0 -0
  129. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/s3_compatible_storage/README.md +0 -0
  130. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/s3_compatible_storage/__init__.py +0 -0
  131. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/s3_compatible_storage/source.py +0 -0
  132. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/search_engine_base.py +0 -0
  133. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/servicedesk/__init__.py +0 -0
  134. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/servicedesk/source.py +0 -0
  135. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/slack/__init__.py +0 -0
  136. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/slack/source.py +0 -0
  137. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/snowflake/__init__.py +0 -0
  138. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/snowflake/source.py +0 -0
  139. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/sqlite/__init__.py +0 -0
  140. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/sqlite/source.py +0 -0
  141. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/tableau/__init__.py +0 -0
  142. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/tableau/source.py +0 -0
  143. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/tabular_base.py +0 -0
  144. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/tabular_utils.py +0 -0
  145. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/wordpress/__init__.py +0 -0
  146. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/wordpress/source.py +0 -0
  147. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/sources/youtube/__init__.py +0 -0
  148. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/telemetry.py +0 -0
  149. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/__init__.py +0 -0
  150. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/content_extraction.py +0 -0
  151. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/embedded_images.py +0 -0
  152. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/file_metadata.py +0 -0
  153. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/file_to_images.py +0 -0
  154. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/hashing.py +0 -0
  155. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/resources.py +0 -0
  156. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/uv_sync.py +0 -0
  157. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/src/utils/validation.py +0 -0
  158. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/__init__.py +0 -0
  159. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/_lakehouse_fakes.py +0 -0
  160. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/conftest.py +0 -0
  161. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/__init__.py +0 -0
  162. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
  163. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/conftest.py +0 -0
  164. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/content/__init__.py +0 -0
  165. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/custom/__init__.py +0 -0
  166. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/custom/conftest.py +0 -0
  167. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/custom/test_invoice_extraction.py +0 -0
  168. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/custom/test_llm_runner.py +0 -0
  169. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/custom/test_pipeline_integration.py +0 -0
  170. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/custom/test_regex_runner.py +0 -0
  171. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/custom/test_runner_hardening.py +0 -0
  172. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/custom/test_transformer_runners.py +0 -0
  173. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/pii/__init__.py +0 -0
  174. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/pii/conftest.py +0 -0
  175. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/pii/sample_invoice.pdf +0 -0
  176. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/pii/test_pii_detector.py +0 -0
  177. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
  178. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/secrets/__init__.py +0 -0
  179. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/secrets/test_secrets_detector.py +0 -0
  180. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
  181. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/test_base_detector.py +0 -0
  182. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
  183. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/test_detector_catalog_commercial.py +0 -0
  184. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/test_detector_pipeline_types.py +0 -0
  185. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/test_detector_schema_examples.py +0 -0
  186. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/test_detector_types.py +0 -0
  187. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/test_phase2_detectors.py +0 -0
  188. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/test_registry.py +0 -0
  189. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/threat/__init__.py +0 -0
  190. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/threat/test_code_security_detector.py +0 -0
  191. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/detectors/threat/test_yara_detector.py +0 -0
  192. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
  193. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/integration/test_wordpress_links_assets.py +0 -0
  194. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/pipeline/test_worker_pool.py +0 -0
  195. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_assets_metadata_catalog.py +0 -0
  196. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_azure_blob_storage_source.py +0 -0
  197. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_base_source_attachment.py +0 -0
  198. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_base_source_sampling.py +0 -0
  199. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_config.py +0 -0
  200. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_confluence_source.py +0 -0
  201. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_custom_extractor.py +0 -0
  202. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_databricks_source.py +0 -0
  203. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_delta_lake_source.py +0 -0
  204. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_elasticsearch_source.py +0 -0
  205. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_email_source.py +0 -0
  206. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_google_cloud_storage_source.py +0 -0
  207. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_google_workspace_source.py +0 -0
  208. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_hashing.py +0 -0
  209. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_hive_source.py +0 -0
  210. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_iceberg_source.py +0 -0
  211. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_kafka_source.py +0 -0
  212. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_local_folder_source.py +0 -0
  213. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_meilisearch_source.py +0 -0
  214. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_microsoft_365_source.py +0 -0
  215. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_mongodb_source.py +0 -0
  216. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_mssql_source.py +0 -0
  217. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_mysql_source.py +0 -0
  218. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_neo4j_source.py +0 -0
  219. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_notion_source.py +0 -0
  220. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_opensearch_source.py +0 -0
  221. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_oracle_source.py +0 -0
  222. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_outputs.py +0 -0
  223. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_postgresql_source.py +0 -0
  224. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_powerbi_source.py +0 -0
  225. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_recipe_normalizer.py +0 -0
  226. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_sampling_automatic.py +0 -0
  227. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_sandbox_runner.py +0 -0
  228. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_servicedesk_source.py +0 -0
  229. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_slack_source.py +0 -0
  230. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_snowflake_source.py +0 -0
  231. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_source_dependency_groups.py +0 -0
  232. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_source_strategy_conformance.py +0 -0
  233. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_sqlite_source.py +0 -0
  234. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_tableau_source.py +0 -0
  235. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_tabular_automatic_sampling.py +0 -0
  236. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_tabular_utils.py +0 -0
  237. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_uv_sync.py +0 -0
  238. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_wordpress_source.py +0 -0
  239. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/test_youtube_source_integration.py +0 -0
  240. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/utils/test_content_extraction.py +0 -0
  241. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/utils/test_embedded_images.py +0 -0
  242. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/utils/test_file_metadata.py +0 -0
  243. {classifyre_cli-0.4.52 → classifyre_cli-0.4.53}/tests/utils/test_file_to_images.py +0 -0
@@ -1,3 +1,3 @@
1
1
  $ uv sync
2
- Resolved 293 packages in 218ms
2
+ Resolved 292 packages in 384ms
3
3
  Checked 51 packages in 1ms
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: classifyre-cli
3
- Version: 0.4.52
3
+ Version: 0.4.53
4
4
  Summary: Classifyre CLI — scan and classify unstructured data sources
5
5
  License: MIT
6
6
  Keywords: data,ingestion,metadata,pii,secrets,unstructured
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classifyre/cli",
3
- "version": "0.4.52",
3
+ "version": "0.4.53",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "build": "uv sync",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "classifyre-cli"
3
- version = "0.4.52"
3
+ version = "0.4.53"
4
4
  description = "Classifyre CLI — scan and classify unstructured data sources"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -128,6 +128,7 @@ detectors = [
128
128
  { include-group = "regex" },
129
129
  { include-group = "llm" },
130
130
  { include-group = "transcription" },
131
+ { include-group = "video" },
131
132
  ]
132
133
  file-processing = [
133
134
  "filetype>=1.2.0",
@@ -145,9 +146,16 @@ transcription = [
145
146
  # PyAV (no system ffmpeg required) and runs ctranslate2 for inference.
146
147
  "faster-whisper>=1.1.0",
147
148
  ]
149
+ video = [
150
+ # Use the server-safe OpenCV 5 wheel. RapidOCR's desktop OpenCV dependency
151
+ # is excluded below because both distributions install the same cv2 module.
152
+ "opencv-python-headless>=5.0.0.93,<6.0.0",
153
+ "rapidocr-onnxruntime>=1.4.0",
154
+ ]
148
155
  ocr = [
149
156
  { include-group = "file-processing" },
150
157
  "docling>=2.94.0",
158
+ "opencv-python-headless>=5.0.0.93,<6.0.0",
151
159
  "rapidocr-onnxruntime>=1.4.0",
152
160
  # torchvision is a transitive dep of docling-ibm-models, but [tool.uv.sources]
153
161
  # index pins only apply to dependencies declared directly in this project.
@@ -407,6 +415,10 @@ markers = [
407
415
 
408
416
  [tool.uv]
409
417
  package = true
418
+ # RapidOCR is the sole requester of opencv-python and works with the headless
419
+ # distribution declared by our video and OCR groups. Installing both would
420
+ # overwrite the same cv2 namespace and add GUI libraries to CPU-only containers.
421
+ exclude-dependencies = ["opencv-python"]
410
422
  # Every platform we ship (desktop bundles + CI) must resolve to versions with
411
423
  # usable wheels. Without this, uv happily locks versions that are missing a
412
424
  # platform — e.g. deltalake 1.6.1 published no win_amd64 wheel, which broke the
@@ -112,14 +112,6 @@ class SamplingConfig(BaseModel):
112
112
  extra='forbid',
113
113
  )
114
114
  strategy: SamplingStrategy
115
- enable_ocr: bool | None = Field(
116
- False,
117
- description='When true, enable OCR/text extraction for supported binary documents and images before routing text-capable detectors.',
118
- )
119
- enable_transcription: bool | None = Field(
120
- False,
121
- description='When true, transcribe audio and video files to text (via faster-whisper) before routing text-capable detectors. Slower and requires the transcription dependency.',
122
- )
123
115
  order_by_column: str | None = Field(
124
116
  None,
125
117
  description='Column to use for LATEST sampling mode in tabular sources (usually created_at/updated_at). Auto-detected when not set.',
@@ -407,10 +399,6 @@ class YouTubeOptionalTranscript(BaseModel):
407
399
  None,
408
400
  description='Preferred caption language codes in priority order (e.g. ["en"]). Empty means accept any available language.',
409
401
  )
410
- skip_transcript: bool | None = Field(
411
- False,
412
- description='When true, skip transcript fetching entirely (metadata-only assets, no detector content).',
413
- )
414
402
 
415
403
 
416
404
  class YouTubeOptionalConnection(BaseModel):
@@ -108,11 +108,7 @@ class DetectorPipeline:
108
108
  return asset
109
109
 
110
110
  scan_started = datetime.now(UTC)
111
- ocr_enabled = self.source.ocr_enabled()
112
- transcription_enabled = self.source.transcription_enabled()
113
- text_content_type = self._text_content_type_for_asset(
114
- asset.asset_type, ocr_enabled, transcription_enabled
115
- )
111
+ text_content_type = self._text_content_type_for_asset(asset.asset_type)
116
112
  link_content = self._build_links_payload(asset.links)
117
113
 
118
114
  text_detectors = []
@@ -765,8 +761,6 @@ class DetectorPipeline:
765
761
  def _text_content_type_for_asset(
766
762
  self,
767
763
  asset_type: OutputAssetType,
768
- ocr_enabled: bool,
769
- transcription_enabled: bool = False,
770
764
  ) -> str | None:
771
765
  mapping = {
772
766
  OutputAssetType.TXT: "text/plain",
@@ -775,9 +769,12 @@ class DetectorPipeline:
775
769
  }
776
770
  if asset_type in mapping:
777
771
  return mapping[asset_type]
778
- if ocr_enabled and asset_type in {OutputAssetType.IMAGE, OutputAssetType.BINARY}:
779
- return "text/plain"
780
- if transcription_enabled and asset_type in {OutputAssetType.AUDIO, OutputAssetType.VIDEO}:
772
+ if asset_type in {
773
+ OutputAssetType.IMAGE,
774
+ OutputAssetType.BINARY,
775
+ OutputAssetType.AUDIO,
776
+ OutputAssetType.VIDEO,
777
+ }:
781
778
  return "text/plain"
782
779
  return None
783
780
 
@@ -325,18 +325,6 @@ class BaseSource(ABC):
325
325
  return stored.strip()
326
326
  return fallback_url
327
327
 
328
- def ocr_enabled(self) -> bool:
329
- """Return whether sampling-level OCR is enabled for this source."""
330
- config = getattr(self, "config", None)
331
- sampling = getattr(config, "sampling", None) if config is not None else None
332
- return bool(getattr(sampling, "enable_ocr", False))
333
-
334
- def transcription_enabled(self) -> bool:
335
- """Return whether sampling-level audio/video transcription is enabled."""
336
- config = getattr(self, "config", None)
337
- sampling = getattr(config, "sampling", None) if config is not None else None
338
- return bool(getattr(sampling, "enable_transcription", False))
339
-
340
328
  def parse_asset_bytes(
341
329
  self,
342
330
  file_bytes: bytes,
@@ -350,8 +338,6 @@ class BaseSource(ABC):
350
338
  file_bytes,
351
339
  declared_mime_type=declared_mime_type,
352
340
  file_name=file_name,
353
- enable_ocr=self.ocr_enabled(),
354
- enable_transcription=self.transcription_enabled(),
355
341
  )
356
342
 
357
343
  def iter_asset_pages(
@@ -371,8 +357,6 @@ class BaseSource(ABC):
371
357
  batch_size,
372
358
  include_column_names,
373
359
  file_name=file_name,
374
- enable_ocr=self.ocr_enabled(),
375
- enable_transcription=self.transcription_enabled(),
376
360
  )
377
361
 
378
362
  async def fetch_content_bytes(self, asset_id: str) -> tuple[bytes, str] | None:
@@ -393,16 +393,11 @@ class ObjectStorageSourceBase(BaseSource, ABC):
393
393
  )
394
394
  normalized_mime = mime_type.split(";", 1)[0].strip().lower()
395
395
 
396
- # Non-extractable types (images, opaque binary) carry no text. Audio/video
397
- # are extractable only when transcription is enabled otherwise they are
398
- # treated as opaque binary. Everything else defers extraction to
399
- # fetch_content_pages() so detectors receive content in configurable-sized
400
- # pages instead of one monolithic blob.
401
- is_media = normalized_mime.startswith(("audio/", "video/"))
402
- is_non_extractable = (
403
- normalized_mime.startswith("image/")
404
- or (is_media and not self.transcription_enabled())
405
- or normalized_mime in ("application/octet-stream", "application/zip")
396
+ # Images and audio/video are extractable automatically. Opaque binaries
397
+ # remain available to binary detectors but have no text page stream.
398
+ is_non_extractable = normalized_mime in (
399
+ "application/octet-stream",
400
+ "application/zip",
406
401
  )
407
402
 
408
403
  return ContentSnapshot(
@@ -6,10 +6,9 @@ Pipeline:
6
6
  -> video ids
7
7
  -> youtube-transcript-api (fetch captions)
8
8
  -> transcript found -> asset + metadata, transcript is detector content
9
- -> no transcript:
10
- -> sampling.enable_transcription set -> download audio (yt-dlp) and
11
- transcribe with faster-whisper -> transcript is detector content
12
- -> otherwise -> asset + metadata only (no detector content)
9
+ -> download a bounded-resolution muxed stream
10
+ -> no captions -> transcribe audio with faster-whisper
11
+ -> sparse changed-frame OCR with OpenCV + RapidOCR
13
12
 
14
13
  Both libraries scrape public data and need no API key. ``yt-dlp`` and
15
14
  ``youtube-transcript-api`` are optional dependencies (``[youtube]`` group) and
@@ -89,6 +88,9 @@ class YouTubeSource(BaseSource):
89
88
  # Transcript text keyed by hash / external_url / video_id so the detector
90
89
  # pipeline (which probes both external_url and hash) resolves content.
91
90
  self._transcripts: dict[str, str] = {}
91
+ self._video_id_by_key: dict[str, str] = {}
92
+ self._assets_by_video_id: dict[str, SingleAssetScanResults] = {}
93
+ self._media_attempted: set[str] = set()
92
94
  self._cookiefile: str | None = None
93
95
 
94
96
  # ------------------------------------------------------------------
@@ -289,9 +291,8 @@ class YouTubeSource(BaseSource):
289
291
 
290
292
  Handles the documented failure cases (captions disabled, no captions,
291
293
  age-restricted/private, rate limiting) by logging and returning None so
292
- the asset is still emitted without detector content. When no captions
293
- exist, ``_build_video_asset`` falls back to ``_transcribe_audio`` if the
294
- source has ``sampling.enable_transcription`` set.
294
+ the asset is still emitted. When no captions exist, the downloaded video
295
+ audio is always transcribed by ``_process_video_media``.
295
296
  """
296
297
  yt_mod = require_module("youtube_transcript_api", "YouTube", uv_groups=["youtube"])
297
298
 
@@ -331,17 +332,16 @@ class YouTubeSource(BaseSource):
331
332
  logger.warning("Transcript fetch failed for video %s: %s", video_id, exc)
332
333
  return None
333
334
 
334
- def _download_audio(self, video_id: str, dest_dir: Path) -> Path | None:
335
- """Download the best audio-only stream for a video into ``dest_dir``.
336
-
337
- Returns the path to the downloaded file, or None on failure. No yt-dlp
338
- post-processing is used, so the raw audio container (.m4a/.webm/…) is
339
- written directly — faster-whisper decodes it via bundled PyAV, so no
340
- system ffmpeg is required.
341
- """
335
+ def _download_video(self, video_id: str, dest_dir: Path) -> Path | None:
336
+ """Download one muxed stream, preferring at most 480p to bound CPU/network."""
342
337
  opts = self._base_ydl_opts()
343
338
  opts["skip_download"] = False
344
- opts["format"] = "bestaudio/best"
339
+ # Requiring both codecs avoids yt-dlp's ffmpeg merge path. Visual OCR only
340
+ # needs readable text, while the audio track remains suitable for Whisper.
341
+ opts["format"] = (
342
+ "best[height<=480][acodec!=none][vcodec!=none]/"
343
+ "best[acodec!=none][vcodec!=none]"
344
+ )
345
345
  opts["noplaylist"] = True
346
346
  opts["outtmpl"] = str(dest_dir / "%(id)s.%(ext)s")
347
347
  url = _WATCH_URL.format(video_id=video_id)
@@ -349,45 +349,53 @@ class YouTubeSource(BaseSource):
349
349
  with self._ydl_class()(opts) as ydl:
350
350
  ydl.extract_info(url, download=True)
351
351
  except Exception as exc:
352
- logger.warning("Failed to download audio for video %s: %s", video_id, exc)
352
+ logger.warning("Failed to download video %s: %s", video_id, exc)
353
353
  return None
354
354
  files = [p for p in dest_dir.iterdir() if p.is_file()]
355
355
  if not files:
356
- logger.warning("Audio download produced no file for video %s", video_id)
356
+ logger.warning("Video download produced no file for video %s", video_id)
357
357
  return None
358
- # bestaudio yields a single file; pick the largest if a sidecar slipped in.
358
+ # The selected format yields one file; pick the largest if a sidecar slipped in.
359
359
  return max(files, key=lambda p: p.stat().st_size)
360
360
 
361
- def _transcribe_audio(self, video_id: str) -> _TranscriptResult | None:
362
- """Download a video's audio and transcribe it with faster-whisper.
363
-
364
- Used as a fallback when captions are unavailable. Returns None when the
365
- download or transcription fails so the asset is still emitted.
366
- """
367
- from ...utils.transcription import transcribe_media
368
-
369
- with tempfile.TemporaryDirectory(prefix="yt_audio_") as tmp:
370
- path = self._download_audio(video_id, Path(tmp))
361
+ def _process_video_media(
362
+ self,
363
+ video_id: str,
364
+ *,
365
+ transcribe: bool,
366
+ ) -> tuple[_TranscriptResult | None, str]:
367
+ """Download once, then transcribe when needed and always OCR changed frames."""
368
+ from ...utils.transcription import transcribe_media_path
369
+ from ...utils.video_processing import extract_video_ocr_path
370
+
371
+ with tempfile.TemporaryDirectory(prefix="yt_video_") as tmp:
372
+ path = self._download_video(video_id, Path(tmp))
371
373
  if path is None:
372
- return None
373
- text, error = transcribe_media(
374
- path.read_bytes(),
375
- mime_type="",
376
- file_name=path.name,
377
- )
378
- if error:
379
- logger.warning("Whisper transcription failed for video %s: %s", video_id, error)
380
- return None
381
- if not text:
382
- return None
383
- logger.info("Transcribed video %s via faster-whisper (%d chars)", video_id, len(text))
384
- return _TranscriptResult(
385
- text=text,
386
- language=None,
387
- is_generated=True,
388
- available_languages=[],
389
- source="whisper",
390
- )
374
+ return None, ""
375
+ visual_text, visual_error = extract_video_ocr_path(path)
376
+ if visual_error:
377
+ logger.warning("Visual OCR failed for YouTube video %s: %s", video_id, visual_error)
378
+
379
+ transcript: _TranscriptResult | None = None
380
+ if transcribe:
381
+ text, error = transcribe_media_path(
382
+ path,
383
+ mime_type="video/mp4",
384
+ )
385
+ if error:
386
+ logger.warning("Whisper transcription failed for video %s: %s", video_id, error)
387
+ elif text:
388
+ logger.info(
389
+ "Transcribed video %s via faster-whisper (%d chars)", video_id, len(text)
390
+ )
391
+ transcript = _TranscriptResult(
392
+ text=text,
393
+ language=None,
394
+ is_generated=True,
395
+ available_languages=[],
396
+ source="whisper",
397
+ )
398
+ return transcript, visual_text
391
399
 
392
400
  # ------------------------------------------------------------------
393
401
  # Asset construction
@@ -417,18 +425,6 @@ class YouTubeSource(BaseSource):
417
425
  else:
418
426
  created_at = datetime.now(UTC)
419
427
 
420
- transcript: _TranscriptResult | None = None
421
- if not self._transcript_options().skip_transcript:
422
- transcript = self._fetch_transcript(video_id)
423
- # No captions: fall back to downloading the audio and transcribing it
424
- # with faster-whisper when the source has transcription enabled.
425
- if transcript is None and self.transcription_enabled():
426
- transcript = self._transcribe_audio(video_id)
427
- if transcript is not None:
428
- self._transcripts[asset_hash] = transcript.text
429
- self._transcripts[external_url] = transcript.text
430
- self._transcripts[video_id] = transcript.text
431
-
432
428
  asset_metadata: dict[str, Any] = {
433
429
  "video_id": video_id,
434
430
  "title": title,
@@ -451,25 +447,17 @@ class YouTubeSource(BaseSource):
451
447
  if isinstance(upload_date, str) and upload_date:
452
448
  asset_metadata["upload_date"] = upload_date
453
449
 
454
- asset_metadata["transcript_available"] = transcript is not None
455
- if transcript is not None:
456
- asset_metadata["transcript_source"] = transcript.source
457
- if transcript.language:
458
- asset_metadata["transcript_language"] = transcript.language
459
- if transcript.is_generated is not None:
460
- asset_metadata["transcript_is_generated"] = transcript.is_generated
461
- if transcript.available_languages:
462
- asset_metadata["caption_tracks"] = transcript.available_languages
450
+ asset_metadata["transcript_available"] = False
463
451
 
464
452
  # Checksum must only reflect content changes: view/like counters move on
465
453
  # every re-scan and would flip UNCHANGED assets to UPDATED spuriously.
466
454
  checksum_data = {
467
455
  "video_id": video_id,
468
456
  "title": title,
469
- "transcript_available": asset_metadata["transcript_available"],
457
+ "detector_content": "",
470
458
  }
471
459
 
472
- return SingleAssetScanResults(
460
+ asset = SingleAssetScanResults(
473
461
  hash=asset_hash,
474
462
  checksum=self.calculate_checksum(checksum_data),
475
463
  name=title,
@@ -482,6 +470,58 @@ class YouTubeSource(BaseSource):
482
470
  runner_id=self.runner_id,
483
471
  **self.metadata_fields("video", asset_metadata),
484
472
  )
473
+ self._assets_by_video_id[video_id] = asset
474
+ for key in (asset_hash, external_url, video_id):
475
+ self._video_id_by_key[key] = video_id
476
+
477
+ # Direct source usage (tests/local integrations) preserves eager behavior.
478
+ # The CLI's two-phase runner sets discovery_only and processes lazily in
479
+ # fetch_content(), bounded by max_concurrent_assets.
480
+ if not self._discovery_only:
481
+ self._populate_video_content(video_id)
482
+ return asset
483
+
484
+ def _populate_video_content(self, video_id: str) -> None:
485
+ if video_id in self._media_attempted:
486
+ return
487
+ self._media_attempted.add(video_id)
488
+
489
+ asset = self._assets_by_video_id.get(video_id)
490
+ if asset is None:
491
+ return
492
+ transcript = self._fetch_transcript(video_id)
493
+ media_transcript, visual_text = self._process_video_media(
494
+ video_id,
495
+ transcribe=transcript is None,
496
+ )
497
+ transcript = transcript or media_transcript
498
+ content_parts: list[str] = []
499
+ if transcript is not None:
500
+ content_parts.append(f"[Transcript]\n{transcript.text}")
501
+ if visual_text:
502
+ content_parts.append(visual_text)
503
+ detector_content = "\n\n".join(content_parts)
504
+
505
+ asset.metadata["transcript_available"] = transcript is not None
506
+ if transcript is not None:
507
+ asset.metadata["transcript_source"] = transcript.source
508
+ if transcript.language:
509
+ asset.metadata["transcript_language"] = transcript.language
510
+ if transcript.is_generated is not None:
511
+ asset.metadata["transcript_is_generated"] = transcript.is_generated
512
+ if transcript.available_languages:
513
+ asset.metadata["caption_tracks"] = transcript.available_languages
514
+
515
+ asset.checksum = self.calculate_checksum(
516
+ {
517
+ "video_id": video_id,
518
+ "title": asset.name,
519
+ "detector_content": detector_content,
520
+ }
521
+ )
522
+ if detector_content:
523
+ for key in (asset.hash, asset.external_url, video_id):
524
+ self._transcripts[key] = detector_content
485
525
 
486
526
  # ------------------------------------------------------------------
487
527
  # BaseSource interface
@@ -527,6 +567,9 @@ class YouTubeSource(BaseSource):
527
567
  return
528
568
 
529
569
  self._transcripts = {}
570
+ self._video_id_by_key = {}
571
+ self._assets_by_video_id = {}
572
+ self._media_attempted = set()
530
573
  video_ids = self._resolve_target_video_ids()
531
574
  logger.info("Resolved %d YouTube video(s) to extract", len(video_ids))
532
575
 
@@ -557,18 +600,28 @@ class YouTubeSource(BaseSource):
557
600
  return hash_id(type_value, asset_id)
558
601
 
559
602
  async def fetch_content(self, asset_id: str) -> tuple[str, str] | None:
560
- """Return the cached transcript text for detector scanning.
603
+ """Lazily process and return transcript plus on-screen text."""
604
+ video_id = self._video_id_by_key.get(asset_id)
605
+ if video_id is not None:
606
+ import asyncio
561
607
 
562
- Returns ``None`` when no transcript was found, so videos without captions
563
- produce an asset + metadata but skip Phase 2 (detectors).
564
- """
608
+ await asyncio.to_thread(self._populate_video_content, video_id)
565
609
  text = self._transcripts.get(asset_id)
566
610
  if not text:
567
611
  return None
568
612
  return text, text
569
613
 
570
614
  def evict_asset_cache(self, asset_hash: str) -> None:
615
+ video_id = self._video_id_by_key.pop(asset_hash, None)
571
616
  self._transcripts.pop(asset_hash, None)
617
+ if video_id:
618
+ asset = self._assets_by_video_id.pop(video_id, None)
619
+ if asset:
620
+ self._transcripts.pop(asset.external_url, None)
621
+ self._video_id_by_key.pop(asset.external_url, None)
622
+ self._transcripts.pop(video_id, None)
623
+ self._video_id_by_key.pop(video_id, None)
624
+ self._media_attempted.discard(video_id)
572
625
 
573
626
  def enrich_finding_location(
574
627
  self,
@@ -590,3 +643,6 @@ class YouTubeSource(BaseSource):
590
643
  pass
591
644
  self._cookiefile = None
592
645
  self._transcripts.clear()
646
+ self._video_id_by_key.clear()
647
+ self._assets_by_video_id.clear()
648
+ self._media_attempted.clear()
@@ -67,11 +67,4 @@ def recipe_uv_groups(recipe: dict[str, Any]) -> set[str]:
67
67
  detector_type = str(detector.get("type", "")).upper()
68
68
  groups |= DETECTOR_TYPE_GROUPS.get(detector_type, set())
69
69
 
70
- sampling = recipe.get("sampling")
71
- if isinstance(sampling, dict):
72
- if sampling.get("enable_ocr"):
73
- groups.add("ocr")
74
- if sampling.get("enable_transcription"):
75
- groups.add("transcription")
76
-
77
70
  return {group for group in groups if group}