karaoke-gen 0.103.1__py3-none-any.whl → 0.107.0__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 (339) hide show
  1. backend/Dockerfile.base +1 -0
  2. backend/api/routes/admin.py +226 -3
  3. backend/api/routes/push.py +238 -0
  4. backend/api/routes/users.py +14 -3
  5. backend/config.py +12 -1
  6. backend/main.py +2 -1
  7. backend/models/job.py +4 -0
  8. backend/models/user.py +20 -2
  9. backend/services/encoding_interface.py +4 -0
  10. backend/services/gce_encoding/main.py +22 -8
  11. backend/services/job_manager.py +68 -11
  12. backend/services/job_notification_service.py +4 -21
  13. backend/services/push_notification_service.py +409 -0
  14. backend/services/stripe_service.py +2 -2
  15. backend/tests/conftest.py +2 -1
  16. backend/tests/test_admin_delete_outputs.py +352 -0
  17. backend/tests/test_gce_encoding_worker.py +229 -0
  18. backend/tests/test_impersonation.py +18 -3
  19. backend/tests/test_job_notification_service.py +24 -58
  20. backend/tests/test_push_notification_service.py +460 -0
  21. backend/tests/test_push_routes.py +357 -0
  22. backend/tests/test_stripe_service.py +205 -0
  23. backend/tests/test_video_worker_orchestrator.py +189 -0
  24. backend/workers/video_worker_orchestrator.py +23 -0
  25. karaoke_gen/instrumental_review/server.py +145 -35
  26. karaoke_gen/nextjs_frontend/__init__.py +98 -0
  27. karaoke_gen/nextjs_frontend/out/404/index.html +1 -0
  28. karaoke_gen/nextjs_frontend/out/404.html +1 -0
  29. karaoke_gen/nextjs_frontend/out/__next.__PAGE__.txt +9 -0
  30. karaoke_gen/nextjs_frontend/out/__next._full.txt +22 -0
  31. karaoke_gen/nextjs_frontend/out/__next._head.txt +8 -0
  32. karaoke_gen/nextjs_frontend/out/__next._index.txt +9 -0
  33. karaoke_gen/nextjs_frontend/out/__next._tree.txt +2 -0
  34. karaoke_gen/nextjs_frontend/out/_next/static/chunks/01a7f8fe40f1ff47.js +1 -0
  35. karaoke_gen/nextjs_frontend/out/_next/static/chunks/112f346e31f991df.js +4 -0
  36. karaoke_gen/nextjs_frontend/out/_next/static/chunks/16d1a4dd9d8a873a.js +3 -0
  37. karaoke_gen/nextjs_frontend/out/_next/static/chunks/1ab85c362b8b0e86.js +9 -0
  38. karaoke_gen/nextjs_frontend/out/_next/static/chunks/247eb132b7f7b574.js +1 -0
  39. karaoke_gen/nextjs_frontend/out/_next/static/chunks/2b80d15cc95e4818.js +1 -0
  40. karaoke_gen/nextjs_frontend/out/_next/static/chunks/32c7eba5cd46c1bc.js +7 -0
  41. karaoke_gen/nextjs_frontend/out/_next/static/chunks/483f26794eae53d0.js +1 -0
  42. karaoke_gen/nextjs_frontend/out/_next/static/chunks/550c3b02e85f196a.js +1 -0
  43. karaoke_gen/nextjs_frontend/out/_next/static/chunks/55c5ade44387bef8.js +1 -0
  44. karaoke_gen/nextjs_frontend/out/_next/static/chunks/5628d92b5893add2.css +1 -0
  45. karaoke_gen/nextjs_frontend/out/_next/static/chunks/56ebf7665e4341c8.js +7 -0
  46. karaoke_gen/nextjs_frontend/out/_next/static/chunks/5997132b61dec430.js +1 -0
  47. karaoke_gen/nextjs_frontend/out/_next/static/chunks/5ea55255bce3eb9e.js +5 -0
  48. karaoke_gen/nextjs_frontend/out/_next/static/chunks/5eda89a57490b3cd.js +1 -0
  49. karaoke_gen/nextjs_frontend/out/_next/static/chunks/692f5d9e0d700c76.js +3 -0
  50. karaoke_gen/nextjs_frontend/out/_next/static/chunks/71d7a05b14f9f0f4.js +1 -0
  51. karaoke_gen/nextjs_frontend/out/_next/static/chunks/81ac355749ef3302.js +1 -0
  52. karaoke_gen/nextjs_frontend/out/_next/static/chunks/95f7e5934dbb0e5d.js +1 -0
  53. karaoke_gen/nextjs_frontend/out/_next/static/chunks/9bce8f19eaa46940.js +1 -0
  54. karaoke_gen/nextjs_frontend/out/_next/static/chunks/a6dad97d9634a72d.js +1 -0
  55. karaoke_gen/nextjs_frontend/out/_next/static/chunks/a9ed54eed3e14c92.js +2 -0
  56. karaoke_gen/nextjs_frontend/out/_next/static/chunks/b35cd41238ecfb17.js +1 -0
  57. karaoke_gen/nextjs_frontend/out/_next/static/chunks/b5bc3c3d5ebd49eb.js +1 -0
  58. karaoke_gen/nextjs_frontend/out/_next/static/chunks/b5c078c08db5ae32.js +5 -0
  59. karaoke_gen/nextjs_frontend/out/_next/static/chunks/be9c44a178104187.js +1 -0
  60. karaoke_gen/nextjs_frontend/out/_next/static/chunks/c4c840e18cb4861c.js +1 -0
  61. karaoke_gen/nextjs_frontend/out/_next/static/chunks/c645af7d6b65f73e.js +1 -0
  62. karaoke_gen/nextjs_frontend/out/_next/static/chunks/d2c5e2575df784d4.js +1 -0
  63. karaoke_gen/nextjs_frontend/out/_next/static/chunks/d30af02b96d81462.js +1 -0
  64. karaoke_gen/nextjs_frontend/out/_next/static/chunks/d9bdf64f4ec1e9b7.js +7 -0
  65. karaoke_gen/nextjs_frontend/out/_next/static/chunks/dcde6ed684dacd0e.js +5 -0
  66. karaoke_gen/nextjs_frontend/out/_next/static/chunks/e422cbe931246000.js +1 -0
  67. karaoke_gen/nextjs_frontend/out/_next/static/chunks/e483af34fc792d38.js +1 -0
  68. karaoke_gen/nextjs_frontend/out/_next/static/chunks/e57422aad6b897da.js +1 -0
  69. karaoke_gen/nextjs_frontend/out/_next/static/chunks/ef02697fb404726a.js +1 -0
  70. karaoke_gen/nextjs_frontend/out/_next/static/chunks/ff1a16fafef87110.js +1 -0
  71. karaoke_gen/nextjs_frontend/out/_next/static/chunks/turbopack-2d9ca3017a9deedf.js +3 -0
  72. karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_buildManifest.js +11 -0
  73. karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_clientMiddlewareManifest.json +1 -0
  74. karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_ssgManifest.js +1 -0
  75. karaoke_gen/nextjs_frontend/out/_not-found/__next._full.txt +18 -0
  76. karaoke_gen/nextjs_frontend/out/_not-found/__next._head.txt +8 -0
  77. karaoke_gen/nextjs_frontend/out/_not-found/__next._index.txt +9 -0
  78. karaoke_gen/nextjs_frontend/out/_not-found/__next._not-found.__PAGE__.txt +5 -0
  79. karaoke_gen/nextjs_frontend/out/_not-found/__next._not-found.txt +4 -0
  80. karaoke_gen/nextjs_frontend/out/_not-found/__next._tree.txt +2 -0
  81. karaoke_gen/nextjs_frontend/out/_not-found/index.html +1 -0
  82. karaoke_gen/nextjs_frontend/out/_not-found/index.txt +18 -0
  83. karaoke_gen/nextjs_frontend/out/admin/__next._full.txt +25 -0
  84. karaoke_gen/nextjs_frontend/out/admin/__next._head.txt +8 -0
  85. karaoke_gen/nextjs_frontend/out/admin/__next._index.txt +9 -0
  86. karaoke_gen/nextjs_frontend/out/admin/__next._tree.txt +2 -0
  87. karaoke_gen/nextjs_frontend/out/admin/__next.admin.__PAGE__.txt +9 -0
  88. karaoke_gen/nextjs_frontend/out/admin/__next.admin.txt +7 -0
  89. karaoke_gen/nextjs_frontend/out/admin/beta/__next._full.txt +25 -0
  90. karaoke_gen/nextjs_frontend/out/admin/beta/__next._head.txt +8 -0
  91. karaoke_gen/nextjs_frontend/out/admin/beta/__next._index.txt +9 -0
  92. karaoke_gen/nextjs_frontend/out/admin/beta/__next._tree.txt +2 -0
  93. karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.beta.__PAGE__.txt +9 -0
  94. karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.beta.txt +4 -0
  95. karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.txt +7 -0
  96. karaoke_gen/nextjs_frontend/out/admin/beta/index.html +1 -0
  97. karaoke_gen/nextjs_frontend/out/admin/beta/index.txt +25 -0
  98. karaoke_gen/nextjs_frontend/out/admin/index.html +1 -0
  99. karaoke_gen/nextjs_frontend/out/admin/index.txt +25 -0
  100. karaoke_gen/nextjs_frontend/out/admin/jobs/__next._full.txt +25 -0
  101. karaoke_gen/nextjs_frontend/out/admin/jobs/__next._head.txt +8 -0
  102. karaoke_gen/nextjs_frontend/out/admin/jobs/__next._index.txt +9 -0
  103. karaoke_gen/nextjs_frontend/out/admin/jobs/__next._tree.txt +2 -0
  104. karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.jobs.__PAGE__.txt +9 -0
  105. karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.jobs.txt +4 -0
  106. karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.txt +7 -0
  107. karaoke_gen/nextjs_frontend/out/admin/jobs/index.html +1 -0
  108. karaoke_gen/nextjs_frontend/out/admin/jobs/index.txt +25 -0
  109. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._full.txt +25 -0
  110. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._head.txt +8 -0
  111. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._index.txt +9 -0
  112. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._tree.txt +2 -0
  113. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.rate-limits.__PAGE__.txt +9 -0
  114. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.rate-limits.txt +4 -0
  115. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.txt +7 -0
  116. karaoke_gen/nextjs_frontend/out/admin/rate-limits/index.html +1 -0
  117. karaoke_gen/nextjs_frontend/out/admin/rate-limits/index.txt +25 -0
  118. karaoke_gen/nextjs_frontend/out/admin/searches/__next._full.txt +25 -0
  119. karaoke_gen/nextjs_frontend/out/admin/searches/__next._head.txt +8 -0
  120. karaoke_gen/nextjs_frontend/out/admin/searches/__next._index.txt +9 -0
  121. karaoke_gen/nextjs_frontend/out/admin/searches/__next._tree.txt +2 -0
  122. karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.searches.__PAGE__.txt +9 -0
  123. karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.searches.txt +4 -0
  124. karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.txt +7 -0
  125. karaoke_gen/nextjs_frontend/out/admin/searches/index.html +1 -0
  126. karaoke_gen/nextjs_frontend/out/admin/searches/index.txt +25 -0
  127. karaoke_gen/nextjs_frontend/out/admin/users/__next._full.txt +25 -0
  128. karaoke_gen/nextjs_frontend/out/admin/users/__next._head.txt +8 -0
  129. karaoke_gen/nextjs_frontend/out/admin/users/__next._index.txt +9 -0
  130. karaoke_gen/nextjs_frontend/out/admin/users/__next._tree.txt +2 -0
  131. karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.txt +7 -0
  132. karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.users.__PAGE__.txt +9 -0
  133. karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.users.txt +4 -0
  134. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._full.txt +25 -0
  135. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._head.txt +8 -0
  136. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._index.txt +9 -0
  137. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._tree.txt +2 -0
  138. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.txt +7 -0
  139. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.detail.__PAGE__.txt +9 -0
  140. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.detail.txt +4 -0
  141. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.txt +4 -0
  142. karaoke_gen/nextjs_frontend/out/admin/users/detail/index.html +1 -0
  143. karaoke_gen/nextjs_frontend/out/admin/users/detail/index.txt +25 -0
  144. karaoke_gen/nextjs_frontend/out/admin/users/index.html +1 -0
  145. karaoke_gen/nextjs_frontend/out/admin/users/index.txt +25 -0
  146. karaoke_gen/nextjs_frontend/out/app/__next._full.txt +22 -0
  147. karaoke_gen/nextjs_frontend/out/app/__next._head.txt +8 -0
  148. karaoke_gen/nextjs_frontend/out/app/__next._index.txt +9 -0
  149. karaoke_gen/nextjs_frontend/out/app/__next._tree.txt +2 -0
  150. karaoke_gen/nextjs_frontend/out/app/__next.app.__PAGE__.txt +9 -0
  151. karaoke_gen/nextjs_frontend/out/app/__next.app.txt +4 -0
  152. karaoke_gen/nextjs_frontend/out/app/index.html +1 -0
  153. karaoke_gen/nextjs_frontend/out/app/index.txt +22 -0
  154. karaoke_gen/nextjs_frontend/out/app/jobs/__next._full.txt +19 -0
  155. karaoke_gen/nextjs_frontend/out/app/jobs/__next._head.txt +8 -0
  156. karaoke_gen/nextjs_frontend/out/app/jobs/__next._index.txt +9 -0
  157. karaoke_gen/nextjs_frontend/out/app/jobs/__next._tree.txt +2 -0
  158. karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
  159. karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.$oc$slug.txt +4 -0
  160. karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.txt +4 -0
  161. karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.txt +4 -0
  162. karaoke_gen/nextjs_frontend/out/app/jobs/index.html +1 -0
  163. karaoke_gen/nextjs_frontend/out/app/jobs/index.txt +19 -0
  164. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._full.txt +19 -0
  165. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._head.txt +8 -0
  166. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._index.txt +9 -0
  167. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._tree.txt +2 -0
  168. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
  169. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.$oc$slug.txt +4 -0
  170. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.txt +4 -0
  171. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.txt +4 -0
  172. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/index.html +1 -0
  173. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/index.txt +19 -0
  174. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._full.txt +19 -0
  175. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._head.txt +8 -0
  176. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._index.txt +9 -0
  177. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._tree.txt +2 -0
  178. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
  179. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.$oc$slug.txt +4 -0
  180. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.txt +4 -0
  181. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.txt +4 -0
  182. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/index.html +1 -0
  183. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/index.txt +19 -0
  184. karaoke_gen/nextjs_frontend/out/auth/verify/__next._full.txt +22 -0
  185. karaoke_gen/nextjs_frontend/out/auth/verify/__next._head.txt +8 -0
  186. karaoke_gen/nextjs_frontend/out/auth/verify/__next._index.txt +9 -0
  187. karaoke_gen/nextjs_frontend/out/auth/verify/__next._tree.txt +2 -0
  188. karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.txt +4 -0
  189. karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.verify.__PAGE__.txt +9 -0
  190. karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.verify.txt +4 -0
  191. karaoke_gen/nextjs_frontend/out/auth/verify/index.html +1 -0
  192. karaoke_gen/nextjs_frontend/out/auth/verify/index.txt +22 -0
  193. karaoke_gen/nextjs_frontend/out/index.html +1 -0
  194. karaoke_gen/nextjs_frontend/out/index.txt +22 -0
  195. karaoke_gen/nextjs_frontend/out/manifest.webmanifest +31 -0
  196. karaoke_gen/nextjs_frontend/out/order/success/__next._full.txt +22 -0
  197. karaoke_gen/nextjs_frontend/out/order/success/__next._head.txt +8 -0
  198. karaoke_gen/nextjs_frontend/out/order/success/__next._index.txt +9 -0
  199. karaoke_gen/nextjs_frontend/out/order/success/__next._tree.txt +2 -0
  200. karaoke_gen/nextjs_frontend/out/order/success/__next.order.success.__PAGE__.txt +9 -0
  201. karaoke_gen/nextjs_frontend/out/order/success/__next.order.success.txt +4 -0
  202. karaoke_gen/nextjs_frontend/out/order/success/__next.order.txt +4 -0
  203. karaoke_gen/nextjs_frontend/out/order/success/index.html +1 -0
  204. karaoke_gen/nextjs_frontend/out/order/success/index.txt +22 -0
  205. karaoke_gen/nextjs_frontend/out/payment/success/__next._full.txt +22 -0
  206. karaoke_gen/nextjs_frontend/out/payment/success/__next._head.txt +8 -0
  207. karaoke_gen/nextjs_frontend/out/payment/success/__next._index.txt +9 -0
  208. karaoke_gen/nextjs_frontend/out/payment/success/__next._tree.txt +2 -0
  209. karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.success.__PAGE__.txt +9 -0
  210. karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.success.txt +4 -0
  211. karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.txt +4 -0
  212. karaoke_gen/nextjs_frontend/out/payment/success/index.html +1 -0
  213. karaoke_gen/nextjs_frontend/out/payment/success/index.txt +22 -0
  214. karaoke_gen/nextjs_frontend/out/screenshots/email-action_reminder.png +0 -0
  215. karaoke_gen/nextjs_frontend/out/screenshots/email-beta_welcome.png +0 -0
  216. karaoke_gen/nextjs_frontend/out/screenshots/email-job_completion.png +0 -0
  217. karaoke_gen/nextjs_frontend/out/screenshots/example-output.avif +0 -0
  218. karaoke_gen/nextjs_frontend/out/screenshots/homepage-full.png +0 -0
  219. karaoke_gen/nextjs_frontend/out/screenshots/homepage-hero.png +0 -0
  220. karaoke_gen/nextjs_frontend/out/screenshots/instrumental-review.avif +0 -0
  221. karaoke_gen/nextjs_frontend/out/screenshots/instrumental-review.png +0 -0
  222. karaoke_gen/nextjs_frontend/out/screenshots/job-dashboard.avif +0 -0
  223. karaoke_gen/nextjs_frontend/out/screenshots/lyrics-review.avif +0 -0
  224. karaoke_gen/nextjs_frontend/out/screenshots/lyrics-review.png +0 -0
  225. karaoke_gen/nextjs_frontend/out/sw.js +183 -0
  226. karaoke_gen/utils/cli_args.py +3 -3
  227. karaoke_gen/utils/gen_cli.py +4 -0
  228. karaoke_gen/utils/remote_cli.py +8 -40
  229. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/METADATA +2 -1
  230. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/RECORD +244 -131
  231. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/WHEEL +1 -1
  232. lyrics_transcriber/correction/agentic/agent.py +83 -60
  233. lyrics_transcriber/correction/anchor_sequence.py +48 -3
  234. lyrics_transcriber/correction/corrector.py +92 -58
  235. lyrics_transcriber/review/server.py +165 -33
  236. lyrics_transcriber/utils/tracing.py +214 -0
  237. karaoke_gen/instrumental_review/static/index.html +0 -1695
  238. lyrics_transcriber/frontend/.gitignore +0 -24
  239. lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +0 -935
  240. lyrics_transcriber/frontend/.yarnrc.yml +0 -3
  241. lyrics_transcriber/frontend/README.md +0 -50
  242. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +0 -210
  243. lyrics_transcriber/frontend/__init__.py +0 -25
  244. lyrics_transcriber/frontend/e2e/agentic-corrections.spec.ts +0 -207
  245. lyrics_transcriber/frontend/e2e/fixtures/agentic-correction-data.json +0 -226
  246. lyrics_transcriber/frontend/eslint.config.js +0 -28
  247. lyrics_transcriber/frontend/index.html +0 -22
  248. lyrics_transcriber/frontend/package-lock.json +0 -4553
  249. lyrics_transcriber/frontend/package.json +0 -48
  250. lyrics_transcriber/frontend/playwright.config.ts +0 -69
  251. lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
  252. lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
  253. lyrics_transcriber/frontend/src/App.tsx +0 -243
  254. lyrics_transcriber/frontend/src/api.ts +0 -262
  255. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +0 -111
  256. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +0 -114
  257. lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +0 -204
  258. lyrics_transcriber/frontend/src/components/AppHeader.tsx +0 -65
  259. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +0 -180
  260. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +0 -175
  261. lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +0 -359
  262. lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +0 -281
  263. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +0 -162
  264. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +0 -257
  265. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +0 -94
  266. lyrics_transcriber/frontend/src/components/EditModal.tsx +0 -720
  267. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +0 -592
  268. lyrics_transcriber/frontend/src/components/EditWordList.tsx +0 -431
  269. lyrics_transcriber/frontend/src/components/FileUpload.tsx +0 -77
  270. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +0 -467
  271. lyrics_transcriber/frontend/src/components/Header.tsx +0 -520
  272. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +0 -1526
  273. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +0 -216
  274. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +0 -721
  275. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +0 -80
  276. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +0 -999
  277. lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +0 -51
  278. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +0 -127
  279. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +0 -67
  280. lyrics_transcriber/frontend/src/components/ModelSelector.tsx +0 -23
  281. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +0 -177
  282. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +0 -268
  283. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +0 -336
  284. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +0 -354
  285. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +0 -64
  286. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +0 -383
  287. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +0 -131
  288. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +0 -266
  289. lyrics_transcriber/frontend/src/components/WordDivider.tsx +0 -191
  290. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +0 -466
  291. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +0 -56
  292. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +0 -89
  293. lyrics_transcriber/frontend/src/components/shared/constants.ts +0 -30
  294. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +0 -180
  295. lyrics_transcriber/frontend/src/components/shared/styles.ts +0 -13
  296. lyrics_transcriber/frontend/src/components/shared/types.js +0 -2
  297. lyrics_transcriber/frontend/src/components/shared/types.ts +0 -135
  298. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +0 -177
  299. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +0 -78
  300. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +0 -75
  301. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +0 -360
  302. lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +0 -110
  303. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +0 -22
  304. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +0 -537
  305. lyrics_transcriber/frontend/src/main.tsx +0 -11
  306. lyrics_transcriber/frontend/src/theme.ts +0 -406
  307. lyrics_transcriber/frontend/src/types/global.d.ts +0 -9
  308. lyrics_transcriber/frontend/src/types.js +0 -2
  309. lyrics_transcriber/frontend/src/types.ts +0 -199
  310. lyrics_transcriber/frontend/src/validation.ts +0 -132
  311. lyrics_transcriber/frontend/src/vite-env.d.ts +0 -1
  312. lyrics_transcriber/frontend/tsconfig.app.json +0 -26
  313. lyrics_transcriber/frontend/tsconfig.json +0 -25
  314. lyrics_transcriber/frontend/tsconfig.node.json +0 -23
  315. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +0 -1
  316. lyrics_transcriber/frontend/update_version.js +0 -11
  317. lyrics_transcriber/frontend/vite.config.d.ts +0 -2
  318. lyrics_transcriber/frontend/vite.config.js +0 -15
  319. lyrics_transcriber/frontend/vite.config.ts +0 -16
  320. lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
  321. lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
  322. lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
  323. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js +0 -44465
  324. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +0 -1
  325. lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
  326. lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
  327. lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
  328. lyrics_transcriber/frontend/web_assets/index.html +0 -22
  329. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
  330. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +0 -5
  331. lyrics_transcriber/frontend/yarn.lock +0 -3711
  332. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/apple-touch-icon.png +0 -0
  333. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon-16x16.png +0 -0
  334. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon-32x32.png +0 -0
  335. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon.ico +0 -0
  336. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/nomad-karaoke-logo.svg +0 -0
  337. /lyrics_transcriber/frontend/public/nomad-karaoke-logo.png → /karaoke_gen/nextjs_frontend/out/nomad-logo.png +0 -0
  338. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/entry_points.txt +0 -0
  339. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/licenses/LICENSE +0 -0
