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.
- backend/Dockerfile.base +1 -0
- backend/api/routes/admin.py +226 -3
- backend/api/routes/push.py +238 -0
- backend/api/routes/users.py +14 -3
- backend/config.py +12 -1
- backend/main.py +2 -1
- backend/models/job.py +4 -0
- backend/models/user.py +20 -2
- backend/services/encoding_interface.py +4 -0
- backend/services/gce_encoding/main.py +22 -8
- backend/services/job_manager.py +68 -11
- backend/services/job_notification_service.py +4 -21
- backend/services/push_notification_service.py +409 -0
- backend/services/stripe_service.py +2 -2
- backend/tests/conftest.py +2 -1
- backend/tests/test_admin_delete_outputs.py +352 -0
- backend/tests/test_gce_encoding_worker.py +229 -0
- backend/tests/test_impersonation.py +18 -3
- backend/tests/test_job_notification_service.py +24 -58
- backend/tests/test_push_notification_service.py +460 -0
- backend/tests/test_push_routes.py +357 -0
- backend/tests/test_stripe_service.py +205 -0
- backend/tests/test_video_worker_orchestrator.py +189 -0
- backend/workers/video_worker_orchestrator.py +23 -0
- karaoke_gen/instrumental_review/server.py +145 -35
- karaoke_gen/nextjs_frontend/__init__.py +98 -0
- karaoke_gen/nextjs_frontend/out/404/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/404.html +1 -0
- karaoke_gen/nextjs_frontend/out/__next.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/__next._full.txt +22 -0
- karaoke_gen/nextjs_frontend/out/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/01a7f8fe40f1ff47.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/112f346e31f991df.js +4 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/16d1a4dd9d8a873a.js +3 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/1ab85c362b8b0e86.js +9 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/247eb132b7f7b574.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/2b80d15cc95e4818.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/32c7eba5cd46c1bc.js +7 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/483f26794eae53d0.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/550c3b02e85f196a.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/55c5ade44387bef8.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/5628d92b5893add2.css +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/56ebf7665e4341c8.js +7 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/5997132b61dec430.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/5ea55255bce3eb9e.js +5 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/5eda89a57490b3cd.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/692f5d9e0d700c76.js +3 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/71d7a05b14f9f0f4.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/81ac355749ef3302.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/95f7e5934dbb0e5d.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/9bce8f19eaa46940.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/a6dad97d9634a72d.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/a9ed54eed3e14c92.js +2 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/b35cd41238ecfb17.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/b5bc3c3d5ebd49eb.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/b5c078c08db5ae32.js +5 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/be9c44a178104187.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/c4c840e18cb4861c.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/c645af7d6b65f73e.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/d2c5e2575df784d4.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/d30af02b96d81462.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/d9bdf64f4ec1e9b7.js +7 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/dcde6ed684dacd0e.js +5 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/e422cbe931246000.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/e483af34fc792d38.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/e57422aad6b897da.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/ef02697fb404726a.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/ff1a16fafef87110.js +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/chunks/turbopack-2d9ca3017a9deedf.js +3 -0
- karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_buildManifest.js +11 -0
- karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_clientMiddlewareManifest.json +1 -0
- karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_ssgManifest.js +1 -0
- karaoke_gen/nextjs_frontend/out/_not-found/__next._full.txt +18 -0
- karaoke_gen/nextjs_frontend/out/_not-found/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/_not-found/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/_not-found/__next._not-found.__PAGE__.txt +5 -0
- karaoke_gen/nextjs_frontend/out/_not-found/__next._not-found.txt +4 -0
- karaoke_gen/nextjs_frontend/out/_not-found/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/_not-found/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/_not-found/index.txt +18 -0
- karaoke_gen/nextjs_frontend/out/admin/__next._full.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/admin/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/admin/__next.admin.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/__next.admin.txt +7 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/__next._full.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.beta.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.beta.txt +4 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.txt +7 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/admin/beta/index.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/admin/index.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/__next._full.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.jobs.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.jobs.txt +4 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.txt +7 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/admin/jobs/index.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._full.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.rate-limits.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.rate-limits.txt +4 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.txt +7 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/admin/rate-limits/index.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/__next._full.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.searches.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.searches.txt +4 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.txt +7 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/admin/searches/index.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/users/__next._full.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/users/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/admin/users/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/users/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.txt +7 -0
- karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.users.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.users.txt +4 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._full.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.txt +7 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.detail.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.detail.txt +4 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.txt +4 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/admin/users/detail/index.txt +25 -0
- karaoke_gen/nextjs_frontend/out/admin/users/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/admin/users/index.txt +25 -0
- karaoke_gen/nextjs_frontend/out/app/__next._full.txt +22 -0
- karaoke_gen/nextjs_frontend/out/app/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/app/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/app/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/app/__next.app.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/app/__next.app.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/app/index.txt +22 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/__next._full.txt +19 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.$oc$slug.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/index.txt +19 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._full.txt +19 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.$oc$slug.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/index.txt +19 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._full.txt +19 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.$oc$slug.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.txt +4 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/app/jobs/local/review/index.txt +19 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/__next._full.txt +22 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.txt +4 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.verify.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.verify.txt +4 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/auth/verify/index.txt +22 -0
- karaoke_gen/nextjs_frontend/out/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/index.txt +22 -0
- karaoke_gen/nextjs_frontend/out/manifest.webmanifest +31 -0
- karaoke_gen/nextjs_frontend/out/order/success/__next._full.txt +22 -0
- karaoke_gen/nextjs_frontend/out/order/success/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/order/success/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/order/success/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/order/success/__next.order.success.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/order/success/__next.order.success.txt +4 -0
- karaoke_gen/nextjs_frontend/out/order/success/__next.order.txt +4 -0
- karaoke_gen/nextjs_frontend/out/order/success/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/order/success/index.txt +22 -0
- karaoke_gen/nextjs_frontend/out/payment/success/__next._full.txt +22 -0
- karaoke_gen/nextjs_frontend/out/payment/success/__next._head.txt +8 -0
- karaoke_gen/nextjs_frontend/out/payment/success/__next._index.txt +9 -0
- karaoke_gen/nextjs_frontend/out/payment/success/__next._tree.txt +2 -0
- karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.success.__PAGE__.txt +9 -0
- karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.success.txt +4 -0
- karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.txt +4 -0
- karaoke_gen/nextjs_frontend/out/payment/success/index.html +1 -0
- karaoke_gen/nextjs_frontend/out/payment/success/index.txt +22 -0
- karaoke_gen/nextjs_frontend/out/screenshots/email-action_reminder.png +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/email-beta_welcome.png +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/email-job_completion.png +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/example-output.avif +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/homepage-full.png +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/homepage-hero.png +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/instrumental-review.avif +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/instrumental-review.png +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/job-dashboard.avif +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/lyrics-review.avif +0 -0
- karaoke_gen/nextjs_frontend/out/screenshots/lyrics-review.png +0 -0
- karaoke_gen/nextjs_frontend/out/sw.js +183 -0
- karaoke_gen/utils/cli_args.py +3 -3
- karaoke_gen/utils/gen_cli.py +4 -0
- karaoke_gen/utils/remote_cli.py +8 -40
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/RECORD +244 -131
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/WHEEL +1 -1
- lyrics_transcriber/correction/agentic/agent.py +83 -60
- lyrics_transcriber/correction/anchor_sequence.py +48 -3
- lyrics_transcriber/correction/corrector.py +92 -58
- lyrics_transcriber/review/server.py +165 -33
- lyrics_transcriber/utils/tracing.py +214 -0
- karaoke_gen/instrumental_review/static/index.html +0 -1695
- lyrics_transcriber/frontend/.gitignore +0 -24
- lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +0 -935
- lyrics_transcriber/frontend/.yarnrc.yml +0 -3
- lyrics_transcriber/frontend/README.md +0 -50
- lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +0 -210
- lyrics_transcriber/frontend/__init__.py +0 -25
- lyrics_transcriber/frontend/e2e/agentic-corrections.spec.ts +0 -207
- lyrics_transcriber/frontend/e2e/fixtures/agentic-correction-data.json +0 -226
- lyrics_transcriber/frontend/eslint.config.js +0 -28
- lyrics_transcriber/frontend/index.html +0 -22
- lyrics_transcriber/frontend/package-lock.json +0 -4553
- lyrics_transcriber/frontend/package.json +0 -48
- lyrics_transcriber/frontend/playwright.config.ts +0 -69
- lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/src/App.tsx +0 -243
- lyrics_transcriber/frontend/src/api.ts +0 -262
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +0 -111
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +0 -114
- lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +0 -204
- lyrics_transcriber/frontend/src/components/AppHeader.tsx +0 -65
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +0 -180
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +0 -175
- lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +0 -359
- lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +0 -281
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +0 -162
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +0 -257
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +0 -94
- lyrics_transcriber/frontend/src/components/EditModal.tsx +0 -720
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +0 -592
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +0 -431
- lyrics_transcriber/frontend/src/components/FileUpload.tsx +0 -77
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +0 -467
- lyrics_transcriber/frontend/src/components/Header.tsx +0 -520
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +0 -1526
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +0 -216
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +0 -721
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +0 -80
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +0 -999
- lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +0 -51
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +0 -127
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +0 -67
- lyrics_transcriber/frontend/src/components/ModelSelector.tsx +0 -23
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +0 -177
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +0 -268
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +0 -336
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +0 -354
- lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +0 -64
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +0 -383
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +0 -131
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +0 -266
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +0 -191
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +0 -466
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +0 -56
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +0 -89
- lyrics_transcriber/frontend/src/components/shared/constants.ts +0 -30
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +0 -180
- lyrics_transcriber/frontend/src/components/shared/styles.ts +0 -13
- lyrics_transcriber/frontend/src/components/shared/types.js +0 -2
- lyrics_transcriber/frontend/src/components/shared/types.ts +0 -135
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +0 -177
- lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +0 -78
- lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +0 -75
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +0 -360
- lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +0 -110
- lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +0 -22
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +0 -537
- lyrics_transcriber/frontend/src/main.tsx +0 -11
- lyrics_transcriber/frontend/src/theme.ts +0 -406
- lyrics_transcriber/frontend/src/types/global.d.ts +0 -9
- lyrics_transcriber/frontend/src/types.js +0 -2
- lyrics_transcriber/frontend/src/types.ts +0 -199
- lyrics_transcriber/frontend/src/validation.ts +0 -132
- lyrics_transcriber/frontend/src/vite-env.d.ts +0 -1
- lyrics_transcriber/frontend/tsconfig.app.json +0 -26
- lyrics_transcriber/frontend/tsconfig.json +0 -25
- lyrics_transcriber/frontend/tsconfig.node.json +0 -23
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +0 -1
- lyrics_transcriber/frontend/update_version.js +0 -11
- lyrics_transcriber/frontend/vite.config.d.ts +0 -2
- lyrics_transcriber/frontend/vite.config.js +0 -15
- lyrics_transcriber/frontend/vite.config.ts +0 -16
- lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js +0 -44465
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +0 -1
- lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
- lyrics_transcriber/frontend/web_assets/index.html +0 -22
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +0 -5
- lyrics_transcriber/frontend/yarn.lock +0 -3711
- {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/apple-touch-icon.png +0 -0
- {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon-16x16.png +0 -0
- {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon-32x32.png +0 -0
- {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon.ico +0 -0
- {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/nomad-karaoke-logo.svg +0 -0
- /lyrics_transcriber/frontend/public/nomad-karaoke-logo.png → /karaoke_gen/nextjs_frontend/out/nomad-logo.png +0 -0
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/entry_points.txt +0 -0
- {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 && \
|
backend/api/routes/admin.py
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
+
)
|
backend/api/routes/users.py
CHANGED
|
@@ -858,8 +858,19 @@ async def enroll_beta_tester(
|
|
|
858
858
|
detail="Access denied from this location"
|
|
859
859
|
)
|
|
860
860
|
|
|
861
|
-
# 3. Check
|
|
862
|
-
|
|
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
|
-
#
|
|
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
|