backend/Dockerfile.base CHANGED
@@ -54,6 +54,7 @@ WORKDIR /app
54
54
  COPY pyproject.toml README.md LICENSE /app/
55
55
  COPY karaoke_gen /app/karaoke_gen
56
56
  COPY lyrics_transcriber_temp /app/lyrics_transcriber_temp
57
+ COPY backend /app/backend
57
58
 
58
59
  # Install all Python dependencies (the slow part - cached in base image)
59
60
  RUN pip install --no-cache-dir --upgrade pip && \
@@ -8,7 +8,7 @@ Handles:
8
8
  - Audio search cache management
9
9
  """
10
10
  import logging
11
- from datetime import datetime, timedelta
11
+ from datetime import datetime, timedelta, timezone
12
12
  from typing import Tuple, List, Optional, Any, Dict
13
13
 
14
14
  from fastapi import APIRouter, Depends, HTTPException
@@ -1139,6 +1139,229 @@ async def reset_job(
1139
1139
  )
1140
1140
 
1141
1141
 
1142
+ # =============================================================================
1143
+ # Delete Job Outputs Endpoint
1144
+ # =============================================================================
1145
+
1146
+ class DeleteOutputsResponse(BaseModel):
1147
+ """Response from delete job outputs endpoint."""
1148
+ status: str
1149
+ job_id: str
1150
+ message: str
1151
+ deleted_services: Dict[str, Any] # youtube, dropbox, gdrive results
1152
+ cleared_state_data: List[str]
1153
+ outputs_deleted_at: str
1154
+
1155
+
1156
+ # State data keys to clear when deleting outputs
1157
+ OUTPUT_STATE_DATA_KEYS = [
1158
+ "youtube_url",
1159
+ "youtube_video_id",
1160
+ "dropbox_link",
1161
+ "brand_code",
1162
+ "gdrive_files",
1163
+ ]
1164
+
1165
+
1166
+ # Terminal states that allow output deletion
1167
+ TERMINAL_STATES = {"complete", "prep_complete", "failed", "cancelled"}
1168
+
1169
+
1170
+ @router.post("/jobs/{job_id}/delete-outputs", response_model=DeleteOutputsResponse)
1171
+ async def delete_job_outputs(
1172
+ job_id: str,
1173
+ auth_data: AuthResult = Depends(require_admin),
1174
+ ):
1175
+ """
1176
+ Delete all distributed outputs for a job (admin only).
1177
+
1178
+ This endpoint deletes:
1179
+ 1. YouTube video (if uploaded)
1180
+ 2. Dropbox folder (if uploaded) - frees brand code for reuse
1181
+ 3. Google Drive files (if uploaded)
1182
+
1183
+ The job record is preserved with outputs_deleted_at timestamp set.
1184
+ State data related to distribution is cleared.
1185
+
1186
+ Use case: Delete outputs for quality issues, then reset job to
1187
+ awaiting_review or awaiting_instrumental_selection to re-process.
1188
+
1189
+ Args:
1190
+ job_id: Job ID to delete outputs for
1191
+
1192
+ Returns:
1193
+ Deletion results for each service
1194
+ """
1195
+ import re
1196
+ from google.cloud.firestore_v1 import DELETE_FIELD, ArrayUnion
1197
+
1198
+ admin_email = auth_data.user_email or "unknown"
1199
+ job_manager = JobManager()
1200
+ job = job_manager.get_job(job_id)
1201
+
1202
+ if not job:
1203
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
1204
+
1205
+ # Verify job is in a terminal state
1206
+ if job.status not in TERMINAL_STATES:
1207
+ raise HTTPException(
1208
+ status_code=400,
1209
+ detail=f"Can only delete outputs from jobs in terminal states. "
1210
+ f"Current status: {job.status}. Allowed: {', '.join(sorted(TERMINAL_STATES))}"
1211
+ )
1212
+
1213
+ # Check if outputs already deleted
1214
+ if job.outputs_deleted_at:
1215
+ raise HTTPException(
1216
+ status_code=400,
1217
+ detail=f"Outputs were already deleted at {job.outputs_deleted_at}"
1218
+ )
1219
+
1220
+ state_data = job.state_data or {}
1221
+ results = {
1222
+ "youtube": {"status": "skipped", "reason": "no youtube_url in state_data"},
1223
+ "dropbox": {"status": "skipped", "reason": "no brand_code or dropbox_path"},
1224
+ "gdrive": {"status": "skipped", "reason": "no gdrive_files in state_data"},
1225
+ }
1226
+
1227
+ # Clean up YouTube
1228
+ youtube_url = state_data.get('youtube_url')
1229
+ if youtube_url:
1230
+ try:
1231
+ video_id_match = re.search(r'(?:youtu\.be/|youtube\.com/watch\?v=)([^&\s]+)', youtube_url)
1232
+ if video_id_match:
1233
+ video_id = video_id_match.group(1)
1234
+
1235
+ from karaoke_gen.karaoke_finalise.karaoke_finalise import KaraokeFinalise
1236
+ from backend.services.youtube_service import get_youtube_service
1237
+
1238
+ youtube_service = get_youtube_service()
1239
+ if youtube_service.is_configured:
1240
+ finalise = KaraokeFinalise(
1241
+ dry_run=False,
1242
+ non_interactive=True,
1243
+ user_youtube_credentials=youtube_service.get_credentials_dict()
1244
+ )
1245
+ success = finalise.delete_youtube_video(video_id)
1246
+ results["youtube"] = {
1247
+ "status": "success" if success else "failed",
1248
+ "video_id": video_id
1249
+ }
1250
+ else:
1251
+ results["youtube"] = {"status": "skipped", "reason": "YouTube credentials not configured"}
1252
+ else:
1253
+ results["youtube"] = {"status": "failed", "reason": f"Could not extract video ID from {youtube_url}"}
1254
+ except Exception as e:
1255
+ logger.error(f"Error deleting YouTube video for job {job_id}: {e}", exc_info=True)
1256
+ results["youtube"] = {"status": "error", "error": str(e)}
1257
+
1258
+ # Clean up Dropbox
1259
+ brand_code = state_data.get('brand_code')
1260
+ dropbox_path = getattr(job, 'dropbox_path', None)
1261
+ if brand_code and dropbox_path:
1262
+ try:
1263
+ from backend.services.dropbox_service import get_dropbox_service
1264
+ dropbox = get_dropbox_service()
1265
+ if dropbox.is_configured:
1266
+ base_name = f"{job.artist} - {job.title}"
1267
+ folder_name = f"{brand_code} - {base_name}"
1268
+ full_path = f"{dropbox_path}/{folder_name}"
1269
+ success = dropbox.delete_folder(full_path)
1270
+ results["dropbox"] = {
1271
+ "status": "success" if success else "failed",
1272
+ "path": full_path
1273
+ }
1274
+ else:
1275
+ results["dropbox"] = {"status": "skipped", "reason": "Dropbox credentials not configured"}
1276
+ except Exception as e:
1277
+ logger.error(f"Error deleting Dropbox folder for job {job_id}: {e}", exc_info=True)
1278
+ results["dropbox"] = {"status": "error", "error": str(e)}
1279
+
1280
+ # Clean up Google Drive
1281
+ gdrive_files = state_data.get('gdrive_files')
1282
+ if gdrive_files:
1283
+ try:
1284
+ from backend.services.gdrive_service import get_gdrive_service
1285
+ gdrive = get_gdrive_service()
1286
+ if gdrive.is_configured:
1287
+ file_ids = list(gdrive_files.values()) if isinstance(gdrive_files, dict) else []
1288
+ delete_results = gdrive.delete_files(file_ids)
1289
+ all_success = all(delete_results.values())
1290
+ results["gdrive"] = {
1291
+ "status": "success" if all_success else "partial",
1292
+ "files": delete_results
1293
+ }
1294
+ else:
1295
+ results["gdrive"] = {"status": "skipped", "reason": "Google Drive credentials not configured"}
1296
+ except Exception as e:
1297
+ logger.error(f"Error deleting Google Drive files for job {job_id}: {e}", exc_info=True)
1298
+ results["gdrive"] = {"status": "error", "error": str(e)}
1299
+
1300
+ # Update job record
1301
+ deletion_timestamp = datetime.now(timezone.utc)
1302
+ user_service = get_user_service()
1303
+ db = user_service.db
1304
+ job_ref = db.collection("jobs").document(job_id)
1305
+
1306
+ update_payload = {
1307
+ "outputs_deleted_at": deletion_timestamp,
1308
+ "outputs_deleted_by": admin_email,
1309
+ "updated_at": deletion_timestamp,
1310
+ }
1311
+
1312
+ # Clear distribution-related state_data keys
1313
+ cleared_keys = []
1314
+ for key in OUTPUT_STATE_DATA_KEYS:
1315
+ if key in state_data:
1316
+ update_payload[f"state_data.{key}"] = DELETE_FIELD
1317
+ cleared_keys.append(key)
1318
+
1319
+ # Add timeline event
1320
+ timeline_event = {
1321
+ "status": job.status, # Keep current status
1322
+ "timestamp": deletion_timestamp.isoformat(),
1323
+ "message": f"Outputs deleted by admin ({admin_email})",
1324
+ }
1325
+ update_payload["timeline"] = ArrayUnion([timeline_event])
1326
+
1327
+ job_ref.update(update_payload)
1328
+
1329
+ # Determine overall status based on per-service results
1330
+ error_services = [s for s, r in results.items() if r["status"] == "error"]
1331
+ failed_services = [s for s, r in results.items() if r["status"] == "failed"]
1332
+ success_services = [s for s, r in results.items() if r["status"] == "success"]
1333
+
1334
+ if error_services:
1335
+ overall_status = "partial_success" if success_services else "error"
1336
+ error_details = "; ".join(
1337
+ f"{s}: {results[s].get('error', 'unknown error')}" for s in error_services
1338
+ )
1339
+ message = f"Some services failed: {error_details}"
1340
+ elif failed_services:
1341
+ overall_status = "partial_success" if success_services else "failed"
1342
+ message = f"Some deletions failed: {', '.join(failed_services)}"
1343
+ else:
1344
+ overall_status = "success"
1345
+ message = "Outputs deleted successfully"
1346
+
1347
+ logger.info(
1348
+ f"Admin {admin_email} deleted outputs for job {job_id}. "
1349
+ f"YouTube: {results['youtube']['status']}, "
1350
+ f"Dropbox: {results['dropbox']['status']}, "
1351
+ f"GDrive: {results['gdrive']['status']}. "
1352
+ f"Cleared state_data keys: {cleared_keys}"
1353
+ )
1354
+
1355
+ return DeleteOutputsResponse(
1356
+ status=overall_status,
1357
+ job_id=job_id,
1358
+ message=message,
1359
+ deleted_services=results,
1360
+ cleared_state_data=cleared_keys,
1361
+ outputs_deleted_at=deletion_timestamp.isoformat(),
1362
+ )
1363
+
1364
+
1142
1365
  @router.get("/jobs/{job_id}/completion-message", response_model=CompletionMessageResponse)
1143
1366
  async def get_job_completion_message(
1144
1367
  job_id: str,
@@ -1286,7 +1509,7 @@ class ImpersonateUserResponse(BaseModel):
1286
1509
  @router.post("/users/{email}/impersonate", response_model=ImpersonateUserResponse)
1287
1510
  async def impersonate_user(
1288
1511
  email: str,
1289
- auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1512
+ auth_data: AuthResult = Depends(require_admin),
1290
1513
  user_service: UserService = Depends(get_user_service),
1291
1514
  ):
1292
1515
  """
@@ -1308,7 +1531,7 @@ async def impersonate_user(
1308
1531
  user_email: The impersonated user's email
1309
1532
  message: Success message
1310
1533
  """
1311
- admin_email = auth_data[0]
1534
+ admin_email = auth_data.user_email or "unknown"
1312
1535
  target_email = email.lower()
1313
1536
 
1314
1537
  # Cannot impersonate yourself
@@ -0,0 +1,238 @@
1
+ """
2
+ Push Notification API routes.
3
+
4
+ Provides endpoints for managing Web Push notification subscriptions:
5
+ - GET /api/push/vapid-public-key: Get VAPID public key for client-side subscription
6
+ - POST /api/push/subscribe: Register a push subscription
7
+ - POST /api/push/unsubscribe: Remove a push subscription
8
+ - GET /api/push/subscriptions: List user's subscriptions
9
+ - POST /api/push/test: Send a test notification (admin only)
10
+ """
11
+ import logging
12
+ from typing import Optional, Dict, List
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException
15
+ from pydantic import BaseModel
16
+
17
+ from backend.config import get_settings
18
+ from backend.api.dependencies import require_auth, require_admin
19
+ from backend.services.auth_service import AuthResult
20
+ from backend.services.push_notification_service import get_push_notification_service
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+ router = APIRouter(prefix="/push", tags=["push"])
25
+
26
+
27
+ # Request/Response Models
28
+
29
+ class VapidPublicKeyResponse(BaseModel):
30
+ """Response containing VAPID public key."""
31
+ enabled: bool
32
+ vapid_public_key: Optional[str] = None
33
+
34
+
35
+ class SubscribeRequest(BaseModel):
36
+ """Request to subscribe to push notifications."""
37
+ endpoint: str
38
+ keys: Dict[str, str] # p256dh and auth
39
+ device_name: Optional[str] = None
40
+
41
+
42
+ class SubscribeResponse(BaseModel):
43
+ """Response after subscribing."""
44
+ status: str
45
+ message: str
46
+
47
+
48
+ class UnsubscribeRequest(BaseModel):
49
+ """Request to unsubscribe from push notifications."""
50
+ endpoint: str
51
+
52
+
53
+ class UnsubscribeResponse(BaseModel):
54
+ """Response after unsubscribing."""
55
+ status: str
56
+ message: str
57
+
58
+
59
+ class SubscriptionInfo(BaseModel):
60
+ """Information about a push subscription."""
61
+ endpoint: str
62
+ device_name: Optional[str] = None
63
+ created_at: Optional[str] = None
64
+ last_used_at: Optional[str] = None
65
+
66
+
67
+ class SubscriptionsListResponse(BaseModel):
68
+ """Response containing user's subscriptions."""
69
+ subscriptions: List[SubscriptionInfo]
70
+ count: int
71
+
72
+
73
+ class TestNotificationRequest(BaseModel):
74
+ """Request to send a test notification."""
75
+ title: Optional[str] = "Test Notification"
76
+ body: Optional[str] = "This is a test push notification from Karaoke Generator"
77
+
78
+
79
+ class TestNotificationResponse(BaseModel):
80
+ """Response after sending test notification."""
81
+ status: str
82
+ sent_count: int
83
+ message: str
84
+
85
+
86
+ # Routes
87
+
88
+ @router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
89
+ async def get_vapid_public_key():
90
+ """
91
+ Get the VAPID public key for push subscription.
92
+
93
+ This endpoint is public - no authentication required.
94
+ Returns the public key needed for client-side PushManager.subscribe().
95
+ """
96
+ settings = get_settings()
97
+ push_service = get_push_notification_service()
98
+
99
+ if not settings.enable_push_notifications:
100
+ return VapidPublicKeyResponse(enabled=False)
101
+
102
+ public_key = push_service.get_public_key()
103
+ if not public_key:
104
+ return VapidPublicKeyResponse(enabled=False)
105
+
106
+ return VapidPublicKeyResponse(
107
+ enabled=True,
108
+ vapid_public_key=public_key
109
+ )
110
+
111
+
112
+ @router.post("/subscribe", response_model=SubscribeResponse)
113
+ async def subscribe_push(
114
+ request: SubscribeRequest,
115
+ auth_result: AuthResult = Depends(require_auth)
116
+ ):
117
+ """
118
+ Register a push notification subscription for the current user.
119
+
120
+ Requires authentication. Users can have up to 5 subscriptions
121
+ (configurable via MAX_PUSH_SUBSCRIPTIONS_PER_USER).
122
+ """
123
+ settings = get_settings()
124
+ if not settings.enable_push_notifications:
125
+ raise HTTPException(status_code=503, detail="Push notifications are not enabled")
126
+
127
+ if not auth_result.user_email:
128
+ raise HTTPException(status_code=401, detail="User email not available")
129
+
130
+ push_service = get_push_notification_service()
131
+
132
+ # Validate keys
133
+ if "p256dh" not in request.keys or "auth" not in request.keys:
134
+ raise HTTPException(status_code=400, detail="Missing required keys (p256dh, auth)")
135
+
136
+ success = await push_service.add_subscription(
137
+ user_email=auth_result.user_email,
138
+ endpoint=request.endpoint,
139
+ keys=request.keys,
140
+ device_name=request.device_name
141
+ )
142
+
143
+ if not success:
144
+ raise HTTPException(status_code=500, detail="Failed to save subscription")
145
+
146
+ return SubscribeResponse(
147
+ status="success",
148
+ message="Push subscription registered successfully"
149
+ )
150
+
151
+
152
+ @router.post("/unsubscribe", response_model=UnsubscribeResponse)
153
+ async def unsubscribe_push(
154
+ request: UnsubscribeRequest,
155
+ auth_result: AuthResult = Depends(require_auth)
156
+ ):
157
+ """
158
+ Remove a push notification subscription.
159
+
160
+ Requires authentication. Users can only remove their own subscriptions.
161
+ """
162
+ if not auth_result.user_email:
163
+ raise HTTPException(status_code=401, detail="User email not available")
164
+
165
+ push_service = get_push_notification_service()
166
+
167
+ success = await push_service.remove_subscription(
168
+ user_email=auth_result.user_email,
169
+ endpoint=request.endpoint
170
+ )
171
+
172
+ if not success:
173
+ # Don't error if subscription wasn't found - might already be removed
174
+ return UnsubscribeResponse(
175
+ status="success",
176
+ message="Subscription removed (or was not found)"
177
+ )
178
+
179
+ return UnsubscribeResponse(
180
+ status="success",
181
+ message="Push subscription removed successfully"
182
+ )
183
+
184
+
185
+ @router.get("/subscriptions", response_model=SubscriptionsListResponse)
186
+ async def list_subscriptions(
187
+ auth_result: AuthResult = Depends(require_auth)
188
+ ):
189
+ """
190
+ List all push notification subscriptions for the current user.
191
+
192
+ Requires authentication.
193
+ """
194
+ if not auth_result.user_email:
195
+ raise HTTPException(status_code=401, detail="User email not available")
196
+
197
+ push_service = get_push_notification_service()
198
+
199
+ subscriptions = await push_service.list_subscriptions(auth_result.user_email)
200
+
201
+ return SubscriptionsListResponse(
202
+ subscriptions=[SubscriptionInfo(**s) for s in subscriptions],
203
+ count=len(subscriptions)
204
+ )
205
+
206
+
207
+ @router.post("/test", response_model=TestNotificationResponse)
208
+ async def send_test_notification(
209
+ request: TestNotificationRequest,
210
+ auth_result: AuthResult = Depends(require_admin)
211
+ ):
212
+ """
213
+ Send a test push notification to the current user's devices.
214
+
215
+ Admin only. Useful for testing push notification setup.
216
+ """
217
+ settings = get_settings()
218
+ if not settings.enable_push_notifications:
219
+ raise HTTPException(status_code=503, detail="Push notifications are not enabled")
220
+
221
+ if not auth_result.user_email:
222
+ raise HTTPException(status_code=401, detail="User email not available")
223
+
224
+ push_service = get_push_notification_service()
225
+
226
+ sent_count = await push_service.send_push(
227
+ user_email=auth_result.user_email,
228
+ title=request.title or "Test Notification",
229
+ body=request.body or "This is a test push notification",
230
+ url="/app/",
231
+ tag="test"
232
+ )
233
+
234
+ return TestNotificationResponse(
235
+ status="success",
236
+ sent_count=sent_count,
237
+ message=f"Test notification sent to {sent_count} device(s)"
238
+ )
@@ -858,8 +858,19 @@ async def enroll_beta_tester(
858
858
  detail="Access denied from this location"
859
859
  )
860
860
 
861
- # 3. Check IP-based enrollment rate limit (1 per 24h per IP)
862
- if ip_address:
861
+ # 3. Check for E2E test bypass (allows automated testing to skip IP rate limit)
862
+ from backend.config import settings
863
+ e2e_bypass_key = http_request.headers.get("X-E2E-Bypass-Key")
864
+ skip_ip_rate_limit = False
865
+ if e2e_bypass_key and settings.e2e_bypass_key:
866
+ if e2e_bypass_key == settings.e2e_bypass_key:
867
+ logger.info(f"Beta enrollment: E2E bypass key validated for {_mask_email(email)}")
868
+ skip_ip_rate_limit = True
869
+ else:
870
+ logger.warning(f"Beta enrollment: Invalid E2E bypass key attempted for {_mask_email(email)}")
871
+
872
+ # 4. Check IP-based enrollment rate limit (1 per 24h per IP)
873
+ if ip_address and not skip_ip_rate_limit:
863
874
  allowed, remaining, message = rate_limit_service.check_beta_ip_limit(ip_address)
864
875
  if not allowed:
865
876
  logger.warning(f"Beta enrollment rejected - IP rate limit: {ip_address} - {message}")
@@ -868,7 +879,7 @@ async def enroll_beta_tester(
868
879
  detail="Too many beta enrollments from your location. Please try again tomorrow."
869
880
  )
870
881
 
871
- # 4. Check for duplicate enrollment via normalized email
882
+ # 5. Check for duplicate enrollment via normalized email
872
883
  normalized_email = email_validation.normalize_email(email)
873
884
  if normalized_email != email:
874
885
  # Check if normalized version is already enrolled
backend/config.py CHANGED
@@ -111,6 +111,9 @@ class Settings(BaseSettings):
111
111
  rate_limit_youtube_uploads_per_day: int = int(os.getenv("RATE_LIMIT_YOUTUBE_UPLOADS_PER_DAY", "10"))
112
112
  # Maximum beta enrollments from same IP per day (0 = unlimited)
113
113
  rate_limit_beta_ip_per_day: int = int(os.getenv("RATE_LIMIT_BETA_IP_PER_DAY", "1"))
114
+
115
+ # E2E test bypass key for rate limiting (set via secret in production)
116
+ e2e_bypass_key: str = os.getenv("E2E_BYPASS_KEY", "")
114
117
  default_youtube_description: str = os.getenv(
115
118
  "DEFAULT_YOUTUBE_DESCRIPTION",
116
119
  "Karaoke video created with Nomad Karaoke (https://nomadkaraoke.com)\n\n"
@@ -123,7 +126,15 @@ class Settings(BaseSettings):
123
126
  # These can be overridden per-request via explicit enable_cdg/enable_txt parameters
124
127
  default_enable_cdg: bool = os.getenv("DEFAULT_ENABLE_CDG", "true").lower() in ("true", "1", "yes")
125
128
  default_enable_txt: bool = os.getenv("DEFAULT_ENABLE_TXT", "true").lower() in ("true", "1", "yes")
126
-
129
+
130
+ # Push Notifications Configuration
131
+ # When enabled, users can subscribe to push notifications for job status updates
132
+ enable_push_notifications: bool = os.getenv("ENABLE_PUSH_NOTIFICATIONS", "false").lower() in ("true", "1", "yes")
133
+ # Maximum number of push subscriptions per user (oldest removed when exceeded)
134
+ max_push_subscriptions_per_user: int = int(os.getenv("MAX_PUSH_SUBSCRIPTIONS_PER_USER", "5"))
135
+ # VAPID subject (email or URL for push service to contact)
136
+ vapid_subject: str = os.getenv("VAPID_SUBJECT", "mailto:gen@nomadkaraoke.com")
137
+
127
138
  # Secret Manager cache
128
139
  _secret_cache: Dict[str, str] = {}
129
140
 
backend/main.py CHANGED
@@ -7,7 +7,7 @@ from fastapi import FastAPI
7
7
  from fastapi.middleware.cors import CORSMiddleware
8
8
 
9
9
  from backend.config import settings
10
- from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant, rate_limits
10
+ from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant, rate_limits, push
11
11
  from backend.services.tracing import setup_tracing, instrument_app, get_current_trace_id
12
12
  from backend.services.structured_logging import setup_structured_logging
13
13
  from backend.services.spacy_preloader import preload_spacy_model
@@ -144,6 +144,7 @@ app.include_router(themes.router, prefix="/api") # Theme selection for styles
144
144
  app.include_router(users.router, prefix="/api") # User auth, credits, and Stripe webhooks
145
145
  app.include_router(admin.router, prefix="/api") # Admin dashboard and management
146
146
  app.include_router(rate_limits.router, prefix="/api") # Rate limits admin management
147
+ app.include_router(push.router, prefix="/api") # Push notification subscription management
147
148
  app.include_router(tenant.router) # Tenant/white-label configuration (no /api prefix, router has it)
148
149
 
149
150
 
backend/models/job.py CHANGED
@@ -285,6 +285,10 @@ class Job(BaseModel):
285
285
  customer_email: Optional[str] = None # Customer email for final delivery (job owned by admin during processing)
286
286
  customer_notes: Optional[str] = None # Notes provided by customer with their order
287
287
 
288
+ # Output deletion tracking (for admin cleanup without deleting job)
289
+ outputs_deleted_at: Optional[datetime] = None # Timestamp when outputs were deleted by admin
290
+ outputs_deleted_by: Optional[str] = None # Admin email who deleted outputs
291
+
288
292
  # Processing state
289
293
  track_output_dir: Optional[str] = None # Local output directory (temp)
290
294
  audio_hash: Optional[str] = None # Hash for deduplication
backend/models/user.py CHANGED
@@ -8,9 +8,9 @@ Supports:
8
8
  - Stripe integration for payments
9
9
  - Beta tester program with feedback collection
10
10
  """
11
- from datetime import datetime
11
+ from datetime import datetime, timezone
12
12
  from enum import Enum
13
- from typing import Optional, List
13
+ from typing import Optional, List, Dict
14
14
  from pydantic import BaseModel, Field
15
15
 
16
16
 
@@ -39,6 +39,20 @@ class CreditTransaction(BaseModel):
39
39
  created_by: Optional[str] = None # Admin email if granted by admin
40
40
 
41
41
 
42
+ class PushSubscription(BaseModel):
43
+ """
44
+ Web Push subscription for a user's device.
45
+
46
+ Stores the push subscription endpoint and encryption keys needed
47
+ to send push notifications to the user's browser/device.
48
+ """
49
+ endpoint: str # Push service endpoint URL
50
+ keys: Dict[str, str] # p256dh and auth keys for encryption
51
+ device_name: Optional[str] = None # e.g., "iPhone", "Chrome on Windows"
52
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
53
+ last_used_at: Optional[datetime] = None # Last time a notification was sent
54
+
55
+
42
56
  class User(BaseModel):
43
57
  """
44
58
  User model stored in Firestore.
@@ -86,6 +100,10 @@ class User(BaseModel):
86
100
  beta_feedback_due_at: Optional[datetime] = None # 24hr after job completion
87
101
  beta_feedback_email_sent: bool = False
88
102
 
103
+ # Push notification subscriptions (Web Push API)
104
+ # Users can subscribe from multiple devices/browsers
105
+ push_subscriptions: List[PushSubscription] = Field(default_factory=list)
106
+
89
107
 
90
108
  class MagicLinkToken(BaseModel):
91
109
  """
@@ -40,6 +40,9 @@ class EncodingInput:
40
40
  # Output directory
41
41
  output_dir: str = ""
42
42
 
43
+ # Instrumental selection (clean, with_backing, or custom)
44
+ instrumental_selection: str = "clean"
45
+
43
46
  # Additional options
44
47
  options: Dict[str, Any] = field(default_factory=dict)
45
48
 
@@ -328,6 +331,7 @@ class GCEEncodingBackend(EncodingBackend):
328
331
  "formats": ["mp4_4k_lossless", "mp4_4k_lossy", "mkv_4k", "mp4_720p"],
329
332
  "artist": input_config.artist,
330
333
  "title": input_config.title,
334
+ "instrumental_selection": input_config.instrumental_selection,
331
335
  }
332
336
 
333
337
  # Submit and wait for completion