Compare commits
624 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ddecb4acc | |||
| 17a35247c1 | |||
| fb987acc18 | |||
| b524b8e6ec | |||
| 9cfc31f725 | |||
| d512a1d329 | |||
| 4e2033e40c | |||
| 8c811c411c | |||
| 44c17c8b73 | |||
| d961eadc93 | |||
| c7d627b817 | |||
| 9850be8a49 | |||
| f49e196596 | |||
| c8168564bb | |||
| a210b2ded7 | |||
| 7386ab0dd0 | |||
| 3f83e0f11c | |||
| 6303b4f62c | |||
| 02cc83ed31 | |||
| a97cdcf395 | |||
| 5614bbefad | |||
| 6ecc7f1f37 | |||
| 35ae775954 | |||
| 412b96ba16 | |||
| 40b5cb8328 | |||
| 7e27856359 | |||
| 2c5c569797 | |||
| 855a4a5d2a | |||
| 3835d9f9c4 | |||
| 8a329aadcf | |||
| e2c3f2a3aa | |||
| b16fc3ca7e | |||
| 282cafc52f | |||
| 08f56d09d1 | |||
| e4b6fc525f | |||
| 53a27ce06c | |||
| fc32791cea | |||
| 007033e7e8 | |||
| e38678009e | |||
| 1fef60a7fb | |||
| 29ab4840d0 | |||
| 15ddc4c332 | |||
| 2c2342fbaf | |||
| b8f81edb59 | |||
| db8391b81c | |||
| db29b0dd18 | |||
| dd4f8ddded | |||
| 23a1275025 | |||
| 13fbcc2d43 | |||
| fe481d0417 | |||
| ded5dca698 | |||
| 167b2fc3c5 | |||
| 2071a821db | |||
| 6f00c6fa54 | |||
| 43bbc8172b | |||
| 30999726e5 | |||
| 826ce218a4 | |||
| 739d6c6e81 | |||
| d12b732e40 | |||
| e24048e961 | |||
| 528f09d96a | |||
| 0dce46bcab | |||
| f00758dc47 | |||
| 8a187a3ed8 | |||
| 9395f503b4 | |||
| bc804afb55 | |||
| 80220d06f0 | |||
| 05486a61af | |||
| 955182d6da | |||
| 5fb46bf5eb | |||
| 9009f2c8cf | |||
| f1afe6e028 | |||
| 7a3d44420a | |||
| 4477026638 | |||
| 9f8808a596 | |||
| b501cd9e3e | |||
| 803bc7840a | |||
| 7aeced6a8d | |||
| a19a734757 | |||
| c9c6286571 | |||
| ec3989c354 | |||
| 916bf626de | |||
| 3eef1a50f9 | |||
| 585dd30efb | |||
| accf20ba57 | |||
| 3839948eeb | |||
| dc70be768a | |||
| ad94354632 | |||
| 8331ccf6a3 | |||
| 372e006be1 | |||
| dcfb1fca9f | |||
| ea74aaaf2e | |||
| 54ef4c038e | |||
| 394b07f404 | |||
| d6df0de63a | |||
| 76060f60a8 | |||
| b9f06bb7cd | |||
| d105385006 | |||
| e48baa5b27 | |||
| cf47fee07e | |||
| a0b3255028 | |||
| d36aea212c | |||
| efef23753b | |||
| 4e34696719 | |||
| ba1a1cd8ec | |||
| bfdbf7568f | |||
| 4eba3b0bb3 | |||
| 39fabc8d0d | |||
| 371812b274 | |||
| a6d25344b4 | |||
| 81ea5909d2 | |||
| 9cf6bb4cf2 | |||
| 3e97e34aee | |||
| fc7c9e978f | |||
| daafae8af6 | |||
| 841822d8fe | |||
| 1730aa0166 | |||
| b7a60f24c5 | |||
| f7366b167c | |||
| c926937694 | |||
| d8d908d4a6 | |||
| b5fdb826b0 | |||
| dae8020a22 | |||
| 919a800f4b | |||
| 79de2503c4 | |||
| 5b3036ed83 | |||
| 946b1d7cf9 | |||
| 56d94b7424 | |||
| 41ac7a5a93 | |||
| f07d29cdcf | |||
| bb4e169d0a | |||
| fe28573b68 | |||
| d5ea5f52ee | |||
| 78df665480 | |||
| a64a5598ae | |||
| 5fb7d85019 | |||
| ca5fc5649a | |||
| 09309630cb | |||
| db7afe4ea7 | |||
| e6a80b6086 | |||
| f35cbc82fe | |||
| ed7304af1f | |||
| 0b2fee1520 | |||
| cff18df783 | |||
| 2c4bd3a394 | |||
| d899bc9456 | |||
| ce437521ee | |||
| ef6d21b94e | |||
| bef1e3adfb | |||
| 313551ac7c | |||
| f08b412772 | |||
| d98ead97c3 | |||
| ff37efea89 | |||
| 55515981a9 | |||
| 74b9c02722 | |||
| 96b13af95d | |||
| f8f9844ef4 | |||
| 6ac943ca09 | |||
| 364450885b | |||
| fbb397228e | |||
| c2a3e53991 | |||
| 23b34004ff | |||
| aedbe82d28 | |||
| 2bb7d86e63 | |||
| ff9c87c461 | |||
| e59271aa00 | |||
| b27ec1b7d0 | |||
| c1ed2a9ba3 | |||
| 294414d00a | |||
| 2b42e01cd0 | |||
| 26d7a05ba4 | |||
| cfacc9f79a | |||
| 07ddc69cee | |||
| 779e1f569c | |||
| 5011fb43f0 | |||
| a9d6445881 | |||
| 56e205082d | |||
| 31e00e6abd | |||
| e9f4411fdf | |||
| 22c2ae5ecb | |||
| b7bd6ba04f | |||
| 1e6129401b | |||
| bf00b7f22f | |||
| 913861860b | |||
| e0f371cda6 | |||
| 44a15bf67d | |||
| 65e5e09245 | |||
| d73e94a12f | |||
| df4381b4d8 | |||
| ad8cb7dbc0 | |||
| 652c90979d | |||
| 1ad501ff11 | |||
| c9b8dfcf3f | |||
| 2bacf58241 | |||
| 83c0425133 | |||
| 0758bfe7f1 | |||
| 79e7bb4799 | |||
| 45bf5e5d37 | |||
| 3c7f28b2eb | |||
| 61d53dacff | |||
| 06b58304c5 | |||
| b3283d0bd2 | |||
| cb6f75be5f | |||
| c1562dde03 | |||
| 8b0bd6d26e | |||
| 7d23c0654b | |||
| cab181db4b | |||
| f02f370ed9 | |||
| b451dda79e | |||
| 4f84216ab6 | |||
| bb50d8369b | |||
| ebdcc29f2e | |||
| ea8b97e47b | |||
| f1600023dc | |||
| 09f6dc88f7 | |||
| 31084b09a4 | |||
| 5941f1f23a | |||
| 7f1c6bdb66 | |||
| 37608aee28 | |||
| e0ab2f3d00 | |||
| 41e3ccc9fa | |||
| 709103ad71 | |||
| 9f074f7350 | |||
| 47082591ee | |||
| 4df2b8fb57 | |||
| a9965ad751 | |||
| c64455f2f2 | |||
| 2d0a565765 | |||
| c608fa345a | |||
| 09a980ba2a | |||
| da08ac4efb | |||
| 00d7215178 | |||
| 898fcfaa04 | |||
| 05130aaed2 | |||
| 03c96c621b | |||
| c9457ae21b | |||
| c6ef641ab9 | |||
| 3ef98aa3ff | |||
| 4b9e6531fd | |||
| f9c483bbad | |||
| 20084ace4f | |||
| 3f1230fd2d | |||
| 9e7755812f | |||
| 314e7b1f34 | |||
| 743c2c3d02 | |||
| e78a61c3b1 | |||
| 2991d9ec5d | |||
| c748d901d3 | |||
| 1beefe4515 | |||
| 62dd9d5c03 | |||
| 737c423d9c | |||
| 4701804594 | |||
| 18f4b596f2 | |||
| eeab0a1c4c | |||
| f44c270b9f | |||
| 208db33927 | |||
| 97686c2a16 | |||
| 86999cb94e | |||
| 1b37a637e5 | |||
| 2bd9aa7b74 | |||
| c44117ccc5 | |||
| bc5d7f52b8 | |||
| add43c5a7d | |||
| 2f7af6d6d2 | |||
| fccace1381 | |||
| c83b06aaee | |||
| 77a9eb1158 | |||
| f6b7fa2df5 | |||
| 2f565deb8f | |||
| 26246b5d65 | |||
| b893ca84de | |||
| 6f42464294 | |||
| 6e0da7a486 | |||
| 79c4e1e584 | |||
| 0371bcd15e | |||
| 9122cfee6e | |||
| bcfcc91618 | |||
| fdc0208339 | |||
| 1a08e3c787 | |||
| 7f575d1d75 | |||
| 9a9adf5a57 | |||
| 31d7b20672 | |||
| 3ab1d77ecb | |||
| 0b989aa739 | |||
| bb61cf4014 | |||
| 8b62915083 | |||
| a7e2335c20 | |||
| a40d82fa22 | |||
| ea018beb3e | |||
| 412c0334c6 | |||
| 3ea4eb143b | |||
| d4d28fdb0e | |||
| 2f47efeb46 | |||
| af724ce570 | |||
| 5f7eaed112 | |||
| 46749c8fa4 | |||
| ca44fc8794 | |||
| 22f4939b24 | |||
| 93dcc59814 | |||
| 5d6b54d2fc | |||
| 6f63fe7d7c | |||
| 8087fd04ce | |||
| c1271aeb90 | |||
| 0b349da5f8 | |||
| f07ad58655 | |||
| 2f7f8dbdf8 | |||
| 528b904d72 | |||
| 0448711082 | |||
| dd30d57838 | |||
| 70f110bed7 | |||
| 80ebc80a2a | |||
| 68bf328e7c | |||
| b5bd1c977b | |||
| 4b26e0a969 | |||
| c6078a3e71 | |||
| 0874042040 | |||
| 6d3b9cd4d3 | |||
| 9792d4346e | |||
| fc20a5d3d2 | |||
| f02974b3c2 | |||
| a6e565e445 | |||
| 38e345ccf7 | |||
| fd8c0e389f | |||
| b359786e69 | |||
| bef3f590ca | |||
| 407ed90341 | |||
| 92a3bea129 | |||
| 6480953189 | |||
| b22c3f96d7 | |||
| 62620bc0d4 | |||
| 55b26b2e41 | |||
| 508a522a8d | |||
| cf557e16aa | |||
| a2f9742f8a | |||
| a29b961c27 | |||
| e077b8ec7b | |||
| 612b21b1e7 | |||
| 70d4a87cd5 | |||
| ae531116b7 | |||
| 63bdc5ee93 | |||
| f767d288c5 | |||
| 9d7f2ff003 | |||
| c59f59c3fe | |||
| 91566692f6 | |||
| 16f356a760 | |||
| 8983592e56 | |||
| 92ddc5bb3e | |||
| 76e5080278 | |||
| 675710d086 | |||
| c46c3a2f9c | |||
| 49e99ff986 | |||
| 5a345cabea | |||
| 25ade16b07 | |||
| 5d9ba1c953 | |||
| ab418bf840 | |||
| d3f1d6a8a0 | |||
| 4d9505c341 | |||
| 0439d3da4f | |||
| 98142754fa | |||
| 3da12067f6 | |||
| 86e1243eba | |||
| b6b212e429 | |||
| 879c30a5e5 | |||
| a2771c71aa | |||
| 81b8796ba5 | |||
| 489215e415 | |||
| b7b5933b25 | |||
| c4930e80ba | |||
| b04081b960 | |||
| bd6bd4d827 | |||
| c835a54652 | |||
| 909d259df9 | |||
| f10e20a0e2 | |||
| 009f565b73 | |||
| 4a46ec36b3 | |||
| 0b0bcb3dee | |||
| 34e7f2f8ed | |||
| 3bb8104967 | |||
| a82bd875d9 | |||
| 72171c9374 | |||
| 480c961a09 | |||
| 754dc311a6 | |||
| d47a5e00af | |||
| 77dee5eac5 | |||
| f8186fb7c7 | |||
| 092ac0b5f2 | |||
| 3953229ae4 | |||
| 8d80d43a47 | |||
| eddbb00cd9 | |||
| aa1f7d50f1 | |||
| b4cda76114 | |||
| 38529a962a | |||
| d2a9475ba2 | |||
| e84823be39 | |||
| 6c602170a9 | |||
| 88ac5b2c88 | |||
| 0f5eaa42b5 | |||
| f0185587f7 | |||
| 0a5ddfdad8 | |||
| 8b94a5fdf7 | |||
| fb27918ed6 | |||
| 691d904273 | |||
| ded5a3e5eb | |||
| f25d0f624f | |||
| 43f54cb950 | |||
| f40940b957 | |||
| 10256677ac | |||
| 6fe7663667 | |||
| 5cae83b9ed | |||
| d9b92e0703 | |||
| 0fd1977353 | |||
| 1071ba7374 | |||
| 79a015f60a | |||
| 0bd7e6904d | |||
| f602eb9772 | |||
| b372bee365 | |||
| fad3635fa1 | |||
| 561f4a500a | |||
| 9be35e5a58 | |||
| aaa9f732ae | |||
| 5c3c3c3d0c | |||
| 760e9a1982 | |||
| 5b3bbc7b47 | |||
| f40786171d | |||
| cef1e6bc69 | |||
| 5258729c86 | |||
| 8679a9f619 | |||
| ae22153edb | |||
| e3df6dd93e | |||
| 6151e6024c | |||
| 505ac0c47b | |||
| 6cacf51318 | |||
| 87971dbd6f | |||
| 881d3d49cd | |||
| 561cd45237 | |||
| 4e6e3c9eab | |||
| 4ab48ce527 | |||
| 58725c4646 | |||
| 9cbc09edf7 | |||
| 149127c920 | |||
| ad1c85f3ee | |||
| 095b49701f | |||
| 0392ef6954 | |||
| c086d03776 | |||
| b9969640e5 | |||
| a2814fc939 | |||
| 5b50879476 | |||
| 16f4f894f9 | |||
| 2bac1520db | |||
| 6ce7c580a0 | |||
| 1c942ffb2b | |||
| b88af29731 | |||
| 21e1a33ccf | |||
| 2db9a6251a | |||
| 00a3cc8034 | |||
| 6705c52b69 | |||
| 4e6cda939d | |||
| 1bd27f2160 | |||
| 8fbabcdbc5 | |||
| 1fdffb1e50 | |||
| 2eebc04733 | |||
| 7eae599490 | |||
| 9169493d41 | |||
| f1da2382d2 | |||
| 165d935ae7 | |||
| cef4d243f3 | |||
| d07ebc9e66 | |||
| 317e9f84b8 | |||
| c57e61f7f9 | |||
| 2e165d0aef | |||
| b7b539743b | |||
| 0e5cf7e79d | |||
| 3f02686012 | |||
| 9015411f12 | |||
| 0d4ef369b9 | |||
| 4b1a68aa29 | |||
| ea535e0c7e | |||
| ceb0984262 | |||
| 94a2789127 | |||
| 2b4cdeaf72 | |||
| 7cd85f0bb1 | |||
| 465cb1ff6c | |||
| 40e001cc7a | |||
| a6eba5d8c3 | |||
| c766cdf5b8 | |||
| 905d7fa409 | |||
| c4dc382bd7 | |||
| fa28bfb5cc | |||
| 5703ac2752 | |||
| 10cb96ef7c | |||
| f6616ed109 | |||
| 6ef88bef38 | |||
| 7bd9a434ca | |||
| 627d5623f0 | |||
| 1e9313a5d7 | |||
| 5bc1b63b61 | |||
| 9ead3bf2a7 | |||
| eecab12f48 | |||
| 858110306c | |||
| 4e6ec75000 | |||
| 8e4d783ec2 | |||
| daa334a947 | |||
| bd15b66aee | |||
| 4072197313 | |||
| 22452815c6 | |||
| 8ba3a10e15 | |||
| ba31e124f2 | |||
| 86d70c1af6 | |||
| e04f780014 | |||
| 80a79c1232 | |||
| 75766154bb | |||
| cb9c5f9b3c | |||
| 5d3ea49de8 | |||
| a2b8b12bf0 | |||
| fcaf8f0bf6 | |||
| 3de88c786a | |||
| 5cdd69d7d9 | |||
| 6dfb3a2f23 | |||
| 54939721e4 | |||
| ec88759b55 | |||
| 8b3e7e0620 | |||
| 18b5fa9401 | |||
| c4e7b49776 | |||
| 13adb144a6 | |||
| 84a302ce24 | |||
| 47d0475d3f | |||
| 4341d97f12 | |||
| bd110c07da | |||
| d1cb85b840 | |||
| 07ba9946ce | |||
| 4b5de088ab | |||
| 9ce2631bf4 | |||
| 475f93c8a3 | |||
| a4b098b8ea | |||
| 7dfdad2666 | |||
| b1d58c1327 | |||
| 6b18d7cc1e | |||
| 93d9b47a67 | |||
| 0dd33a5dfc | |||
| 3e4ddbb2a6 | |||
| 1bb6e29e47 | |||
| c83b132522 | |||
| d96c41eafb | |||
| 9110b4b764 | |||
| 526e607f33 | |||
| 7d3da58573 | |||
| e3fe401abf | |||
| 1d97729e57 | |||
| 766e98fd2b | |||
| d055c2a548 | |||
| 75bf93c2bb | |||
| b746645f97 | |||
| ab9db6d0ec | |||
| 3dc9fc2446 | |||
| 59dbfb8aab | |||
| 76e16fe32e | |||
| 97c8439ed7 | |||
| cabc8654d1 | |||
| f468fafaba | |||
| af6ed6130f | |||
| 6e25ad3085 | |||
| 75db127708 | |||
| 84307dabde | |||
| 1b493434d6 | |||
| 2ee0667aa2 | |||
| 9c916245c1 | |||
| 8de7342352 | |||
| acd76e0601 | |||
| 7c89220667 | |||
| 9cfcd5f67a | |||
| 9538310c43 | |||
| b3473aa37e | |||
| de4583b759 | |||
| 9d39843982 | |||
| edf45bb8de | |||
| 9854d51940 | |||
| 92f860897b | |||
| cc1fa60a4d | |||
| fa57861dbf | |||
| 7c401d75b5 | |||
| 3c17260f32 | |||
| 61c5bee5d7 | |||
| eed99df0dd | |||
| 1986aed902 | |||
| c10d315a7b | |||
| b9b2c131a8 | |||
| 231ed399a3 | |||
| d9664988ad | |||
| b22b57069d | |||
| a86ccae432 | |||
| 87f722fa58 | |||
| 31d2c2ee7e | |||
| 78c6803e6b | |||
| 8178174275 | |||
| ffb71b6d71 | |||
| cbc43300b2 | |||
| 190d8d044f | |||
| 4887454911 | |||
| 0c5ebae9c9 | |||
| 91214336c5 | |||
| 4616fbf0e1 | |||
| 72e9f71fbc | |||
| b6572bead0 | |||
| f07ab4b235 | |||
| 73e0eea328 | |||
| dbf02a9426 | |||
| b24c6ff78e | |||
| de0c01ef4d | |||
| 8420ab8d37 | |||
| a57e0f71c4 | |||
| 7622e94ba2 | |||
| 034e9d5633 | |||
| db8a44fc79 | |||
| 6e274b7395 | |||
| 21b7661ca8 | |||
| 79591fe4e4 | |||
| c69c25c6dc | |||
| 4171b493fd | |||
| fe8ddff41c | |||
| 58a94fe315 | |||
| 757c1d5c85 | |||
| 194a76ce4c | |||
| a34e083c2e | |||
| 52d6afa335 |
@@ -7,3 +7,5 @@
|
||||
*.yaml text eol=lf
|
||||
*.service text eol=lf
|
||||
*.conf text eol=lf
|
||||
# Vendor JS pinned LF — avoids CRLF churn on Windows checkout
|
||||
DeepDrftShared.Client/wwwroot/js/parallax/parallax.js text eol=lf
|
||||
|
||||
@@ -9,7 +9,6 @@ on:
|
||||
- 'DeepDrftContent/**'
|
||||
- 'DeepDrftModels/**'
|
||||
- '.gitea/workflows/deploy-api.yml'
|
||||
- 'deploy/systemd/deepdrftapi.service'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -110,7 +109,6 @@ jobs:
|
||||
rsync -e "ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes" \
|
||||
staging/deepdrft-api.tar.gz \
|
||||
staging/deepdrft-migrations-bundle \
|
||||
deploy/systemd/deepdrftapi.service \
|
||||
deepdrft@$DEPLOY_HOST:
|
||||
|
||||
- name: Trigger deploy on host
|
||||
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install wasm-tools workload
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
|
||||
+7
-1
@@ -311,4 +311,10 @@ __pycache__/
|
||||
Database/Vaults/*
|
||||
|
||||
# TypeScript output
|
||||
**/wwwroot/js/*
|
||||
**/wwwroot/js/*
|
||||
# ...except hand-authored client JS modules (not TS compile output).
|
||||
!DeepDrftPublic.Client/wwwroot/js/
|
||||
!DeepDrftPublic.Client/wwwroot/js/*.js
|
||||
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
|
||||
# gitignored TS output is absent when manifest is generated, so absent from publish output.
|
||||
!DeepDrftShared.Client/wwwroot/js/parallax/
|
||||
@@ -9,12 +9,12 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
|
||||
### Core Projects
|
||||
|
||||
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
|
||||
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Consumed by the public site.
|
||||
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer.
|
||||
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Consumed by the public site.
|
||||
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes).
|
||||
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
|
||||
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
|
||||
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Seven track endpoints: `GET api/track/{id}` unauthenticated streaming; `PUT api/track/{id}` vault write (ApiKey); `POST api/track/upload` upload + SQL persist (ApiKey); `DELETE api/track/{id:long}` SQL delete + vault remove (ApiKey); `GET api/track/page` paged metadata list (unauthenticated); `GET api/track/meta/{id:long}` single metadata (ApiKey); `PUT api/track/meta/{id:long}` metadata update (ApiKey).
|
||||
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `WavOffsetService`, `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
|
||||
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations. Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
|
||||
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
|
||||
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
|
||||
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
|
||||
|
||||
@@ -70,7 +70,7 @@ The player is not fetch-then-play:
|
||||
2. `StreamingAudioPlayerService` reads in adaptive 16–64 KB chunks, pushes each via `AudioInteropService.processStreamingChunk`.
|
||||
3. TypeScript `StreamDecoder` parses WAV header, decodes chunks to `AudioBuffer`s. `PlaybackScheduler` schedules them on a Web Audio graph.
|
||||
4. Playback starts as soon as a min buffer is queued; UI duration from parsed header (not waiting for full file).
|
||||
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}?offset={byteOffset}`. Server's `WavOffsetService` block-aligns offset, synthesises a fresh 44-byte WAV header, streams `[new header][data from offset]`. Player tears down and re-initialises decoder for the new stream.
|
||||
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}` with `Range: bytes={byteOffset}-`. Server streams raw bytes from that file-absolute offset with a `206 Partial Content` response. Player retains the parsed WAV header and feeds the raw PCM continuation into the existing decode pipeline.
|
||||
|
||||
Keep this seam clean — it is the most architecturally load-bearing part of the playback path.
|
||||
|
||||
@@ -80,11 +80,13 @@ Keep this seam clean — it is the most architecturally load-bearing part of the
|
||||
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
|
||||
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
|
||||
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
|
||||
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DDIcons.cs`.
|
||||
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
|
||||
|
||||
### TypeScript interop, not raw JS
|
||||
|
||||
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging.
|
||||
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
|
||||
|
||||
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"` → `outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`) and `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -123,7 +125,7 @@ dotnet ef database update --project DeepDrftData --startup-project DeepDrftPubli
|
||||
All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitignored `environment/` files:
|
||||
|
||||
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
|
||||
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`).
|
||||
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 600 — budget for server-side persist after the body is fully sent).
|
||||
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
|
||||
|
||||
## Folder-Level Guidance
|
||||
|
||||
+1806
File diff suppressed because it is too large
Load Diff
+74
-50
@@ -2,61 +2,77 @@
|
||||
|
||||
Living orientation doc for what this repo is, how it is currently shaped, and where it appears headed. Sits alongside the root `CLAUDE.md` (operational guidance) — this file is the product/architecture view.
|
||||
|
||||
> **Drift notice.** The root `CLAUDE.md` and every folder-level `CLAUDE.md` currently in the tree describe the project as `.NET 9`. The most recent commit upgraded all projects to `.NET 10` (every `.csproj` now targets `net10.0`, packages pinned at `10.0.1`). Until those docs are refreshed, treat any framework-version claim in them as stale. The other staleness items are listed at the bottom of this file.
|
||||
> **Status.** The root `CLAUDE.md` is current — it reflects the post-split ten-project solution, `net10.0`, and the dual-app topology. This file (`CONTEXT.md`) was the lagging document and §2 / §4 / §7 below have been brought back into line with the root `CLAUDE.md` as of 2026-06-06. Folder-level `CLAUDE.md` files are still being swept (`DOC_PLAN.md`); treat framework-version and structural claims in any *folder* `CLAUDE.md` not yet rewritten as potentially stale until that sweep lands.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this project is
|
||||
|
||||
DeepDrftHome is the home + listening surface for **DeepDrft**, a two-person electronic music collective based in Charleston, SC (per `DeepDrftWeb.Client/Pages/Home.razor`). The product is, at minimum:
|
||||
DeepDrftHome is the home + listening surface for **DeepDrft**, a two-person electronic music collective based in Charleston, SC (per `DeepDrftPublic.Client/Pages/Home.razor`). The product is, at minimum:
|
||||
|
||||
- A public-facing site (hero, about, "experience" features).
|
||||
- A public-facing site (hero, about, "experience" features) at `DeepDrftPublic`.
|
||||
- A **track gallery** that browses a library of WAV recordings, plays them in-browser with a persistent dock-style player, and supports seek (including seek beyond what's been streamed so far).
|
||||
- An admin CLI for adding tracks (Terminal.Gui or scripted), running locally against the same dual-database substrate the site uses.
|
||||
- A browser-based **CMS** (`DeepDrftManager`) for adding, editing, and deleting tracks — gated behind AuthBlocks login and the `Admin` role. This replaced the former `DeepDrftCli` Terminal.Gui admin tool, which has been retired.
|
||||
|
||||
The interesting engineering bet is the **dual-database split**: structured track metadata in SQLite via EF Core, and binary media + per-vault indexes in a hand-rolled `FileDatabase` that lives on disk. The split is enforced across two ASP.NET Core hosts so that the browser never reaches the database directly.
|
||||
The interesting engineering bet is the **dual-database split**: structured track metadata in PostgreSQL via EF Core, and binary media + per-vault indexes in a hand-rolled `FileDatabase` that lives on disk. The split is enforced through a dedicated authority host (`DeepDrftAPI`) so that the browser never reaches the database directly.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution shape (current)
|
||||
|
||||
Eight projects in `DeepDrftHome.sln`, plus an external `NetBlocks` referenced from `C:\lib\NetBlocks\`.
|
||||
Ten projects in `DeepDrftHome.sln`, plus an external `NetBlocks` referenced from `C:\lib\NetBlocks\`. The solution is split into **two independent Blazor applications** — the public site (`DeepDrftPublic`) and the CMS (`DeepDrftManager`) — both fronting a single dual-database authority host (`DeepDrftAPI`).
|
||||
|
||||
```
|
||||
DeepDrftWeb ASP.NET Core host. Blazor Web App (Server + WASM render modes).
|
||||
Owns the SQL-backed API (api/track/page), MudBlazor theme/host,
|
||||
TypeScript→JS audio interop sources under Interop/.
|
||||
DeepDrftWeb.Client Blazor WebAssembly assembly. All interactive UI lives here —
|
||||
pages, controls, player services, dark-mode/theme plumbing,
|
||||
HTTP clients for both backends.
|
||||
DeepDrftWeb.Services Class library. EF Core: DeepDrftContext, TrackConfiguration,
|
||||
Migrations, TrackRepository, TrackService. Sharable between
|
||||
the web host and the CLI (avoids duplicating data-access).
|
||||
── Public application ──────────────────────────────────────────────────────
|
||||
DeepDrftPublic ASP.NET Core host. Blazor Web App (Server + WASM render
|
||||
modes). Owns the browser-facing proxy controller for
|
||||
api/track/* (metadata listing + audio streaming),
|
||||
MudBlazor theme prerender, and TypeScript→JS audio interop
|
||||
sources under Interop/. The public listening surface.
|
||||
DeepDrftPublic.Client Blazor WebAssembly assembly. All interactive public UI —
|
||||
pages, the player stack, dark-mode plumbing, HTTP clients
|
||||
for the backend. Consumed by DeepDrftPublic.
|
||||
|
||||
DeepDrftContent ASP.NET Core host. Binary content API (api/track/{id}).
|
||||
ApiKey middleware, CORS, ForwardedHeaders. Returns audio bytes
|
||||
(with optional byte offset) and accepts PUT of AudioBinaryDto.
|
||||
DeepDrftContent.Services Class library. The FileDatabase implementation in full
|
||||
── CMS application ─────────────────────────────────────────────────────────
|
||||
DeepDrftManager ASP.NET Core host. Blazor Web App (InteractiveServer).
|
||||
Hosts all CMS Razor components/pages (Components/Pages/Cms/,
|
||||
Components/Pages/Tracks/, Components/Layout/CmsLayout.razor,
|
||||
Components/Shared/ — inlined from the former DeepDrftCms RCL).
|
||||
Gated by AuthBlocks login + hierarchical Admin role. All track
|
||||
operations proxy via ICmsTrackService / CmsTrackService.
|
||||
|
||||
── Dual-database authority ─────────────────────────────────────────────────
|
||||
DeepDrftAPI ASP.NET Core host. The single authority over both databases
|
||||
(SQL metadata + FileDatabase binary). AuthBlocks API host
|
||||
(registration, migration/seed, JWT endpoints). Seven track
|
||||
endpoints (stream, vault write, upload, delete, paged list,
|
||||
single metadata read, metadata update).
|
||||
DeepDrftData Class library. EF Core domain logic: DeepDrftContext,
|
||||
TrackConfiguration, Migrations, TrackRepository, TrackService,
|
||||
TrackManager. Consumed by DeepDrftAPI and tests.
|
||||
DeepDrftContent Class library. The FileDatabase implementation in full
|
||||
(Models, Services, Utils, Abstractions, Constants),
|
||||
WavOffsetService, AudioProcessor, TrackService (the content-side
|
||||
orchestrator that processes WAVs and stores them in a vault).
|
||||
WavOffsetService, AudioProcessor, content-side TrackService.
|
||||
Consumed by hosts and tests.
|
||||
|
||||
── Shared ──────────────────────────────────────────────────────────────────
|
||||
DeepDrftShared.Client Razor Class Library. Shared Blazor components consumed by
|
||||
BOTH DeepDrftPublic and DeepDrftManager (e.g. TrackCard,
|
||||
TracksGallery) for consistency across public and admin surfaces.
|
||||
DeepDrftModels Shared contracts: TrackEntity, TrackDto, PagingParameters<T>,
|
||||
PagedResult<T>. The only project all three layers reference.
|
||||
|
||||
DeepDrftCli Console app. Two modes: classic `add` / `list` / `help` and
|
||||
`gui` (Terminal.Gui). Consumes BOTH service libraries directly
|
||||
(it's a local admin tool, not a network client).
|
||||
|
||||
PagedResult<T>, plus waveform DTOs. Every project references this.
|
||||
DeepDrftTests NUnit. Covers the FileDatabase, MediaVault, IndexSystem,
|
||||
MediaVaultFactory, SimpleMediaTypeRegistry, utility code, and
|
||||
model behaviour. References DeepDrftContent.Services.
|
||||
MediaVaultFactory, SimpleMediaTypeRegistry, utility code, model
|
||||
behaviour, and the waveform loudness algorithm. References
|
||||
DeepDrftContent.
|
||||
|
||||
NetBlocks (external) Result patterns: Result, ResultContainer<T>, ApiResult<T>,
|
||||
ApiResultDto<T>. Referenced via absolute path.
|
||||
```
|
||||
|
||||
Two stray .sln files (`WebAPI.sln`, `WebUI.sln`, `CLI.sln`) exist at the root alongside `DeepDrftHome.sln`. `DeepDrftHome.sln` is the canonical solution; the others appear to be subsets.
|
||||
**Naming history (for readers of older docs/commits):** `DeepDrftWeb` → `DeepDrftPublic`, `DeepDrftWeb.Client` → `DeepDrftPublic.Client`, `DeepDrftWeb.Services` → `DeepDrftData`, `DeepDrftContent.Services` → `DeepDrftContent` (the host that previously owned the binary API is gone; its proxy duties moved into `DeepDrftPublic`, its authority duties into `DeepDrftAPI`). `DeepDrftCli` and the `DeepDrftCms` RCL have both been removed — the CLI retired in favour of the CMS, and the CMS RCL was inlined into `DeepDrftManager`.
|
||||
|
||||
**Subdomain topology (deployment):** `deepdrft.com` (public) and `manage.deepdrft.com` (CMS), behind nginx. CD infrastructure (Gitea workflows + installer scripts + systemd/nginx templates) has landed — see `COMPLETED.md` "Deployment Infrastructure."
|
||||
|
||||
---
|
||||
|
||||
@@ -147,17 +163,19 @@ In dev, the host serves the original `.ts` sources at `/Interop/...` for source-
|
||||
|
||||
Recent commits (newest first):
|
||||
|
||||
- `style simplification and publish upgrades for dotnet 10`
|
||||
- `Styles & Home Page Content Cleanup Mobile Menu System & Dark Mode Cookie Theme Draft`
|
||||
- `Theming Draft 2`
|
||||
- `2026 Deep DRFT Theme Draft 1 WIP`
|
||||
- `Spectrum Visualizer for player & Layout`
|
||||
- `docs: update CLAUDE.md files to reflect Range header seek, remove WavOffsetService references`
|
||||
- `chore: remove WavOffsetService and ?offset= seek path, superseded by Range header (Phase 4.1)`
|
||||
- `feat: replace ?offset= seek with HTTP Range streaming across API, proxy, and client`
|
||||
- `refactor: extract StreamNowButton component shared by hero and nav menu`
|
||||
- (earlier: WaveformSeeker improvements, play-state icon normalization, CMS build-out, the two-app split, deployment infrastructure)
|
||||
|
||||
Three observations:
|
||||
Observations:
|
||||
|
||||
1. **The current arc is presentation, not capability.** The last five commits are framework upgrade, theming, content/layout cleanup, mobile menu, dark-mode persistence, and the spectrum visualiser. The playback substrate, streaming, and seek-beyond-buffer machinery landed earlier and is stable enough to support cosmetic iteration on top.
|
||||
2. **The "Track Gallery" is the only real page.** `/tracks` is the working surface; `/` is marketing copy. Nav (in `Pages.cs`) defines only `Home` + `Track Gallery`.
|
||||
3. **Content surface is narrow on purpose.** The DeepDrftContent API exposes exactly two routes: `GET api/track/{id}` (with optional `offset`) and `PUT api/track/{id}` (ApiKey). There is no listing endpoint there; listing lives on DeepDrftWeb because listings are SQL queries.
|
||||
1. **The big structural moves have landed.** Since the last revision of this doc, three large initiatives shipped: the **two-app split** (public/CMS separation with `DeepDrftAPI` as the dual-database authority), the **browser CMS** replacing the CLI (auth via AuthBlocks, stealth-routed `/cms/*`, full add/list/edit/delete parity), and **CD infrastructure** (Gitea workflows + host installer + systemd/nginx templates). The substrate is no longer the frontier — the product and presentation layers are.
|
||||
2. **Phase 4 streaming work is complete.** HTTP Range header seek (`Range: bytes=X-`) is now the sole seek mechanism; `WavOffsetService` and the `?offset=` path have been removed (Phase 4.1, merged 2026-06-09). `StreamDecoder.reinitializeForRangeContinuation` handles Range continuations by retaining the parsed WAV header. The streaming substrate is solid.
|
||||
3. **The embeddable iframe player has landed** (commit `c83b132`, 2026-06-07). The presentation layer now includes a chrome-free single-track embed surface for off-site use, completing the Phase 4 feature set.
|
||||
4. **The "Track Gallery" is still the only real public content page.** `/tracks` is the working listening surface; `/` is the (reskinned) marketing home. Nav (in `Layout/Pages.cs`) is still essentially `Home` + `Track Gallery`. The CMS adds admin surfaces under `/cms` but those are not public.
|
||||
5. **The metadata/streaming surface is consolidated on `DeepDrftAPI`.** It exposes seven track endpoints (stream, vault write, upload, delete, paged list, single-metadata read, metadata update) plus waveform endpoints. `DeepDrftPublic` is a thin browser-facing proxy in front of it; the browser never reaches `DeepDrftAPI` or the databases directly.
|
||||
|
||||
---
|
||||
|
||||
@@ -167,7 +185,7 @@ Captured here so the next round of planning has a starting point — none of thi
|
||||
|
||||
- **More vault types in active use.** `MediaVaultType.Image` exists end-to-end (tests cover it) but the production surface only registers a `tracks` vault of type `Audio`. The path to releases/albums probably runs through images first (cover art via `ImagePath`, which is currently a free-form URL string).
|
||||
- **More than one collection view.** The `TrackCard` already conditionally renders `ImagePath`, `Album`, `Genre`, `ReleaseDate` — the data shape supports album-grouped or genre-filtered views without schema work.
|
||||
- **Upload from the web side, not just the CLI.** The CLI is currently the only producer of tracks. A web-side upload would re-use `DeepDrftContent.Services.TrackService.AddTrackFromWavAsync` and pair it with a `TrackService.Create` on the SQL side. The `[ApiKeyAuthorize]` middleware on `PUT api/track/{id}` is already in place.
|
||||
- **Web upload — landed.** *(Historical note: this was a "likely direction" when the CLI was the only producer. It has since shipped.)* The CMS (`DeepDrftManager`) now produces tracks via `POST api/track/upload` on `DeepDrftAPI`, proxied through the auth-gated CMS surface. The CLI has been retired. The dual-write rollback gap (`PLAN.md §4.3`) still stands.
|
||||
- **Live/session content.** The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these yet; they would likely need new vault types (`MediaVaultType.Media` is the obvious home for video) and new entity tables.
|
||||
- **Non-WAV formats.** Today the producer side is WAV-only (`AudioProcessor.ProcessWavFileAsync` validates RIFF/WAVE/PCM). `MimeTypeExtensions` already knows mp3/flac/aac/ogg/m4a — the gap is a processor per format and a decoder strategy in the JS player (currently WAV-specific).
|
||||
- **Search / filter on the gallery.** `TracksViewModel` exposes `SortBy` / `IsDescending` but no filter. `TrackService.GetPaged` accepts only sort, not filter. Adding filter would be a natural next step on the same pagination contract.
|
||||
@@ -187,14 +205,20 @@ Captured here so the next round of planning has a starting point — none of thi
|
||||
|
||||
## 7. Staleness in existing docs (for doc-keeper to address)
|
||||
|
||||
Captured so the next sweep of folder-level `CLAUDE.md` files can correct in one pass.
|
||||
Two layers of drift remain. The root `CLAUDE.md` and this `CONTEXT.md` are current; the lag is now in **folder-level `CLAUDE.md` files** and the in-tree `FileDatabase` README. `DOC_PLAN.md` holds the per-folder rewrite briefs, but note that `DOC_PLAN.md` itself was authored against the *pre-split* project names (2026-05-16) and is partly superseded — see the warning at the end of this section.
|
||||
|
||||
- Every folder `CLAUDE.md` says ".NET 9" / "ASP.NET Core 9.0"; reality is `net10.0` across the board.
|
||||
- `DeepDrftModels/CLAUDE.md` and `DeepDrftContent.Services/FileDatabase/README.md` reference `TrackEntity.MediaPath`; the field is `EntryKey` and the column is `entry_key`.
|
||||
- `DeepDrftContent/CLAUDE.md` describes a `FileDatabase/` tree inside `DeepDrftContent/`; that tree has moved entirely to `DeepDrftContent.Services/FileDatabase/`. The DeepDrftContent host now contains only `Controllers/`, `Middleware/`, `Models/` (settings POCOs), `environment/`, `Program.cs`, `Startup.cs`.
|
||||
- `DeepDrftContent/CLAUDE.md` documents only the PUT endpoint; the production API now also has `GET api/track/{id}?offset=` (unauthenticated read, with `WavOffsetService` for offset streaming).
|
||||
- `DeepDrftWeb/CLAUDE.md` describes EF Core, repositories, services, migrations as living inside `DeepDrftWeb/Data` and `DeepDrftWeb/Services`. They have all moved to `DeepDrftWeb.Services`. The only things still in `DeepDrftWeb` are `Controllers/TrackController.cs`, `Services/DarkModeService.cs`, `Startup.cs`, `Program.cs`, `Components/`, `Interop/`, `wwwroot/`.
|
||||
- `DeepDrftWeb.Client/CLAUDE.md` lists the `Pages/` directory as containing `Counter.razor` / `Weather.razor` (demo); those are gone. The real client structure is `Pages/Home.razor` + `Pages/TracksView.razor`, plus the `Controls/AudioPlayerBar/` cluster, `Controls/AudioPlayerProvider.razor`, `Services/AudioInteropService.cs` + `AudioPlayerService.cs` + `StreamingAudioPlayerService.cs` + `IPlayerService.cs` + dark-mode services, `Common/DarkModeSettings.cs` + `Common/DDIcons.cs`, and `Layout/Pages.cs` + `Layout/DeepDrftMenu.razor`.
|
||||
- The `DeepDrftWeb.Services` and `DeepDrftContent.Services` projects have **no** `CLAUDE.md` yet — they are where most of the domain logic actually lives, so this is the biggest gap.
|
||||
- `DeepDrftCli/CLAUDE.md` references `appsettings.json`; the CLI actually loads `environment/connections.json` into `CliSettings` (with `ConnectionString` and `VaultPath`). The "Available Commands" section is otherwise current, including the `gui` Terminal.Gui mode and interactive `add`.
|
||||
- `DeepDrftContent.Services/FileDatabase/README.md` (an in-tree dev README, not a CLAUDE.md) refers to `ImageDirectoryVault`; the type is `ImageVault`. It also describes `EntryKey` as removed in favour of strings, which is accurate, but its diagram still says "FileDatabase.csproj (.NET 9.0)" — the FileDatabase no longer has its own csproj at all (it's a subdirectory of `DeepDrftContent.Services`).
|
||||
**Project-rename drift (the big one).** The two-app split renamed or removed most projects. Any folder `CLAUDE.md` still using the old names is wrong at the structural level, not just the framework-version level:
|
||||
- `DeepDrftWeb` → `DeepDrftPublic`; `DeepDrftWeb.Client` → `DeepDrftPublic.Client`; `DeepDrftWeb.Services` → `DeepDrftData`.
|
||||
- `DeepDrftContent.Services` (class library) is now just `DeepDrftContent`; the old `DeepDrftContent` *host* is gone — binary-API duties split between the `DeepDrftPublic` proxy and the `DeepDrftAPI` authority.
|
||||
- `DeepDrftCli` and the `DeepDrftCms` RCL are **deleted**. Any `CLAUDE.md` for them should be removed, not rewritten.
|
||||
|
||||
**Known content drift to correct in the sweep:**
|
||||
- Framework version: any folder `CLAUDE.md` still saying ".NET 9" / "ASP.NET Core 9.0" — reality is `net10.0` across the board.
|
||||
- `TrackEntity.MediaPath` references (notably the `FileDatabase/README.md`) — the field is `EntryKey`, column `entry_key`.
|
||||
- The `FileDatabase/README.md` refers to `ImageDirectoryVault` (the type is `ImageVault`) and a "FileDatabase.csproj (.NET 9.0)" that no longer exists (FileDatabase is a subdirectory of `DeepDrftContent`).
|
||||
- `DeepDrftData` and `DeepDrftContent` are where most domain logic lives and are the highest-value targets for accurate `CLAUDE.md` coverage.
|
||||
|
||||
**Already corrected (no longer stale):**
|
||||
- `DeepDrftPublic.Client/CLAUDE.md` was rewritten in commit `9110b4b` and reflects the current player stack, `PlaybackIcons`/`PlayStateIcon`, and the post-split structure.
|
||||
|
||||
> **`DOC_PLAN.md` caveat.** `DOC_PLAN.md` predates the two-app split — its per-folder briefs reference `DeepDrftWeb*`, `DeepDrftCli`, and a SQLite backend (now PostgreSQL). Treat its *intent* (lead-with-truth, cross-reference root, no docs for build output) as still valid, but its *project list and per-folder details* need reconciling against the current ten-project solution before doc-keeper executes against it. Flag to Daniel whether to refresh `DOC_PLAN.md` first or let doc-keeper work from the root `CLAUDE.md` directly.
|
||||
|
||||
+192
-26
@@ -6,13 +6,15 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
|
||||
|
||||
## One-line purpose
|
||||
|
||||
Dual-database authority for tracks (SQL metadata + FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. ApiKey middleware for track endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
|
||||
Dual-database authority for tracks (SQL metadata + FileDatabase binary), releases (SQL metadata with media-specific satellites), and images (FileDatabase binary); AuthBlocks API host (JWT auth, role/admin seed). Track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing with filters, metadata operations, waveform profiles (512-bucket player-bar seeker + per-track high-res visualizer datum), and release associations. Release endpoints provide paged listing with medium filter, single-release read, and media-specific operations (session hero-image upload; mix waveform is a caller-less legacy delegate — the track-cardinal `GET api/track/{entryKey}/waveform/high-res` is the live fetch path). Image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for authenticated endpoints, JWT + AuthBlocks for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
|
||||
|
||||
## What lives here now (only)
|
||||
|
||||
- `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, middleware setup, port binding. AuthBlocks startup: `AddAuthBlocks`, `UseAuthBlocksStartupAsync`, `MapAuthBlocks`, authentication/authorization middleware.
|
||||
- `Services/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`).
|
||||
- `Controllers/TrackController.cs`: Seven track endpoints (see below).
|
||||
- `Services/UnifiedReleaseService.cs`: Host-internal orchestrator. Coordinates release mutations (mix waveform compute + store, session hero-image upload + link).
|
||||
- `Controllers/TrackController.cs`: Track endpoints (see below).
|
||||
- `Controllers/ReleaseController.cs`: Release endpoints (see below).
|
||||
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only).
|
||||
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
|
||||
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
|
||||
@@ -22,21 +24,100 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), and Aut
|
||||
|
||||
## What does NOT live here anymore
|
||||
|
||||
- `FileDatabase/`, `Processors/`, media models (`AudioBinary`, `ImageBinary`, etc.), `WavOffsetService` — all in `DeepDrftContent` (class library).
|
||||
- `FileDatabase/`, `Processors/`, media models (`AudioBinary`, `ImageBinary`, etc.) — all in `DeepDrftContent` (class library).
|
||||
- EF Core context and repository — in `DeepDrftData`.
|
||||
- **Hosts only own HTTP surface and wiring.** New domain code goes in `*.Services` (shared libraries) or host-internal `Services/` folders (e.g., `UnifiedTrackService` here for dual-database orchestration).
|
||||
|
||||
## The endpoint surface (seven endpoints)
|
||||
## The endpoint surface
|
||||
|
||||
### GET api/track/{trackId}?offset=0 (unauthenticated)
|
||||
### GET api/track/{trackId} (unauthenticated)
|
||||
|
||||
Returns the WAV bytes from the `tracks` vault with optional offset support.
|
||||
Returns the WAV bytes from the `tracks` vault with HTTP Range support.
|
||||
|
||||
- **Route parameter `trackId`** (string): the entry id inside the `tracks` vault (i.e. `TrackEntity.EntryKey`).
|
||||
- **Query parameter `offset`** (optional, default 0): byte position to start streaming from.
|
||||
- If `offset == 0`: streams the entire file directly from disk without buffering (so 100 MB WAVs do not force 100 MB LOH allocations per request).
|
||||
- If `offset > 0`: `WavOffsetService.CreateOffsetStream` block-aligns the offset and synthesises a fresh 44-byte WAV header so the response is a valid standalone WAV starting from that byte position. This is load-bearing for seek-beyond-buffer — the player asks for a new stream at the offset it wants to seek to, gets back a valid WAV that starts there, and tears down/re-initialises the decoder.
|
||||
- Returns 404 if track not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
|
||||
- **Range header** (optional): HTTP Range header for byte-range requests (e.g., `Range: bytes=1000-`). Server responds with `206 Partial Content` and streams from the requested offset.
|
||||
- Streams the file directly from disk with `enableRangeProcessing: true`, supporting both full-file and partial-range requests without synthesizing WAV headers or buffering.
|
||||
- Returns 200 for full-file requests, 206 for Range requests, 404 if track not found, 500 if vault operations fail (with error swallowing — the vault returns `null`).
|
||||
|
||||
### GET api/track/albums (unauthenticated)
|
||||
|
||||
Returns a list of all releases with per-release track counts. Public browse data, same auth posture as `GET api/track/page`.
|
||||
|
||||
- **Response**: `List<ReleaseDto>` where each release carries its title, artist, genre, release date, medium, and track count.
|
||||
- Returns 200 with the release list on success. Returns 500 on query error.
|
||||
|
||||
### GET api/track/genres (unauthenticated)
|
||||
|
||||
Returns distinct non-null genres with per-genre track counts. Public browse data, same auth posture as `GET api/track/page`.
|
||||
|
||||
- **Response**: A collection of genre strings with track counts.
|
||||
- Returns 200 on success. Returns 500 on query error.
|
||||
|
||||
### GET api/track/random (unauthenticated)
|
||||
|
||||
Picks one track at random from the full library and returns its metadata. Public, same auth posture as `GET api/track/page`.
|
||||
|
||||
- **Response**: A single `TrackDto` selected uniformly at random.
|
||||
- Returns 200 on success. Returns 404 if the library is empty (a valid state). Returns 500 on query error.
|
||||
|
||||
### GET api/track/{trackId}/waveform (unauthenticated)
|
||||
|
||||
Returns the stored waveform loudness profile for a track as base64-encoded bytes. Public listener data, same auth posture as `GET api/track/{trackId}`.
|
||||
|
||||
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
|
||||
- **Response**: `WaveformProfileDto` with `BucketCount` (number of loudness buckets) and `Data` (base64-encoded byte array).
|
||||
- Returns 200 on success. Returns 404 if no profile is stored (existing tracks may predate profiling, or computation failed at upload — the frontend falls back to a flat seekbar). Returns 500 on vault error.
|
||||
|
||||
### POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
|
||||
|
||||
Admin backfill: computes and stores a waveform profile for an existing track from its vault audio.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
|
||||
- Fetches audio from vault, decodes it, computes a loudness profile, and stores the profile in the `waveform-profiles` vault.
|
||||
- Returns 200 on success. Returns 404 if no audio is stored under that key. Returns 500 if WAV decoding or vault write fails.
|
||||
|
||||
### GET api/track/{trackId}/waveform/high-res (unauthenticated)
|
||||
|
||||
Track-cardinal high-res datum fetch. Returns the per-track duration-derived high-res waveform datum (~333 samples/sec) from the `track-waveforms` vault. This is the live read path for the `WaveformVisualizer` bridge — the release-level mix waveform endpoint is a caller-less legacy delegate.
|
||||
|
||||
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
|
||||
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
|
||||
- Returns 200 on success. Returns 404 if no high-res datum is stored (graceful — not-yet-backfilled tracks fall back to no visualizer data). Returns 500 on vault error.
|
||||
|
||||
### POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
|
||||
|
||||
Server-side trigger: compute and store the per-track high-res datum for any track from its vault audio, keyed by `EntryKey` in the `track-waveforms` vault. Drives the CMS per-row "Generate high-res" action and the CMS batch backfill action. Generalised off Mix-only in Phase 12.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
|
||||
- Calls `WaveformProfileService.ComputeAndStoreHighResAsync` via `UnifiedTrackService`.
|
||||
- Returns 200 on success. Returns 404 if no audio stored under that key. Returns 500 on compute/storage failure.
|
||||
|
||||
### GET api/track/meta/by-key/{entryKey} (unauthenticated)
|
||||
|
||||
Single track metadata by vault entry key (EntryKey). Unauthenticated, reachable through the public proxy.
|
||||
|
||||
- **Route parameter `entryKey`** (string): the TrackEntity.EntryKey.
|
||||
- **Response**: `TrackDto` for the matching track.
|
||||
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
|
||||
|
||||
### GET api/track/waveform-status ([ApiKeyAuthorize])
|
||||
|
||||
Admin backfill view: returns every track with flags indicating whether each waveform type is stored. Used by the CMS track list to flag tracks needing waveform computation.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, `HasProfile` (bool — 512-bucket player-bar seeker profile in `waveform-profiles` vault), and `HasHighRes` (bool — duration-derived high-res visualizer datum in `track-waveforms` vault).
|
||||
- Returns 200 on success. Returns 500 on query error.
|
||||
|
||||
### DELETE api/track/release/{id:long} ([ApiKeyAuthorize])
|
||||
|
||||
Soft-delete a release row. Used by the albums browser to remove an orphaned release (one with no live tracks).
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL release ID.
|
||||
- Calls `ITrackService.DeleteRelease`.
|
||||
- Returns 200 on success. Returns 500 on deletion error.
|
||||
|
||||
### PUT api/track/{trackId} ([ApiKeyAuthorize])
|
||||
|
||||
@@ -51,21 +132,24 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
|
||||
|
||||
### POST api/track/upload ([ApiKeyAuthorize])
|
||||
|
||||
**Authenticated endpoint.** Accepts a raw WAV upload + metadata as `multipart/form-data`, processes the WAV, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackDto` with `Id` populated.
|
||||
**Authenticated endpoint.** Accepts a raw audio file upload (.wav, .mp3, .flac) + metadata as `multipart/form-data`, processes the file, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackDto` with `Id` populated.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Form fields**:
|
||||
- `wav` (`IFormFile`, required): the WAV bytes. File name must end in `.wav`.
|
||||
- `audioFile` (`IFormFile`, required): the audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
|
||||
- `trackName` (string, required)
|
||||
- `artist` (string, required)
|
||||
- `album` (string, optional)
|
||||
- `genre` (string, optional)
|
||||
- `releaseDate` (string, optional, format `YYYY-MM-DD`)
|
||||
- `createdByUserId` (long, required): audit trail — who uploaded this track.
|
||||
- The upload stream is copied to a `.wav`-suffixed temp file under `Path.GetTempPath()` (the audio processor requires that extension and reads from disk). The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized WAVs are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackService.AddTrackFromWavAsync` (vault write) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields. Returns 500 if processing fails.
|
||||
- `releaseType` (string, optional): enum `ReleaseType` (e.g., `Single`, `Album`, `EP`). Defaults to `Single` if null or unrecognized.
|
||||
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
|
||||
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
|
||||
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails.
|
||||
|
||||
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
|
||||
|
||||
@@ -76,17 +160,20 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
|
||||
- Calls `UnifiedTrackService.DeleteAsync`, which: looks up SQL row → deletes SQL row → deletes vault entry via EntryKey.
|
||||
- Returns 200 on success, 404 if track not found, 500 if deletion fails.
|
||||
|
||||
### GET api/track/page ([ApiKeyAuthorize])
|
||||
### GET api/track/page (unauthenticated)
|
||||
|
||||
**Authenticated endpoint.** Paged metadata list from SQL. Used by CMS track browser.
|
||||
Paged metadata list from SQL with optional filtering. Public browser data, same auth posture as `GET api/track/{id}`.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Query parameters**:
|
||||
- `page` (int, optional, default 1): 1-based page number.
|
||||
- `pageSize` (int, optional, default 20): tracks per page.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`, `"TrackNumber"`. Defaults to `Id`.
|
||||
- `sortDescending` (bool, optional, default false): sort direction.
|
||||
- Calls `ITrackService.GetPaged` (via DI), which is actually `TrackManager` from `DeepDrftData`.
|
||||
- `q` (string, optional): search text filter (matches track name / artist).
|
||||
- `album` (string, optional): album title filter.
|
||||
- `genre` (string, optional): genre filter.
|
||||
- `releaseId` (long?, optional): release ID filter (authoritative join; preferred over album title).
|
||||
- Calls `ITrackService.GetPaged` with optional `TrackFilter` (null if all filter params are empty).
|
||||
- Returns 200 with `PagedResult<TrackDto>` JSON (`Items`, `TotalCount`, `PageNumber`, `PageSize`). Returns 500 on query error.
|
||||
|
||||
### GET api/track/meta/{id:long} ([ApiKeyAuthorize])
|
||||
@@ -104,9 +191,86 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID.
|
||||
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`.
|
||||
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request clear optional fields), and persists the DTO via `ITrackService.Update`.
|
||||
- Returns 200 with the updated `TrackDto` on success. Returns 404 if track not found. Returns 500 on update error.
|
||||
- **Body**: `UpdateTrackMetadataRequest` with fields:
|
||||
- `TrackName` (string, required)
|
||||
- `Artist` (string, required)
|
||||
- `Album` (string?, optional)
|
||||
- `Genre` (string?, optional)
|
||||
- `ReleaseDate` (DateOnly?, optional)
|
||||
- `ImagePath` (string?, tri-state: null = no change, "" = clear, value = set)
|
||||
- `ReleaseType` (ReleaseType?, optional): updates the linked release if present; null = no change.
|
||||
- `Medium` (ReleaseMedium?, optional): updates the linked release if present; null = no change. When `Medium` is set to non-`Cut`, also resets `ReleaseType` to `Single` (the DB default) to avoid stale studio-format values.
|
||||
- `TrackNumber` (int?, optional): track position within the release; validated > 0 when provided.
|
||||
- Looks up SQL row by ID, updates the provided fields, and persists via `ITrackService.Update`. Track-cardinal fields (`TrackName`, `TrackNumber`) update the track row; release-cardinal fields (`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`, `ReleaseType`, `Medium`) update the linked release (if present; loose tracks ignore these).
|
||||
- Returns 200 on success. Returns 400 if `TrackNumber` ≤ 0 (when provided). Returns 404 if track not found. Returns 500 on update error.
|
||||
|
||||
## The image endpoints (two endpoints)
|
||||
|
||||
### POST api/image/upload ([ApiKeyAuthorize])
|
||||
|
||||
**Authenticated endpoint.** Accepts an image file upload, stores it in the `images` vault, and returns the entry key.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Form field `image`** (`IFormFile`, required): the image bytes (PNG, JPEG, or other format supported by `ImageProcessor`). Maximum file size 50 MB.
|
||||
- Calls `FileDatabase.RegisterResourceAsync("images", entryKey, imageBinary)` where `imageBinary` is produced by `ImageProcessor` (computes aspect ratio from headers, defaults 1.0 for unsupported formats).
|
||||
- Returns 200 with JSON `{ entryKey }` on success. Returns 400 for missing file. Returns 500 if processing or vault operations fail.
|
||||
|
||||
### GET api/image/{entryKey} (unauthenticated)
|
||||
|
||||
Returns image bytes from the `images` vault.
|
||||
|
||||
- **Route parameter `entryKey`** (string): the entry id inside the `images` vault.
|
||||
- Streams the image file directly from disk without buffering.
|
||||
- Returns 404 if image not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
|
||||
|
||||
## The release endpoints
|
||||
|
||||
### GET api/release (unauthenticated)
|
||||
|
||||
Paged release list, optionally filtered to one medium. Public browse data, same auth posture as `GET api/track/page`.
|
||||
|
||||
- **Query parameters**:
|
||||
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). If provided, only releases of that medium are returned; the matching medium's metadata satellite is populated, others are null.
|
||||
- `page` (int, optional, default 1): 1-based page number.
|
||||
- `pageSize` (int, optional, default 20): releases per page.
|
||||
- `sortColumn` (string, optional): sort field (typically `"Title"`).
|
||||
- `sortDescending` (bool, optional, default false): sort direction.
|
||||
- Returns 200 with `PagedResult<ReleaseDto>` on success. Returns 400 if `medium` is unrecognized. Returns 500 on query error.
|
||||
|
||||
### GET api/release/{entryKey} (unauthenticated)
|
||||
|
||||
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`. Addresses releases by their opaque public `EntryKey` (GUID string), never the int PK (Phase 11 §3e).
|
||||
|
||||
- **Route parameter `entryKey`** (string): the release's `EntryKey` (the public handle).
|
||||
- **Response**: `ReleaseDto` with `Id`, `EntryKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
|
||||
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
|
||||
|
||||
### GET api/release/{entryKey}/mix/waveform (unauthenticated — caller-less legacy delegate)
|
||||
|
||||
Legacy endpoint: formerly served the high-res waveform datum for a Mix release from the `mix-waveforms` vault. **No longer called by the client** — the live fetch path is now the track-cardinal `GET api/track/{trackId}/waveform/high-res` (Phase 12). The endpoint is retained in the API but has no active callers. `UnifiedReleaseService.TriggerMixWaveformAsync` now delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` (the same shared seam used by the upload path and the generalized CMS generate action).
|
||||
|
||||
- **Route parameter `entryKey`** (string): the release's `EntryKey`.
|
||||
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
|
||||
- Returns 200 on success. Returns 404 if the release is not a Mix, carries no waveform key, or no datum is stored. Returns 500 on query/vault error.
|
||||
|
||||
### POST api/release/{id:long}/mix/waveform ([ApiKeyAuthorize])
|
||||
|
||||
Server-side trigger: fetch the Mix's track audio from the vault, compute the duration-derived high-res datum, store it in the `track-waveforms` vault under the track's `EntryKey`, and link it via `MixMetadata.WaveformEntryKey`. Delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` — the same shared seam used by the upload path and the generalized CMS generate action. No request body.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL release ID.
|
||||
- Calls `UnifiedReleaseService.TriggerMixWaveformAsync`.
|
||||
- Returns 200 on success. Returns 404 if the release is missing, is not a Mix, has no track, or the track audio is not stored. Returns 500 on compute/storage failure.
|
||||
|
||||
### POST api/release/{id:long}/session/hero-image ([ApiKeyAuthorize])
|
||||
|
||||
Stores a hero image in the `images` vault and links it via `SessionMetadata.HeroImageEntryKey`. The release must be a Session medium (enforced in the service).
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL release ID.
|
||||
- **Form field `image`** (`IFormFile`, required): the image bytes (PNG, JPEG, or other format supported by `ImageProcessor`). Maximum file size 50 MB.
|
||||
- Validates MIME type (rejects unsupported types with `.bin` sentinel). Calls `UnifiedReleaseService.SetHeroImageAsync`.
|
||||
- Returns 200 on success. Returns 400 for missing file or unsupported MIME type. Returns 404 if release not found. Returns 500 on processing or vault failure.
|
||||
|
||||
## ApiKey middleware behaviour
|
||||
|
||||
@@ -141,7 +305,9 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
|
||||
2. Await `FileDatabase.FromAsync(VaultPath)` to load or create the database.
|
||||
3. Register `FileDatabase` as singleton.
|
||||
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
|
||||
5. Register singletons: `WavOffsetService`, `AudioProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
|
||||
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
|
||||
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
|
||||
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
|
||||
|
||||
**In `Program.cs`** (SQL + AuthBlocks + wiring):
|
||||
|
||||
@@ -232,7 +398,7 @@ dotnet build DeepDrftAPI
|
||||
curl -H "ApiKey: your-secret-key" -X GET https://localhost:5002/api/track/page \
|
||||
-H "Accept: application/json"
|
||||
|
||||
curl https://localhost:5002/api/track/test-entry-key?offset=0
|
||||
curl https://localhost:5002/api/track/test-entry-key
|
||||
|
||||
# Test auth endpoints (AuthBlocks API)
|
||||
curl -X POST https://localhost:5002/api/auth/login \
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.Processors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ImageController : ControllerBase
|
||||
{
|
||||
// 50 MB ceiling — cover art is small, but this is generous headroom for high-res masters.
|
||||
private const int MaxImageBytes = 50_000_000;
|
||||
|
||||
// FileDatabase is injected directly because image operations are vault-only: there is no
|
||||
// SQL row for an image. The link to a track is TrackEntity.ImagePath (the entry key),
|
||||
// written separately via PUT api/track/meta/{id}.
|
||||
private readonly FileDatabase _fileDatabase;
|
||||
private readonly ImageProcessor _imageProcessor;
|
||||
private readonly ILogger<ImageController> _logger;
|
||||
|
||||
public ImageController(
|
||||
FileDatabase fileDatabase,
|
||||
ImageProcessor imageProcessor,
|
||||
ILogger<ImageController> logger)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_imageProcessor = imageProcessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// POST api/image/upload ([ApiKeyAuthorize])
|
||||
// Stores a cover-art image in the images vault and returns its generated entry key. Images
|
||||
// are small enough to buffer whole in memory — no temp-file dance like the WAV upload path.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(MaxImageBytes)]
|
||||
public async Task<ActionResult> UploadImage([FromForm] IFormFile? image, CancellationToken cancellationToken)
|
||||
{
|
||||
if (image is null || image.Length == 0)
|
||||
{
|
||||
return BadRequest("Image file is required");
|
||||
}
|
||||
|
||||
if (image.Length > MaxImageBytes)
|
||||
{
|
||||
return BadRequest($"Image exceeds the {MaxImageBytes} byte limit");
|
||||
}
|
||||
|
||||
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
|
||||
{
|
||||
_logger.LogWarning("UploadImage rejected: unsupported content type '{ContentType}'", image.ContentType);
|
||||
return BadRequest($"Unsupported image content type: {image.ContentType}");
|
||||
}
|
||||
|
||||
byte[] buffer;
|
||||
await using (var stream = image.OpenReadStream())
|
||||
using (var memory = new MemoryStream())
|
||||
{
|
||||
await stream.CopyToAsync(memory, cancellationToken);
|
||||
buffer = memory.ToArray();
|
||||
}
|
||||
|
||||
var imageBinary = _imageProcessor.Process(buffer, image.ContentType);
|
||||
if (imageBinary is null)
|
||||
{
|
||||
// Process only returns null for an unsupported content type, already screened above —
|
||||
// belt-and-suspenders in case ImageProcessor's validation diverges later.
|
||||
_logger.LogWarning("UploadImage: ImageProcessor rejected content type '{ContentType}'", image.ContentType);
|
||||
return BadRequest($"Unsupported image content type: {image.ContentType}");
|
||||
}
|
||||
|
||||
var entryKey = Guid.NewGuid().ToString("N");
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("UploadImage: vault write failed for entryKey={EntryKey}, contentType={ContentType}, size={Size}",
|
||||
entryKey, image.ContentType, buffer.Length);
|
||||
return StatusCode(500, "Failed to store image");
|
||||
}
|
||||
|
||||
_logger.LogInformation("UploadImage succeeded: entryKey={EntryKey}, contentType={ContentType}, size={Size}",
|
||||
entryKey, image.ContentType, buffer.Length);
|
||||
return Ok(new { entryKey });
|
||||
}
|
||||
|
||||
// GET api/image/{entryKey} (unauthenticated)
|
||||
// Streams the image whole from disk. Same disk-streaming pattern as GET api/track/{trackId}
|
||||
// offset-0 path: File() takes ownership of the inner stream on the success path; the wrapper
|
||||
// is disposed only on the catch path.
|
||||
[HttpGet("{entryKey}")]
|
||||
public async Task<ActionResult> GetImage(string entryKey)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Images);
|
||||
if (vault is null)
|
||||
{
|
||||
_logger.LogWarning("Images vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mediaStream = await vault.GetEntryStreamAsync(entryKey);
|
||||
if (mediaStream is null)
|
||||
{
|
||||
_logger.LogWarning("Image not found: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
string mimeType;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
mimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return File(innerStream, mimeType, enableRangeProcessing: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ReleaseController : ControllerBase
|
||||
{
|
||||
private readonly IReleaseService _releaseService;
|
||||
private readonly UnifiedReleaseService _unifiedReleaseService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly ILogger<ReleaseController> _logger;
|
||||
|
||||
public ReleaseController(
|
||||
IReleaseService releaseService,
|
||||
UnifiedReleaseService unifiedReleaseService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
ILogger<ReleaseController> logger)
|
||||
{
|
||||
_releaseService = releaseService;
|
||||
_unifiedReleaseService = unifiedReleaseService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// GET api/release?medium=session&q=text&genre=House&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth)
|
||||
// Paged release list, optionally narrowed by medium, free-text search (q), and genre. The matching
|
||||
// medium's metadata satellite is populated; the others are null. Backs the public /archive browser.
|
||||
// Public browse data, same auth posture as GET api/track/page.
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> GetReleases(
|
||||
[FromQuery] string? medium = null,
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? genre = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? sortColumn = null,
|
||||
[FromQuery] bool sortDescending = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ReleaseMedium? parsedMedium = null;
|
||||
if (!string.IsNullOrWhiteSpace(medium))
|
||||
{
|
||||
if (!Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var m) || !Enum.IsDefined(m))
|
||||
return BadRequest($"Unrecognised medium: {medium}");
|
||||
parsedMedium = m;
|
||||
}
|
||||
|
||||
var filter = new ReleaseFilter { SearchText = q, Genre = genre };
|
||||
var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, filter, ct);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetReleases failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load releases");
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/release/{entryKey}/mix/waveform (unauthenticated)
|
||||
// Serves the high-res waveform datum for a Mix release as base64, reading the Mix's track datum from
|
||||
// the track-waveforms vault. 404 when the release is not a Mix, carries no waveform key, or no datum
|
||||
// is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The {entryKey}
|
||||
// string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different verb +
|
||||
// constraint). Declared before the shorter "{entryKey}" route for clarity.
|
||||
//
|
||||
// LEGACY (phase-12 §5b): the visualizer no longer fetches through this release-addressed route — it
|
||||
// resolves the current track's datum via the track-cardinal GET api/track/{trackEntryKey}/waveform/
|
||||
// high-res. This endpoint is retained as a thin transitional delegate (it serves the identical datum,
|
||||
// since a Mix is single-track) and has no client caller today; remove it once nothing depends on the
|
||||
// release-addressed shape.
|
||||
[HttpGet("{entryKey}/mix/waveform")]
|
||||
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetMixWaveform lookup failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return StatusCode(500, "Failed to load release");
|
||||
}
|
||||
|
||||
var release = lookup.Value;
|
||||
var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey;
|
||||
if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey))
|
||||
{
|
||||
_logger.LogInformation("No mix waveform datum for release: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(new WaveformProfileDto
|
||||
{
|
||||
BucketCount = bytes.Length,
|
||||
Data = Convert.ToBase64String(bytes),
|
||||
});
|
||||
}
|
||||
|
||||
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
|
||||
// Server-side trigger: fetch the Mix's track audio from the vault, compute a duration-derived high-res
|
||||
// waveform via ComputeAndStoreHighResAsync, store it in the track-waveforms vault, and set
|
||||
// MixMetadata.WaveformEntryKey. 404 when the release is missing or has no stored audio; 500 on
|
||||
// compute/storage failure. Declared before "{id:long}".
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{id:long}/mix/waveform")]
|
||||
public async Task<ActionResult> GenerateMixWaveform(long id, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _unifiedReleaseService.TriggerMixWaveformAsync(id, ct);
|
||||
if (result.Success)
|
||||
return Ok();
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal)
|
||||
|| string.Equals(error, UnifiedReleaseService.MixTrackNoAudioMessage, StringComparison.Ordinal)
|
||||
|| string.Equals(error, UnifiedReleaseService.MixHasNoTrackMessage, StringComparison.Ordinal))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_logger.LogError("GenerateMixWaveform failed for {ReleaseId}: {Error}", id, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// POST api/release/{id}/session/hero-image ([ApiKeyAuthorize], multipart)
|
||||
// Stores a hero image in the images vault and sets SessionMetadata.HeroImageEntryKey. The release
|
||||
// must be a Session medium (enforced in the service). Declared before "{id:long}".
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{id:long}/session/hero-image")]
|
||||
[RequestSizeLimit(50_000_000)]
|
||||
public async Task<ActionResult> UploadSessionHeroImage(
|
||||
long id,
|
||||
[FromForm] IFormFile? image,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (image is null || image.Length == 0)
|
||||
return BadRequest("Image file is required");
|
||||
|
||||
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
|
||||
{
|
||||
_logger.LogWarning("UploadSessionHeroImage rejected: unsupported content type '{ContentType}'", image.ContentType);
|
||||
return BadRequest($"Unsupported image content type: {image.ContentType}");
|
||||
}
|
||||
|
||||
var result = await _unifiedReleaseService.SetHeroImageAsync(id, image, ct);
|
||||
if (result.Success)
|
||||
return Ok();
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
|
||||
_logger.LogError("UploadSessionHeroImage failed for {ReleaseId}: {Error}", id, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// GET api/release/{entryKey} (unauthenticated)
|
||||
// Single release with both metadata navs (nulls for non-matching media). Public read — addresses by
|
||||
// the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform"
|
||||
// route so the segmented route resolves first.
|
||||
[HttpGet("{entryKey}")]
|
||||
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetReleaseByEntryKey failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return StatusCode(500, "Failed to load release");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent.Audio;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
@@ -15,9 +17,9 @@ namespace DeepDrftAPI.Controllers;
|
||||
public class TrackController : ControllerBase
|
||||
{
|
||||
private readonly DeepDrftContent.TrackContentService _trackContentService;
|
||||
private readonly WavOffsetService _wavOffsetService;
|
||||
private readonly UnifiedTrackService _unifiedService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly ILogger<TrackController> _logger;
|
||||
|
||||
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
|
||||
@@ -29,16 +31,16 @@ public class TrackController : ControllerBase
|
||||
public TrackController(
|
||||
DeepDrftContent.TrackContentService trackContentService,
|
||||
DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase,
|
||||
WavOffsetService wavOffsetService,
|
||||
UnifiedTrackService unifiedService,
|
||||
ITrackService sqlTrackService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
ILogger<TrackController> logger)
|
||||
{
|
||||
_trackContentService = trackContentService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_wavOffsetService = wavOffsetService;
|
||||
_unifiedService = unifiedService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,17 +48,26 @@ public class TrackController : ControllerBase
|
||||
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
|
||||
// resolution never treats "page", "upload", or "meta" as a trackId.
|
||||
|
||||
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false
|
||||
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=&releaseId=
|
||||
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
|
||||
// q/album/genre/releaseId build an optional TrackFilter; all null → null passthrough (no filtering).
|
||||
// releaseId is the authoritative release→tracks join (exact match), preferred over album title.
|
||||
[HttpGet("page")]
|
||||
public async Task<ActionResult> GetPage(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? sortColumn = null,
|
||||
[FromQuery] bool sortDescending = false,
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? album = null,
|
||||
[FromQuery] string? genre = null,
|
||||
[FromQuery] long? releaseId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, cancellationToken);
|
||||
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre, ReleaseId = releaseId };
|
||||
var effectiveFilter = filter.IsEmpty ? null : filter;
|
||||
|
||||
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
@@ -67,11 +78,107 @@ public class TrackController : ControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||
// Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never
|
||||
// touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write.
|
||||
// GET api/track/albums (unauthenticated)
|
||||
// All releases with per-release track counts. Public browse data, same posture as GET
|
||||
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
||||
// Route name kept as "albums" for client/proxy compatibility; the payload is List<ReleaseDto>.
|
||||
[HttpGet("albums")]
|
||||
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetReleases(ct);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetAlbums failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load albums");
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/genres (unauthenticated)
|
||||
// Distinct non-null genres with track counts. Public browse data, same posture as GET
|
||||
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
||||
[HttpGet("genres")]
|
||||
public async Task<ActionResult> GetGenres(CancellationToken ct = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetDistinctGenres(ct);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetGenres failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load genres");
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/random (unauthenticated)
|
||||
// Picks one track at random from the full library and returns its metadata. Public, same auth
|
||||
// posture as GET api/track/page. Selection math lives in the SQL service/repository, not here.
|
||||
// 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 +
|
||||
// TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there.
|
||||
[HttpGet("random")]
|
||||
public async Task<ActionResult> GetRandom(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetRandom(cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetRandom failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load track");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/waveform-status ([ApiKeyAuthorize])
|
||||
// Admin backfill view: returns every track with flags for whether each waveform datum is stored —
|
||||
// the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
|
||||
// datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
|
||||
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
|
||||
// segment is never treated as a trackId.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpGet("waveform-status")]
|
||||
public async Task<ActionResult> GetWaveformStatus()
|
||||
{
|
||||
var tracks = await _sqlTrackService.GetAll();
|
||||
if (!tracks.Success || tracks.Value is null)
|
||||
{
|
||||
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetWaveformStatus failed to load tracks: {Error}", error);
|
||||
return StatusCode(500, "Failed to load tracks");
|
||||
}
|
||||
|
||||
var status = new List<WaveformStatusDto>(tracks.Value.Count);
|
||||
foreach (var track in tracks.Value)
|
||||
{
|
||||
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
|
||||
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
|
||||
status.Add(new WaveformStatusDto
|
||||
{
|
||||
TrackId = track.Id,
|
||||
EntryKey = track.EntryKey,
|
||||
TrackName = track.TrackName,
|
||||
HasProfile = profile is not null,
|
||||
HasHighRes = highRes is not null,
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
|
||||
// proxies the upload here so it never touches the vault disk path or SQL directly.
|
||||
// UnifiedTrackService owns the two-database write.
|
||||
//
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds
|
||||
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
|
||||
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
|
||||
// not a buffered allocation.
|
||||
@@ -80,21 +187,26 @@ public class TrackController : ControllerBase
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
|
||||
[FromForm] IFormFile? wav,
|
||||
[FromForm] IFormFile? audioFile,
|
||||
[FromForm] string? trackName,
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
[FromForm] string? genre,
|
||||
[FromForm] string? description,
|
||||
[FromForm] string? releaseDate,
|
||||
[FromForm] string? originalFileName,
|
||||
[FromForm] long createdByUserId,
|
||||
[FromForm] string? releaseType,
|
||||
[FromForm] string? medium,
|
||||
[FromForm] int? trackNumber,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}",
|
||||
trackName, artist, wav?.Length);
|
||||
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
|
||||
trackName, artist, originalFileName, audioFile?.Length);
|
||||
|
||||
if (wav is null || wav.Length == 0)
|
||||
if (audioFile is null || audioFile.Length == 0)
|
||||
{
|
||||
return BadRequest("WAV file is required");
|
||||
return BadRequest("Audio file is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trackName))
|
||||
@@ -107,9 +219,10 @@ public class TrackController : ControllerBase
|
||||
return BadRequest("artist is required");
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase))
|
||||
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
|
||||
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
|
||||
{
|
||||
return BadRequest("Uploaded file must have a .wav extension");
|
||||
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
|
||||
}
|
||||
|
||||
DateOnly? parsedReleaseDate = null;
|
||||
@@ -122,16 +235,48 @@ public class TrackController : ControllerBase
|
||||
parsedReleaseDate = parsed;
|
||||
}
|
||||
|
||||
// AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk.
|
||||
// Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav");
|
||||
// Default to Single for null/unparseable release type; default track number to a valid 1-based value.
|
||||
ReleaseType parsedReleaseType;
|
||||
if (!string.IsNullOrWhiteSpace(releaseType)
|
||||
&& Enum.TryParse<ReleaseType>(releaseType, ignoreCase: true, out var rt)
|
||||
&& Enum.IsDefined(rt))
|
||||
{
|
||||
parsedReleaseType = rt;
|
||||
}
|
||||
else
|
||||
{
|
||||
parsedReleaseType = ReleaseType.Single;
|
||||
if (!string.IsNullOrWhiteSpace(releaseType))
|
||||
_logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
|
||||
}
|
||||
// Default to Cut for null/unparseable medium, mirroring the releaseType defensive parse above.
|
||||
ReleaseMedium parsedMedium;
|
||||
if (!string.IsNullOrWhiteSpace(medium)
|
||||
&& Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var rm)
|
||||
&& Enum.IsDefined(rm))
|
||||
{
|
||||
parsedMedium = rm;
|
||||
}
|
||||
else
|
||||
{
|
||||
parsedMedium = ReleaseMedium.Cut;
|
||||
if (!string.IsNullOrWhiteSpace(medium))
|
||||
_logger.LogWarning("UploadTrack: unrecognised medium value '{Value}', defaulting to Cut", medium);
|
||||
}
|
||||
|
||||
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
|
||||
|
||||
// The processor router selects by extension and reads from disk, so the temp file must carry
|
||||
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
|
||||
// generate our own path preserving the validated .wav/.mp3/.flac extension.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
await using (var uploadStream = wav.OpenReadStream())
|
||||
await using (var uploadStream = audioFile.OpenReadStream())
|
||||
{
|
||||
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
||||
}
|
||||
@@ -142,14 +287,28 @@ public class TrackController : ControllerBase
|
||||
artist,
|
||||
string.IsNullOrWhiteSpace(album) ? null : album,
|
||||
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
||||
string.IsNullOrWhiteSpace(description) ? null : description,
|
||||
parsedReleaseDate,
|
||||
createdByUserId,
|
||||
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
||||
parsedReleaseType,
|
||||
parsedMedium,
|
||||
resolvedTrackNumber,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV";
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
|
||||
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
|
||||
|
||||
// A cardinality rejection is a well-formed request that violates a domain rule, so it
|
||||
// is 409 Conflict — distinct from the 500 used for processing failure. The marker is
|
||||
// stripped so the client sees only the human-readable detail.
|
||||
if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
|
||||
{
|
||||
return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
|
||||
}
|
||||
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
@@ -198,6 +357,28 @@ public class TrackController : ControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/meta/by-key/{entryKey}: single track metadata by vault entry key.
|
||||
// Unauthenticated, like GET api/track/page and GET api/track/{id} — reachable through the
|
||||
// public proxy. 3-segment route, so no collision with meta/{id:long} or {trackId}.
|
||||
[HttpGet("meta/by-key/{entryKey}")]
|
||||
public async Task<ActionResult> GetMetaByKey(string entryKey)
|
||||
{
|
||||
var result = await _sqlTrackService.GetByEntryKey(entryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetMetaByKey failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return StatusCode(500, "Failed to load track");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("meta/{id:long}")]
|
||||
@@ -216,12 +397,51 @@ public class TrackController : ControllerBase
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (request.TrackNumber is <= 0)
|
||||
return BadRequest("trackNumber must be a positive integer when provided.");
|
||||
|
||||
var track = lookup.Value;
|
||||
|
||||
// Track-cardinal fields update the track row directly.
|
||||
track.TrackName = request.TrackName;
|
||||
track.Artist = request.Artist;
|
||||
track.Album = request.Album;
|
||||
track.Genre = request.Genre;
|
||||
track.ReleaseDate = request.ReleaseDate;
|
||||
if (request.TrackNumber is > 0)
|
||||
track.TrackNumber = request.TrackNumber.Value;
|
||||
|
||||
// Release-cardinal fields update the linked release (handled in TrackManager.Update, which
|
||||
// persists track.Release when the track carries a resolved ReleaseId). The loaded track has
|
||||
// its Release populated via the Include; mutate it in place so the edited values flow through.
|
||||
// A loose track (no release) cannot take release-cardinal edits — there is no release row to
|
||||
// write to — so these fields are simply not persisted in that case.
|
||||
if (track.Release is { } release)
|
||||
{
|
||||
release.Artist = request.Artist;
|
||||
release.Title = request.Album ?? string.Empty;
|
||||
release.Genre = request.Genre;
|
||||
release.Description = request.Description;
|
||||
release.ReleaseDate = request.ReleaseDate;
|
||||
|
||||
// ImagePath is tri-state: null = no change, "" = clear, value = set.
|
||||
if (request.ImagePath is not null)
|
||||
release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
|
||||
|
||||
// ReleaseType is non-null on the release; null in the request means "no change".
|
||||
if (request.ReleaseType is not null)
|
||||
release.ReleaseType = request.ReleaseType.Value;
|
||||
|
||||
// Medium is non-null on the release; null in the request means "no change".
|
||||
if (request.Medium is not null)
|
||||
{
|
||||
release.Medium = request.Medium.Value;
|
||||
|
||||
// ReleaseType is meaningful only for Cut. When the medium is anything else, reset
|
||||
// ReleaseType to the DB-level default rather than leaving a stale studio-format value —
|
||||
// mirroring TrackConverter's read-path nulling of ReleaseType for non-Cut releases. This
|
||||
// runs after the ReleaseType apply above, so it correctly overrides a contradictory
|
||||
// ReleaseType sent in the same request alongside a non-Cut medium.
|
||||
if (request.Medium.Value != ReleaseMedium.Cut)
|
||||
release.ReleaseType = ReleaseType.Single;
|
||||
}
|
||||
}
|
||||
|
||||
var update = await _sqlTrackService.Update(track);
|
||||
if (!update.Success)
|
||||
@@ -259,87 +479,74 @@ public class TrackController : ControllerBase
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// DELETE api/track/release/{id} ([ApiKeyAuthorize])
|
||||
// Soft-delete a release row directly. Used by the albums browser to remove an orphaned release
|
||||
// (one with no live tracks). "release" is a literal segment, declared here in the literal-route
|
||||
// block so it never resolves to the parameterized "{trackId}" GET.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpDelete("release/{id:long}")]
|
||||
public async Task<ActionResult> DeleteRelease(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _sqlTrackService.DeleteRelease(id, cancellationToken);
|
||||
if (result.Success) return Ok();
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogError("DeleteRelease failed for id {Id}: {Error}", id, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// --- Parameterized routes ---
|
||||
|
||||
[HttpGet("{trackId}")]
|
||||
public async Task<ActionResult> GetTrack(string trackId, [FromQuery] long offset = 0)
|
||||
public async Task<ActionResult> GetTrack(string trackId)
|
||||
{
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}, offset: {Offset}", trackId, offset);
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);
|
||||
|
||||
try
|
||||
{
|
||||
// No-offset path: stream the file straight from disk so a 100 MB WAV does not
|
||||
// force a 100 MB LOH allocation per request. The offset path still loads
|
||||
// the full buffer because WavOffsetService block-aligns and reslices into
|
||||
// a composite stream over the in-memory buffer.
|
||||
if (offset == 0)
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault == null)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault == null)
|
||||
{
|
||||
_logger.LogWarning("Tracks vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mediaStream = await vault.GetEntryStreamAsync(trackId);
|
||||
if (mediaStream == null)
|
||||
{
|
||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Resolve MIME and log before handing the stream to File().
|
||||
// If anything here throws, the finally block disposes the wrapper
|
||||
// (and its inner FileStream) so neither leaks. On the success path
|
||||
// File() takes ownership of the inner stream; ASP.NET Core disposes
|
||||
// it after the response body is sent. The wrapper is a thin struct
|
||||
// with no extra resources, so disposing it after extracting the
|
||||
// inner stream is a no-op — we only call Dispose() in the catch path.
|
||||
string streamMimeType;
|
||||
long streamLength;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
streamLength = mediaStream.Stream.Length;
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
||||
trackId, streamLength);
|
||||
// enableRangeProcessing: false — seek is served by WavOffsetService, not Range.
|
||||
return File(innerStream, streamMimeType, enableRangeProcessing: false);
|
||||
_logger.LogWarning("Tracks vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Offset path: route through TrackContentService.GetAudioBinaryAsync (Track B's
|
||||
// orchestrator boundary) so the controller stays out of FileDatabase directly.
|
||||
// The buffered AudioBinary is required because WavOffsetService block-aligns
|
||||
// and reslices into a composite stream over the in-memory buffer.
|
||||
var file = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (file == null)
|
||||
var mediaStream = await vault.GetEntryStreamAsync(trackId);
|
||||
if (mediaStream == null)
|
||||
{
|
||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mimeType = MimeTypeExtensions.GetMimeType(file.Extension);
|
||||
|
||||
var offsetStream = _wavOffsetService.CreateOffsetStream(file.Buffer, offset);
|
||||
if (offsetStream == null)
|
||||
// Resolve MIME and log before handing the stream to File().
|
||||
// If anything here throws, the finally block disposes the wrapper
|
||||
// (and its inner FileStream) so neither leaks. On the success path
|
||||
// File() takes ownership of the inner stream; ASP.NET Core disposes
|
||||
// it after the response body is sent. The wrapper is a thin struct
|
||||
// with no extra resources, so disposing it after extracting the
|
||||
// inner stream is a no-op — we only call Dispose() in the catch path.
|
||||
string streamMimeType;
|
||||
long streamLength;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Invalid offset {Offset} for track: {TrackId}", offset, trackId);
|
||||
return BadRequest("Invalid offset");
|
||||
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
streamLength = mediaStream.Stream.Length;
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Successfully retrieved track with offset: {TrackId}, Offset: {Offset}, StreamSize: {Size} bytes",
|
||||
trackId, offset, offsetStream.Length);
|
||||
return File(offsetStream, mimeType);
|
||||
_logger.LogInformation(
|
||||
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
||||
trackId, streamLength);
|
||||
// enableRangeProcessing: true — seek is served by HTTP Range requests.
|
||||
// The FileStream is seekable, so ASP.NET Core honours an incoming
|
||||
// Range header by slicing the file and responding 206 Partial Content.
|
||||
return File(innerStream, streamMimeType, enableRangeProcessing: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -348,6 +555,108 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// GET api/track/{trackId}/waveform (unauthenticated)
|
||||
// Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
|
||||
// data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
|
||||
// (existing tracks predate profiling, or computation failed at upload — the frontend falls back
|
||||
// to a flat seekbar). The "waveform" literal suffix keeps this distinct from the audio route.
|
||||
[HttpGet("{trackId}/waveform")]
|
||||
public async Task<ActionResult> GetWaveform(string trackId)
|
||||
{
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(trackId);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("No waveform profile for track: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(new WaveformProfileDto
|
||||
{
|
||||
BucketCount = bytes.Length,
|
||||
Data = Convert.ToBase64String(bytes),
|
||||
});
|
||||
}
|
||||
|
||||
// GET api/track/{trackId}/waveform/high-res (unauthenticated)
|
||||
// Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum
|
||||
// from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer
|
||||
// fetches for whatever track is currently playing/selected — the release is only addressing context.
|
||||
// Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the
|
||||
// "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is
|
||||
// stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the
|
||||
// parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
|
||||
[HttpGet("{trackId}/waveform/high-res")]
|
||||
public async Task<ActionResult> GetHighResWaveform(string trackId)
|
||||
{
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(new WaveformProfileDto
|
||||
{
|
||||
BucketCount = bytes.Length,
|
||||
Data = Convert.ToBase64String(bytes),
|
||||
});
|
||||
}
|
||||
|
||||
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
|
||||
// Admin backfill: compute and store a waveform profile for an existing track from its vault
|
||||
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
|
||||
// WAV cannot be decoded or the vault write fails. Used by the CMS PreProcessing panel for
|
||||
// tracks that predate the WaveformSeeker feature.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{trackId}/waveform")]
|
||||
public async Task<ActionResult> GenerateWaveform(string trackId)
|
||||
{
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
|
||||
return StatusCode(500, "Failed to generate waveform profile.");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
|
||||
// Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
|
||||
// the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
|
||||
// track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
|
||||
// Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
|
||||
// is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
|
||||
// before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{trackId}/waveform/high-res")]
|
||||
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
|
||||
{
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
||||
audio.Buffer, trackId, audio.Duration);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
|
||||
return StatusCode(500, "Failed to generate high-res waveform datum.");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("{trackId}")]
|
||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||
|
||||
@@ -26,4 +26,3 @@
|
||||
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
###############################################################################
|
||||
# DeepDrftAPI — Phase 9 smoke tests
|
||||
# IDE: VS 2022 / Rider / VS Code REST Client
|
||||
#
|
||||
# HOW TO USE:
|
||||
# 1. Set @apiKey below. The real value is in DeepDrftAPI/environment/apikey.json
|
||||
# under the key "ApiKeySettings.ApiKey". Do NOT commit a real key here.
|
||||
# 2. Adjust @releaseId, @trackId, and @entryKey to IDs present in your DB.
|
||||
# 3. Point the multipart upload requests at a real local file before sending.
|
||||
#
|
||||
# AUTH NOTE:
|
||||
# Unauthenticated endpoints have no ApiKey header.
|
||||
# ApiKey-gated endpoints use header "ApiKey: {{apiKey}}" (header name is literal
|
||||
# "ApiKey", not "X-Api-Key" — confirmed in ApiKeyAuthenticationMiddleware.cs).
|
||||
###############################################################################
|
||||
|
||||
@host = http://localhost:5003
|
||||
|
||||
# REPLACE with value from DeepDrftAPI/environment/apikey.json → ApiKeySettings.ApiKey
|
||||
@apiKey = REPLACE_WITH_YOUR_API_KEY
|
||||
|
||||
# Placeholders — edit these to match IDs in your local DB
|
||||
@releaseId = 1
|
||||
@trackId = 1
|
||||
@entryKey = replace-with-real-entry-key
|
||||
|
||||
|
||||
###############################################################################
|
||||
# 1. RELEASE READS
|
||||
###############################################################################
|
||||
|
||||
### 1a. List all releases (unauth) — expect 200 PagedResult
|
||||
# exercises: GET api/release (no medium filter)
|
||||
GET {{host}}/api/release
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 1b. List Session releases (unauth) — expect 200, only Session medium returned
|
||||
# exercises: GET api/release?medium=session
|
||||
GET {{host}}/api/release?medium=session
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 1c. List Mix releases (unauth) — expect 200, only Mix medium returned
|
||||
# exercises: GET api/release?medium=mix
|
||||
GET {{host}}/api/release?medium=mix
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 1d. List Cut releases (unauth) — expect 200, only Cut medium returned
|
||||
# exercises: GET api/release?medium=cut
|
||||
GET {{host}}/api/release?medium=cut
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 1e. Bad medium value (unauth) — expect 400 "Unrecognised medium: bogus"
|
||||
# exercises: GET api/release?medium=bogus → BadRequest branch in ReleaseController
|
||||
GET {{host}}/api/release?medium=bogus
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 1f. Single release by id (unauth) — expect 200 ReleaseDto with both metadata navs
|
||||
# exercises: GET api/release/{id:long}
|
||||
GET {{host}}/api/release/{{releaseId}}
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# 2. TRACK READS
|
||||
###############################################################################
|
||||
|
||||
### 2a. Paged track list — all tracks (unauth) — expect 200 PagedResult<TrackDto>
|
||||
# exercises: GET api/track/page (no filters)
|
||||
GET {{host}}/api/track/page
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2b. Paged track list filtered by releaseId (unauth) — expect 200, tracks for that release
|
||||
# exercises: GET api/track/page?releaseId=
|
||||
GET {{host}}/api/track/page?releaseId={{releaseId}}
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2c. Paged track list filtered by album title (unauth) — expect 200
|
||||
# exercises: GET api/track/page?album=
|
||||
GET {{host}}/api/track/page?album=My+Album+Name
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2d. Paged track list filtered by genre (unauth) — expect 200
|
||||
# exercises: GET api/track/page?genre=
|
||||
GET {{host}}/api/track/page?genre=Electronic
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2e. Single track metadata by SQL id (ApiKey) — expect 200 TrackDto
|
||||
# exercises: GET api/track/meta/{id:long}
|
||||
GET {{host}}/api/track/meta/{{trackId}}
|
||||
ApiKey: {{apiKey}}
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2f. Track audio stream — full file (unauth) — expect 200 with audio bytes
|
||||
# exercises: GET api/track/{trackId} → streams WAV from FileDatabase vault
|
||||
GET {{host}}/api/track/{{entryKey}}
|
||||
Accept: audio/wav
|
||||
|
||||
###
|
||||
|
||||
### 2g. Track audio stream — Range request (unauth) — expect 206 Partial Content
|
||||
# exercises: GET api/track/{trackId} with Range header → 206 + byte slice
|
||||
GET {{host}}/api/track/{{entryKey}}
|
||||
Range: bytes=44-
|
||||
Accept: audio/wav
|
||||
|
||||
###
|
||||
|
||||
### 2h. Albums list (unauth) — expect 200 List<ReleaseDto> with per-release track counts
|
||||
# exercises: GET api/track/albums
|
||||
GET {{host}}/api/track/albums
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2i. Genres list (unauth) — expect 200 List<string> distinct genres
|
||||
# exercises: GET api/track/genres
|
||||
GET {{host}}/api/track/genres
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2j. Random track (unauth) — expect 200 TrackDto (or 404 when library is empty)
|
||||
# exercises: GET api/track/random
|
||||
GET {{host}}/api/track/random
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2k. Waveform profile for track (unauth) — expect 200 WaveformProfileDto or 404 if not computed
|
||||
# exercises: GET api/track/{trackId}/waveform
|
||||
GET {{host}}/api/track/{{entryKey}}/waveform
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
### 2l. Waveform status for all tracks (ApiKey) — expect 200 List<WaveformStatusDto>
|
||||
# exercises: GET api/track/waveform-status (admin backfill view)
|
||||
GET {{host}}/api/track/waveform-status
|
||||
ApiKey: {{apiKey}}
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# 3. MEDIUM WRITE PATH
|
||||
###############################################################################
|
||||
|
||||
### 3a. Upload a Cut track (ApiKey, multipart) — expect 200 TrackDto
|
||||
# exercises: POST api/track/upload with medium=Cut
|
||||
# REPLACE ./path/to/test.wav with a real local .wav (or .mp3 / .flac) file path.
|
||||
POST {{host}}/api/track/upload
|
||||
ApiKey: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=boundary
|
||||
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
|
||||
Content-Type: audio/wav
|
||||
|
||||
< ./path/to/test.wav
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackName"
|
||||
|
||||
Smoke Test Cut
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="artist"
|
||||
|
||||
Test Artist
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="album"
|
||||
|
||||
Smoke Test Album
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="genre"
|
||||
|
||||
Electronic
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="releaseDate"
|
||||
|
||||
2025-01-01
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="createdByUserId"
|
||||
|
||||
1
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="medium"
|
||||
|
||||
Cut
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="releaseType"
|
||||
|
||||
Single
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackNumber"
|
||||
|
||||
1
|
||||
--boundary--
|
||||
|
||||
###
|
||||
|
||||
### 3b. Upload a Session track (ApiKey, multipart) — expect 200 TrackDto
|
||||
# exercises: POST api/track/upload with medium=Session
|
||||
# NOTE: Session releases are single-track. Use a unique album name to create a new release.
|
||||
# REPLACE ./path/to/test.wav with a real local .wav/.mp3/.flac file.
|
||||
POST {{host}}/api/track/upload
|
||||
ApiKey: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=boundary
|
||||
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
|
||||
Content-Type: audio/wav
|
||||
|
||||
< ./path/to/test.wav
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackName"
|
||||
|
||||
Smoke Test Session
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="artist"
|
||||
|
||||
Test Artist
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="album"
|
||||
|
||||
Smoke Session Album 001
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="createdByUserId"
|
||||
|
||||
1
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="medium"
|
||||
|
||||
Session
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackNumber"
|
||||
|
||||
1
|
||||
--boundary--
|
||||
|
||||
###
|
||||
|
||||
### 3c. Upload a Mix track (ApiKey, multipart) — expect 200 TrackDto
|
||||
# exercises: POST api/track/upload with medium=Mix
|
||||
# NOTE: Mix releases are also single-track. Use a unique album name.
|
||||
# REPLACE ./path/to/test.wav with a real local .wav/.mp3/.flac file.
|
||||
POST {{host}}/api/track/upload
|
||||
ApiKey: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=boundary
|
||||
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
|
||||
Content-Type: audio/wav
|
||||
|
||||
< ./path/to/test.wav
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackName"
|
||||
|
||||
Smoke Test Mix
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="artist"
|
||||
|
||||
Test Artist
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="album"
|
||||
|
||||
Smoke Mix Album 001
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="createdByUserId"
|
||||
|
||||
1
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="medium"
|
||||
|
||||
Mix
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackNumber"
|
||||
|
||||
1
|
||||
--boundary--
|
||||
|
||||
###
|
||||
|
||||
### 3d. Update track metadata — flip Medium to Session (ApiKey, JSON) — expect 200
|
||||
# exercises: PUT api/track/meta/{id:long}
|
||||
# Medium: null means "no change"; provide it to change the release medium.
|
||||
PUT {{host}}/api/track/meta/{{trackId}}
|
||||
ApiKey: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"TrackName": "Updated Track Name",
|
||||
"Artist": "Updated Artist",
|
||||
"Album": "Updated Album",
|
||||
"Genre": "Electronic",
|
||||
"ReleaseDate": "2025-06-01",
|
||||
"ImagePath": null,
|
||||
"ReleaseType": null,
|
||||
"Medium": "Session",
|
||||
"TrackNumber": 1
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# 4. WAVE 7 — SESSION CARDINALITY 409 SEQUENCE
|
||||
#
|
||||
# Session and Mix releases enforce a single-track maximum. The sequence below
|
||||
# demonstrates the enforcement:
|
||||
# Step 1: upload the FIRST track to a Session release → expect 200
|
||||
# Step 2: upload a SECOND track to the SAME album+artist → expect 409 Conflict
|
||||
#
|
||||
# The 409 body will contain: "A Session release holds a single track; '<album>'
|
||||
# already has one — edit the existing track or choose a different release."
|
||||
#
|
||||
# Cut-medium releases have no cardinality limit, so an identical sequence with
|
||||
# medium=Cut would produce 200 on both requests.
|
||||
###############################################################################
|
||||
|
||||
### 4a. [STEP 1] Upload first Session track — expect 200 TrackDto
|
||||
# The album "Cardinality Test Session" does not yet exist; this creates it.
|
||||
# REPLACE ./path/to/test.wav with a real local file.
|
||||
POST {{host}}/api/track/upload
|
||||
ApiKey: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=boundary
|
||||
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
|
||||
Content-Type: audio/wav
|
||||
|
||||
< ./path/to/test.wav
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackName"
|
||||
|
||||
Cardinality Track 1
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="artist"
|
||||
|
||||
Cardinality Artist
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="album"
|
||||
|
||||
Cardinality Test Session
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="createdByUserId"
|
||||
|
||||
1
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="medium"
|
||||
|
||||
Session
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackNumber"
|
||||
|
||||
1
|
||||
--boundary--
|
||||
|
||||
###
|
||||
|
||||
### 4b. [STEP 2] Upload second Session track to the SAME album+artist — expect 409 Conflict
|
||||
# Same album "Cardinality Test Session", same artist "Cardinality Artist".
|
||||
# UnifiedTrackService.UploadAsync pre-checks cardinality before the vault write.
|
||||
# REPLACE ./path/to/test.wav with a real local file.
|
||||
POST {{host}}/api/track/upload
|
||||
ApiKey: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=boundary
|
||||
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
|
||||
Content-Type: audio/wav
|
||||
|
||||
< ./path/to/test.wav
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackName"
|
||||
|
||||
Cardinality Track 2
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="artist"
|
||||
|
||||
Cardinality Artist
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="album"
|
||||
|
||||
Cardinality Test Session
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="createdByUserId"
|
||||
|
||||
1
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="medium"
|
||||
|
||||
Session
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="trackNumber"
|
||||
|
||||
2
|
||||
--boundary--
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# 5. MIX WAVEFORM
|
||||
###############################################################################
|
||||
|
||||
### 5a. Trigger waveform generation for a Mix release (ApiKey, no body) — expect 200 or 404
|
||||
# exercises: POST api/release/{id:long}/mix/waveform
|
||||
# 404 if the release is not a Mix, has no track, or the track has no audio stored.
|
||||
POST {{host}}/api/release/{{releaseId}}/mix/waveform
|
||||
ApiKey: {{apiKey}}
|
||||
Content-Length: 0
|
||||
|
||||
###
|
||||
|
||||
### 5b. Fetch stored mix waveform (unauth) — expect 200 WaveformProfileDto or 404
|
||||
# exercises: GET api/release/{id:long}/mix/waveform
|
||||
# 404 when release is not a Mix, has no WaveformEntryKey, or datum not yet computed (run 5a first).
|
||||
GET {{host}}/api/release/{{releaseId}}/mix/waveform
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# 6. SESSION HERO IMAGE
|
||||
###############################################################################
|
||||
|
||||
### 6a. Upload hero image for a Session release (ApiKey, multipart) — expect 200 or 404
|
||||
# exercises: POST api/release/{id:long}/session/hero-image
|
||||
# 404 if release not found. 400 if no image file or unsupported content type.
|
||||
# The release must be a Session (enforced in UnifiedReleaseService.SetHeroImageAsync).
|
||||
# REPLACE ./path/to/hero.jpg with a real local JPEG or PNG file.
|
||||
POST {{host}}/api/release/{{releaseId}}/session/hero-image
|
||||
ApiKey: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=boundary
|
||||
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="image"; filename="hero.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
< ./path/to/hero.jpg
|
||||
--boundary--
|
||||
|
||||
###
|
||||
@@ -1,12 +1,24 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftAPI.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
|
||||
/// travels over this surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="ImagePath"/> follows tri-state semantics distinct from the other optional
|
||||
/// fields: <c>null</c> leaves the existing value unchanged, an empty string clears it, and a
|
||||
/// non-empty value is the images-vault entry key to link.
|
||||
/// </remarks>
|
||||
public record UpdateTrackMetadataRequest(
|
||||
string TrackName,
|
||||
string Artist,
|
||||
string? Album,
|
||||
string? Genre,
|
||||
DateOnly? ReleaseDate);
|
||||
string? Description,
|
||||
DateOnly? ReleaseDate,
|
||||
string? ImagePath = null,
|
||||
ReleaseType? ReleaseType = null,
|
||||
ReleaseMedium? Medium = null,
|
||||
int? TrackNumber = null);
|
||||
|
||||
@@ -64,6 +64,14 @@ builder.Services
|
||||
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
builder.Services.AddScoped<UnifiedTrackService>();
|
||||
|
||||
// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
|
||||
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
|
||||
builder.Services
|
||||
.AddScoped<ReleaseRepository>()
|
||||
.AddScoped<ReleaseManager>()
|
||||
.AddScoped<IReleaseService>(sp => sp.GetRequiredService<ReleaseManager>());
|
||||
builder.Services.AddScoped<UnifiedReleaseService>();
|
||||
|
||||
// AuthBlocks: JWT Bearer auth, Identity, EF schema, role + admin seeding. This API host owns the
|
||||
// AuthBlocks API surface (registration, migration/seed, endpoint mounting). The Manager keeps only
|
||||
// web-side auth (AuthBlocksWeb) and never holds the signing secret, email creds, or admin creds.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftAPI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Host-internal orchestrator for the two release metadata write paths. Mirrors
|
||||
/// <see cref="UnifiedTrackService"/>: it makes DeepDrftAPI the single authority over both the vault
|
||||
/// (FileDatabase) and SQL satellite rows, so the controller stays a thin HTTP boundary and no caller
|
||||
/// coordinates the two stores.
|
||||
/// </summary>
|
||||
public class UnifiedReleaseService
|
||||
{
|
||||
/// <summary>Error message returned when the Mix release has no linked track.</summary>
|
||||
public const string MixHasNoTrackMessage = "Mix release has no track.";
|
||||
|
||||
/// <summary>Error message returned when the Mix track has no audio stored in the vault.</summary>
|
||||
public const string MixTrackNoAudioMessage = "No audio stored for the Mix track.";
|
||||
|
||||
private readonly IReleaseService _releaseService;
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly ImageProcessor _imageProcessor;
|
||||
private readonly TrackContentService _trackContentService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly ILogger<UnifiedReleaseService> _logger;
|
||||
|
||||
public UnifiedReleaseService(
|
||||
IReleaseService releaseService,
|
||||
FileDb fileDatabase,
|
||||
ImageProcessor imageProcessor,
|
||||
TrackContentService trackContentService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
ILogger<UnifiedReleaseService> logger)
|
||||
{
|
||||
_releaseService = releaseService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_imageProcessor = imageProcessor;
|
||||
_trackContentService = trackContentService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a hero image into the Images vault, then point the release's Session satellite at it.
|
||||
/// The medium check lives in <see cref="IReleaseService.SetSessionHeroImageAsync"/>: if the release
|
||||
/// is not a Session, the satellite is not written and the image is orphaned (logged, recoverable).
|
||||
/// </summary>
|
||||
public async Task<Result> SetHeroImageAsync(long releaseId, IFormFile imageFile, CancellationToken ct)
|
||||
{
|
||||
if (MimeTypeExtensions.GetExtension(imageFile.ContentType) == ".bin")
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SetHeroImage rejected: unsupported content type '{ContentType}' for release {ReleaseId}",
|
||||
imageFile.ContentType, releaseId);
|
||||
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
|
||||
}
|
||||
|
||||
byte[] buffer;
|
||||
await using (var stream = imageFile.OpenReadStream())
|
||||
using (var memory = new MemoryStream())
|
||||
{
|
||||
await stream.CopyToAsync(memory, ct);
|
||||
buffer = memory.ToArray();
|
||||
}
|
||||
|
||||
var imageBinary = _imageProcessor.Process(buffer, imageFile.ContentType);
|
||||
if (imageBinary is null)
|
||||
{
|
||||
_logger.LogWarning("SetHeroImage: ImageProcessor rejected content type '{ContentType}'", imageFile.ContentType);
|
||||
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
|
||||
}
|
||||
|
||||
var entryKey = Guid.NewGuid().ToString("N");
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("SetHeroImage: vault write failed for release {ReleaseId}, entryKey={EntryKey}", releaseId, entryKey);
|
||||
return Result.CreateFailResult("Failed to store hero image.");
|
||||
}
|
||||
|
||||
var linked = await _releaseService.SetSessionHeroImageAsync(releaseId, entryKey, ct);
|
||||
if (!linked.Success)
|
||||
{
|
||||
// Vault write succeeded, SQL link failed — image is orphaned in the Images vault under
|
||||
// entryKey. Log loudly (include entryKey) so it is recoverable manually.
|
||||
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Hero image stored in vault but Session link failed. Orphaned entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
|
||||
entryKey, releaseId, error);
|
||||
return linked;
|
||||
}
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
|
||||
/// resolution (≈333 samples/sec derived from the track's duration; see
|
||||
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms vault under the track's
|
||||
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
|
||||
/// track's EntryKey — the Mix is single-track. Under the per-track model (phase-12 §5) this is the
|
||||
/// same datum every track now carries. The visualizer fetches it via the track-cardinal
|
||||
/// <c>GET api/track/{trackEntryKey}/waveform/high-res</c> (12.B2); the Mix satellite link and the
|
||||
/// legacy release-addressed read path are retained transitionally and no longer feed the visualizer.
|
||||
/// </summary>
|
||||
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _releaseService.GetByIdAsync(releaseId, ct);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("TriggerMixWaveform: release lookup failed for {ReleaseId}: {Error}", releaseId, error);
|
||||
return Result.CreateFailResult("Failed to load release.");
|
||||
}
|
||||
|
||||
if (lookup.Value is null)
|
||||
return Result.CreateFailResult(ReleaseManager.ReleaseNotFoundMessage);
|
||||
|
||||
// Pre-check medium here (before fetching audio) to avoid expensive waveform compute on a
|
||||
// non-Mix release. ReleaseManager.SetMixWaveformAsync enforces this too, so the double-check
|
||||
// is intentional — the orchestrator's guard is the cheap early-exit.
|
||||
if (lookup.Value.Medium != ReleaseMedium.Mix)
|
||||
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
|
||||
|
||||
var keysResult = await _releaseService.GetTrackEntryKeysAsync(releaseId, ct);
|
||||
if (!keysResult.Success || keysResult.Value is null)
|
||||
{
|
||||
var error = keysResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("TriggerMixWaveform: entry-key lookup failed for release {ReleaseId}: {Error}", releaseId, error);
|
||||
return Result.CreateFailResult("Failed to load release tracks.");
|
||||
}
|
||||
|
||||
var entryKey = keysResult.Value.FirstOrDefault();
|
||||
if (entryKey is null)
|
||||
{
|
||||
_logger.LogWarning("TriggerMixWaveform: no track on Mix release {ReleaseId}", releaseId);
|
||||
return Result.CreateFailResult(MixHasNoTrackMessage);
|
||||
}
|
||||
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(entryKey);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
|
||||
return Result.CreateFailResult(MixTrackNoAudioMessage);
|
||||
}
|
||||
|
||||
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
|
||||
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
|
||||
// high-res datum every track now carries (phase-12 §5).
|
||||
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
||||
audio.Buffer, entryKey, audio.Duration);
|
||||
if (!computed)
|
||||
{
|
||||
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
|
||||
return Result.CreateFailResult("Failed to compute the Mix waveform.");
|
||||
}
|
||||
|
||||
var linked = await _releaseService.SetMixWaveformAsync(releaseId, entryKey, ct);
|
||||
if (!linked.Success)
|
||||
{
|
||||
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Mix waveform stored in vault but Mix link failed. Entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
|
||||
entryKey, releaseId, error);
|
||||
return linked;
|
||||
}
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
@@ -15,27 +17,39 @@ namespace DeepDrftAPI.Services;
|
||||
public class UnifiedTrackService
|
||||
{
|
||||
internal const string TrackNotFoundMessage = "Track not found.";
|
||||
|
||||
/// <summary>
|
||||
/// Stable marker prefixed onto a cardinality-rejection message so the controller can map this
|
||||
/// specific failure to 409 Conflict (a well-formed request that violates a domain rule),
|
||||
/// distinct from the 400 (malformed) and 500 (processing) paths. The human-readable detail
|
||||
/// follows the marker and is what the CMS surfaces to the admin.
|
||||
/// </summary>
|
||||
internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: ";
|
||||
private readonly TrackContentService _contentTrackContentService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly ILogger<UnifiedTrackService> _logger;
|
||||
|
||||
public UnifiedTrackService(
|
||||
TrackContentService contentTrackContentService,
|
||||
ITrackService sqlTrackService,
|
||||
FileDb fileDatabase,
|
||||
WaveformProfileService waveformProfileService,
|
||||
ILogger<UnifiedTrackService> logger)
|
||||
{
|
||||
_contentTrackContentService = contentTrackContentService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a WAV into the vault, then persist its metadata to SQL. On success the returned
|
||||
/// DTO carries the SQL-assigned Id. If the vault write succeeds but the SQL persist fails,
|
||||
/// the audio is orphaned under EntryKey — logged loudly so it is recoverable manually.
|
||||
/// Process a supported audio file (.wav, .mp3, .flac) into the vault, then persist its metadata
|
||||
/// to SQL. On success the returned DTO carries the SQL-assigned Id. If the vault write succeeds
|
||||
/// but the SQL persist fails, the audio is orphaned under EntryKey — logged loudly so it is
|
||||
/// recoverable manually.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<TrackDto>> UploadAsync(
|
||||
string tempFilePath,
|
||||
@@ -43,12 +57,45 @@ public class UnifiedTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
DateOnly? releaseDate,
|
||||
long createdByUserId,
|
||||
string? originalFileName,
|
||||
ReleaseType releaseType,
|
||||
ReleaseMedium medium,
|
||||
int trackNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate);
|
||||
// Cardinality pre-check — BEFORE the vault write so a rejected over-limit add never orphans
|
||||
// audio in the tracks vault. This is a READ-only peek (no release is created for an upload we
|
||||
// may reject); the real FindOrCreateRelease still runs below for the accepted path. Only the
|
||||
// find path can violate: a release that does not yet exist has zero tracks and admits its
|
||||
// first. The guard is the general form `(liveCount + 1) > Max`, not Session/Mix-hardcoded, so
|
||||
// a future bounded medium is covered by the same line.
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
|
||||
if (!peek.Success)
|
||||
{
|
||||
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
|
||||
}
|
||||
|
||||
if (peek.Value is { } existing)
|
||||
{
|
||||
var cardinality = MediumRules.CardinalityOf(existing.Medium);
|
||||
if (existing.TrackCount + 1 > cardinality.Max)
|
||||
{
|
||||
return ResultContainer<TrackDto>.CreateFailResult(
|
||||
$"{CardinalityViolationMarker}A {existing.Medium} release holds a single track; " +
|
||||
$"'{existing.Title}' already has one — edit the existing track or choose a different release.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var unpersisted = await _contentTrackContentService.AddTrackAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
|
||||
|
||||
if (unpersisted is null)
|
||||
{
|
||||
@@ -56,9 +103,50 @@ public class UnifiedTrackService
|
||||
return ResultContainer<TrackDto>.CreateFailResult("Failed to process and store WAV.");
|
||||
}
|
||||
|
||||
unpersisted.CreatedByUserId = createdByUserId;
|
||||
unpersisted.TrackNumber = trackNumber;
|
||||
|
||||
var saveResult = await _sqlTrackService.Create(TrackConverter.Convert(unpersisted));
|
||||
// Resolve the release FK before persisting the track. An upload with an album lands on the
|
||||
// shared release (created on first sighting); an upload without one stays a loose track with
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
|
||||
// rides on the release, not the track.
|
||||
long? releaseId = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
var releaseData = new ReleaseDto
|
||||
{
|
||||
Title = album,
|
||||
Artist = artist,
|
||||
Genre = genre,
|
||||
Description = description,
|
||||
ReleaseDate = releaseDate,
|
||||
ReleaseType = releaseType,
|
||||
Medium = medium,
|
||||
CreatedByUserId = createdByUserId,
|
||||
};
|
||||
|
||||
// Medium (like every other field in releaseData) applies only when this upload CREATES the
|
||||
// release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first
|
||||
// upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a
|
||||
// subsequent track add: medium is a release-level property, changed only via the edit path
|
||||
// (PUT api/track/meta), never silently flipped by adding a track to an existing release.
|
||||
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
|
||||
if (!releaseResult.Success || releaseResult.Value is null)
|
||||
{
|
||||
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Track persisted to vault but release resolution failed. Orphaned entry: {EntryKey}. Error: {Error}",
|
||||
unpersisted.EntryKey, error);
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
releaseId = releaseResult.Value.Id;
|
||||
}
|
||||
|
||||
var trackDto = TrackConverter.Convert(unpersisted);
|
||||
trackDto.ReleaseId = releaseId;
|
||||
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
|
||||
|
||||
var saveResult = await _sqlTrackService.Create(trackDto);
|
||||
if (!saveResult.Success || saveResult.Value is null)
|
||||
{
|
||||
// Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault
|
||||
@@ -70,9 +158,41 @@ public class UnifiedTrackService
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
// Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of
|
||||
// the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the
|
||||
// frontend, so a failure here is logged and swallowed — never fails the upload.
|
||||
await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);
|
||||
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
// Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
|
||||
// the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
|
||||
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same
|
||||
// audio: read it back from the vault once (the authoritative parsed duration + the stored buffer)
|
||||
// rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload.
|
||||
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
|
||||
entryKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
|
||||
await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
|
||||
/// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
|
||||
@@ -94,6 +214,7 @@ public class UnifiedTrackService
|
||||
}
|
||||
|
||||
var entryKey = lookup.Value.EntryKey;
|
||||
var releaseId = lookup.Value.ReleaseId;
|
||||
|
||||
var sqlDelete = await _sqlTrackService.Delete(id);
|
||||
if (!sqlDelete.Success)
|
||||
@@ -103,6 +224,14 @@ public class UnifiedTrackService
|
||||
return Result.CreateFailResult("Failed to delete track.");
|
||||
}
|
||||
|
||||
// Cascade: if this was the last live track on its release, soft-delete the release too so it
|
||||
// does not linger as a 0-track orphan in the albums browser. Non-fatal — the track delete
|
||||
// already succeeded, so any failure here is logged and swallowed, not surfaced to the caller.
|
||||
if (releaseId is { } rid)
|
||||
{
|
||||
await TrySoftDeleteEmptyReleaseAsync(rid, ct);
|
||||
}
|
||||
|
||||
// Tri-state per FileDatabase's error-swallow contract: null = vault missing/error,
|
||||
// false = entry not present, true = removed. Anything but a clean removal is an orphan.
|
||||
var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
|
||||
@@ -115,4 +244,30 @@ public class UnifiedTrackService
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
// Soft-delete the release only when no live tracks remain on it. Best-effort: a count or delete
|
||||
// failure here never fails the track delete that triggered it — it is logged so an orphaned
|
||||
// release can be cleaned up later (the migration backfill also catches pre-existing orphans).
|
||||
private async Task TrySoftDeleteEmptyReleaseAsync(long releaseId, CancellationToken ct)
|
||||
{
|
||||
var countResult = await _sqlTrackService.CountLiveTracksByRelease(releaseId, ct);
|
||||
if (!countResult.Success)
|
||||
{
|
||||
var error = countResult.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogWarning("DeleteAsync: live-track count failed for release {ReleaseId}: {Error}", releaseId, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (countResult.Value > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var releaseDelete = await _sqlTrackService.DeleteRelease(releaseId, ct);
|
||||
if (!releaseDelete.Success)
|
||||
{
|
||||
var error = releaseDelete.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogWarning("DeleteAsync: release soft-delete failed for {ReleaseId}: {Error}", releaseId, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-2
@@ -1,6 +1,5 @@
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Audio;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
@@ -15,10 +14,21 @@ namespace DeepDrftAPI
|
||||
public static Task ConfigureDomainServices(WebApplicationBuilder builder)
|
||||
{
|
||||
// Audio services
|
||||
builder.Services.AddSingleton<WavOffsetService>();
|
||||
builder.Services.AddSingleton<AudioProcessor>();
|
||||
builder.Services.AddSingleton<Mp3AudioProcessor>();
|
||||
builder.Services.AddSingleton<FlacAudioProcessor>();
|
||||
builder.Services.AddSingleton<AudioProcessorRouter>();
|
||||
builder.Services.AddSingleton<TrackContentService>();
|
||||
|
||||
// Image services
|
||||
builder.Services.AddSingleton<ImageProcessor>();
|
||||
|
||||
// Waveform loudness profiling (upload-time, off the playback path)
|
||||
builder.Services.Configure<WaveformProfileOptions>(
|
||||
builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
|
||||
builder.Services.AddSingleton<ILoudnessAlgorithm, RmsLoudnessAlgorithm>();
|
||||
builder.Services.AddSingleton<WaveformProfileService>();
|
||||
|
||||
// File Database
|
||||
var fileDatabasePath = CredentialTools.ResolvePathOrThrow("filedatabase", "environment/filedatabase.json");
|
||||
builder.Configuration.AddJsonFile(fileDatabasePath, optional: false, reloadOnChange: false);
|
||||
@@ -32,6 +42,8 @@ namespace DeepDrftAPI
|
||||
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
|
||||
if (db is null) throw new Exception("Unable to initialize file database");
|
||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||
InitializeImageVault(db).GetAwaiter().GetResult();
|
||||
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
|
||||
return db;
|
||||
});
|
||||
|
||||
@@ -45,5 +57,23 @@ namespace DeepDrftAPI
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InitializeImageVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.Images))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the track-waveforms vault exists. Holds the per-track high-resolution waveform datum
|
||||
// (every track — Mix, Session, Cut), keyed by the track's EntryKey.
|
||||
private static async Task InitializeTrackWaveformsVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.TrackWaveforms))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DeepDrftContent.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating WAV audio streams starting from a byte offset.
|
||||
/// Synthesizes a valid WAV header for the remaining audio data.
|
||||
/// </summary>
|
||||
public class WavOffsetService
|
||||
{
|
||||
/// <summary>
|
||||
/// WAV audio format code for linear PCM. The pipeline (AudioProcessor,
|
||||
/// WavOffsetService, and wavutils.ts) is PCM-only by design — IEEE Float
|
||||
/// (format 3) and other formats are rejected at parse time so the
|
||||
/// synthesized header here can safely assume PCM.
|
||||
/// </summary>
|
||||
public const short PcmFormat = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
|
||||
/// The returned stream is composed of a small header buffer and a non-owning slice over the input
|
||||
/// buffer — no copy of the audio payload is made.
|
||||
/// </summary>
|
||||
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
|
||||
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</param>
|
||||
/// <returns>Stream with new WAV header + audio data from offset, or null if invalid</returns>
|
||||
public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
|
||||
{
|
||||
var format = ParseWavHeader(fullAudioBuffer);
|
||||
if (format == null)
|
||||
return null;
|
||||
|
||||
// Validate offset is within bounds and block-aligned
|
||||
if (byteOffset < 0 || byteOffset >= format.DataSize)
|
||||
return null;
|
||||
|
||||
// Align to block boundary for clean audio
|
||||
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
|
||||
|
||||
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
|
||||
var newDataSize = format.DataSize - alignedOffset;
|
||||
if (newDataSize <= 0)
|
||||
return null;
|
||||
|
||||
// MemoryStream does not support offsets or lengths beyond int.MaxValue.
|
||||
// RF64 (>2 GB audio segments) is not supported; reject before truncating.
|
||||
var sourcePosition = format.HeaderSize + alignedOffset;
|
||||
if (sourcePosition > int.MaxValue || newDataSize > int.MaxValue)
|
||||
throw new NotSupportedException("Audio file segment exceeds 2 GB; RF64 not supported");
|
||||
|
||||
var newDataSizeInt = (int)newDataSize;
|
||||
var sourcePositionInt = (int)sourcePosition;
|
||||
|
||||
// Create new WAV header using the format reported by the parsed header.
|
||||
// PCM is the only format we accept (see PcmFormat / ParseWavHeader), but
|
||||
// threading format.AudioFormat through keeps the header self-consistent
|
||||
// and prevents drift if the validation contract is ever relaxed.
|
||||
var newHeader = CreateWavHeader(format, newDataSizeInt);
|
||||
|
||||
// Compose: 44-byte header followed by a non-copying slice of the audio payload.
|
||||
// Wrapping the original buffer in a MemoryStream window avoids a 100MB+ copy
|
||||
// that the previous MemoryStream(capacity).Write(...) implementation forced.
|
||||
var headerStream = new MemoryStream(newHeader, writable: false);
|
||||
var dataStream = new MemoryStream(
|
||||
fullAudioBuffer,
|
||||
sourcePositionInt,
|
||||
newDataSizeInt,
|
||||
writable: false,
|
||||
publiclyVisible: false);
|
||||
|
||||
return new ConcatStream(headerStream, dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the WAV header from a buffer to extract format information.
|
||||
/// PCM-only — IEEE Float (format 3) and other non-PCM formats are rejected
|
||||
/// so downstream synthesis can safely assume PCM sample encoding.
|
||||
/// </summary>
|
||||
public WavFormat? ParseWavHeader(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 44)
|
||||
return null;
|
||||
|
||||
// Check RIFF header
|
||||
var riff = Encoding.ASCII.GetString(buffer, 0, 4);
|
||||
if (riff != "RIFF")
|
||||
return null;
|
||||
|
||||
var wave = Encoding.ASCII.GetString(buffer, 8, 4);
|
||||
if (wave != "WAVE")
|
||||
return null;
|
||||
|
||||
// Variables to store parsed header info
|
||||
int sampleRate = 0;
|
||||
int channels = 0;
|
||||
int bitsPerSample = 0;
|
||||
int byteRate = 0;
|
||||
int blockAlign = 0;
|
||||
long dataSize = 0;
|
||||
int headerSize = 0;
|
||||
short audioFormat = 0;
|
||||
bool foundFmt = false;
|
||||
bool foundData = false;
|
||||
|
||||
// Find fmt and data chunks
|
||||
int chunkOffset = 12;
|
||||
while (chunkOffset < buffer.Length - 8)
|
||||
{
|
||||
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
|
||||
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
|
||||
|
||||
if (chunkSize < 0)
|
||||
return null;
|
||||
|
||||
if (chunkId == "fmt " && !foundFmt)
|
||||
{
|
||||
// Use the first fmt chunk encountered — that is the WAV-spec-authoritative
|
||||
// chunk. Subsequent fmt chunks in a malformed file are ignored, matching
|
||||
// AudioProcessor.FindChunk which also returns the first match.
|
||||
if (chunkSize < 16)
|
||||
return null;
|
||||
|
||||
audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
|
||||
// PCM only. Float32 WAVs were previously accepted here but the synthesized
|
||||
// header below is PCM-shaped — accepting Float would produce a corrupt file
|
||||
// claiming PCM with Float-encoded samples. AudioProcessor also rejects
|
||||
// non-PCM at upload time so this branch is defense in depth.
|
||||
if (audioFormat != PcmFormat)
|
||||
return null;
|
||||
|
||||
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
|
||||
sampleRate = BitConverter.ToInt32(buffer, chunkOffset + 12);
|
||||
byteRate = BitConverter.ToInt32(buffer, chunkOffset + 16);
|
||||
blockAlign = BitConverter.ToInt16(buffer, chunkOffset + 20);
|
||||
bitsPerSample = BitConverter.ToInt16(buffer, chunkOffset + 22);
|
||||
|
||||
// Basic validation
|
||||
if (channels < 1 || channels > 8)
|
||||
return null;
|
||||
|
||||
foundFmt = true;
|
||||
}
|
||||
else if (chunkId == "data")
|
||||
{
|
||||
// WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve
|
||||
// values above int.MaxValue (files between 2–4 GB), then widen to long.
|
||||
dataSize = (long)BitConverter.ToUInt32(buffer, chunkOffset + 4);
|
||||
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
|
||||
foundData = true;
|
||||
}
|
||||
|
||||
// Move to next chunk with proper alignment (chunks are word-aligned)
|
||||
chunkOffset += 8 + ((chunkSize + 1) & ~1);
|
||||
|
||||
// If we found both chunks, we're done
|
||||
if (foundFmt && foundData)
|
||||
break;
|
||||
}
|
||||
|
||||
// Must have found both fmt and data chunks
|
||||
if (!foundFmt || !foundData)
|
||||
return null;
|
||||
|
||||
return new WavFormat(
|
||||
AudioFormat: audioFormat,
|
||||
SampleRate: sampleRate,
|
||||
Channels: channels,
|
||||
BitsPerSample: bitsPerSample,
|
||||
ByteRate: byteRate,
|
||||
BlockAlign: blockAlign,
|
||||
DataSize: dataSize,
|
||||
HeaderSize: headerSize
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standard 44-byte WAV header. The audio format code is taken from
|
||||
/// <paramref name="format"/> rather than hardcoded so the synthesized header matches
|
||||
/// what was parsed (today always <see cref="PcmFormat"/>; see ParseWavHeader).
|
||||
/// </summary>
|
||||
public byte[] CreateWavHeader(WavFormat format, int dataSize)
|
||||
{
|
||||
var header = new byte[44];
|
||||
var fileSize = 36 + dataSize;
|
||||
|
||||
// RIFF header
|
||||
header[0] = (byte)'R'; header[1] = (byte)'I'; header[2] = (byte)'F'; header[3] = (byte)'F';
|
||||
BitConverter.GetBytes(fileSize).CopyTo(header, 4);
|
||||
header[8] = (byte)'W'; header[9] = (byte)'A'; header[10] = (byte)'V'; header[11] = (byte)'E';
|
||||
|
||||
// fmt chunk
|
||||
header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' ';
|
||||
BitConverter.GetBytes(16).CopyTo(header, 16); // fmt chunk size
|
||||
BitConverter.GetBytes(format.AudioFormat).CopyTo(header, 20); // Audio format (from parsed header)
|
||||
BitConverter.GetBytes((short)format.Channels).CopyTo(header, 22);
|
||||
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
|
||||
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
|
||||
BitConverter.GetBytes((short)format.BlockAlign).CopyTo(header, 32);
|
||||
BitConverter.GetBytes((short)format.BitsPerSample).CopyTo(header, 34);
|
||||
|
||||
// data chunk header
|
||||
header[36] = (byte)'d'; header[37] = (byte)'a'; header[38] = (byte)'t'; header[39] = (byte)'a';
|
||||
BitConverter.GetBytes(dataSize).CopyTo(header, 40);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WAV format information extracted from header.
|
||||
/// </summary>
|
||||
/// <param name="AudioFormat">WAV fmt-chunk audio format code (1 = PCM; the only value accepted today).</param>
|
||||
public record WavFormat(
|
||||
short AudioFormat,
|
||||
int SampleRate,
|
||||
int Channels,
|
||||
int BitsPerSample,
|
||||
int ByteRate,
|
||||
int BlockAlign,
|
||||
long DataSize,
|
||||
int HeaderSize
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Forward-only read stream over two underlying streams concatenated end-to-end.
|
||||
/// Lets us serve "[synthesized header][slice of original buffer]" without
|
||||
/// allocating a single contiguous buffer for the combined payload.
|
||||
/// </summary>
|
||||
internal sealed class ConcatStream : Stream
|
||||
{
|
||||
private readonly Stream _first;
|
||||
private readonly Stream _second;
|
||||
private readonly long _length;
|
||||
private long _position;
|
||||
|
||||
public ConcatStream(Stream first, Stream second)
|
||||
{
|
||||
_first = first;
|
||||
_second = second;
|
||||
_length = first.Length + second.Length;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Loop over _first until it returns 0 (exhausted) or the caller's buffer
|
||||
// is full. Stream.Read is not required to fill the buffer in one call even
|
||||
// when data is available (e.g. a future non-MemoryStream _first), so we must
|
||||
// keep pulling until we get 0 before advancing to _second.
|
||||
while (count > 0 && _position < _first.Length)
|
||||
{
|
||||
var read = _first.Read(buffer, offset, count);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
offset += read;
|
||||
count -= read;
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
var read = _second.Read(buffer, offset, count);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Same loop contract as Read() — exhaust _first before reading _second.
|
||||
while (!buffer.IsEmpty && _position < _first.Length)
|
||||
{
|
||||
var read = await _first.ReadAsync(buffer, cancellationToken);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
buffer = buffer[read..];
|
||||
}
|
||||
|
||||
if (!buffer.IsEmpty)
|
||||
{
|
||||
var read = await _second.ReadAsync(buffer, cancellationToken);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_first.Dispose();
|
||||
_second.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
+19
-22
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
|
||||
|
||||
## One-line purpose
|
||||
|
||||
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), WAV stream-with-offset, audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
|
||||
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -17,8 +17,6 @@ DeepDrftContent.Services/
|
||||
│ ├── Models/ # Data models, DTOs, enums
|
||||
│ ├── Services/ # FileDatabase, MediaVault, IndexSystem, IndexWatcher
|
||||
│ └── Utils/ # StructuralMap, StructuralSet, FileUtils
|
||||
├── Audio/
|
||||
│ └── WavOffsetService.cs # Byte offset → valid WAV stream
|
||||
├── Processors/
|
||||
│ └── AudioProcessor.cs # WAV file parsing, metadata extraction
|
||||
├── Constants/
|
||||
@@ -76,30 +74,30 @@ public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, Fi
|
||||
|
||||
**Callers must check return values.** Do not change this without a deliberate design pass — it's embedded in all FileDatabase tests and client code.
|
||||
|
||||
## WAV offset service
|
||||
## Audio processors
|
||||
|
||||
`WavOffsetService.CreateOffsetStream(buffer, byteOffset)`:
|
||||
Multi-format support via router pattern. All processors live in `DeepDrftContent/Processors/`:
|
||||
|
||||
1. Parses the WAV header from the buffer.
|
||||
2. Block-aligns the byte offset to the nearest block boundary (required for clean audio — misalignment causes clicks).
|
||||
3. Synthesises a new 44-byte WAV header sized for the remaining data (from offset to EOF).
|
||||
4. Returns a `MemoryStream` containing `[new header][data from offset]`.
|
||||
- `AudioProcessor.ProcessWavFileAsync(filePath)`: WAV-specific processor. Validates RIFF/WAVE structure and format code. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing. Parses fmt and data chunks; extracts duration and bitrate. Returns `AudioBinary` with metadata. On parse failure, logs warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo). Accepts standard PCM (audioFormat=1), WAVE_FORMAT_EXTENSIBLE with PCM SubFormat (0x0001), IEEE Float SubFormat (0x0003), and Padded 24-in-32 containers; normalizes Float and padded inputs to standard 24-bit PCM before storage.
|
||||
- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. Returns `AudioBinary` with original bytes and `.mp3` extension. On parse failure, falls back to defaults (180s / 320 kbps).
|
||||
- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. Returns `AudioBinary` with original bytes and `.flac` extension. On parse failure, falls back to defaults (180s / 1411 kbps).
|
||||
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav` → `AudioProcessor`, `.mp3` → `Mp3AudioProcessor`, `.flac` → `FlacAudioProcessor`. Throws `ArgumentException` for unsupported extensions.
|
||||
- `WaveformProfileService.ComputeAndStoreHighResAsync(entryKey)`: The shared compute seam for the duration-derived high-res waveform datum (~333 samples/sec). Medium-neutral — computes for any track by `EntryKey`, stores in the `track-waveforms` vault. Called by the upload path (`UnifiedTrackService.UploadAsync` for every new track), the CMS per-row generate action, and the Mix release trigger (now a legacy delegate). Phase 12 generalization of the former Mix-only compute.
|
||||
- `WaveformResolution`: Enum / constants controlling bucket density for the high-res compute. Renamed from `MixWaveformResolution` in Phase 12.
|
||||
|
||||
Used by the content API to serve seek-beyond-buffer requests. The player asks for a new stream at the byte offset it wants to seek to; the server returns a valid WAV that starts there.
|
||||
Vault stores original bytes with correct extension and MIME type (inferred from file extension or content-type header at upload time).
|
||||
|
||||
**Block alignment is critical.** Do not bypass it. The WAV fmt chunk tells you the block size; use it.
|
||||
The primary entry point is `TrackContentService.AddTrackAsync(filePath, mimeType)` — format-agnostic. It selects the right processor via `AudioProcessorRouter`, processes the file, generates an entry GUID, stores in vault, returns unpersisted `TrackEntity`. Legacy `AddTrackFromWavAsync(filePath)` is now a shim over `AddTrackAsync` for backward compatibility.
|
||||
|
||||
## Audio processor
|
||||
## Image processor
|
||||
|
||||
`AudioProcessor.ProcessWavFileAsync(filePath)`:
|
||||
`ImageProcessor.ProcessImageAsync(buffer, mimeType)`:
|
||||
|
||||
1. Validates the RIFF/WAVE/PCM structure.
|
||||
2. Parses the fmt and data chunks.
|
||||
3. Extracts duration (sample count / sample rate) and bitrate (file size / duration).
|
||||
4. Returns `AudioBinary` with all metadata.
|
||||
5. **Fallback**: If parsing fails, logs a warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo).
|
||||
|
||||
PCM-only today. Other formats (mp3, flac, aac, ogg, m4a) are listed in `MimeTypeExtensions` but not implemented. The processor validates RIFF/WAVE/PCM format — anything else is rejected.
|
||||
1. Accepts raw image bytes and MIME type (e.g., `image/png`, `image/jpeg`).
|
||||
2. Parses PNG or JPEG headers to extract image dimensions.
|
||||
3. Computes aspect ratio (width / height). Defaults to 1.0 if parsing fails or format is unsupported.
|
||||
4. Returns `ImageBinary` with MIME type and aspect ratio metadata.
|
||||
5. **No disk I/O**: operates on `byte[]` only — no file reading required.
|
||||
|
||||
## Content-side TrackService (orchestrator)
|
||||
|
||||
@@ -124,14 +122,13 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
|
||||
|
||||
## Vault constants
|
||||
|
||||
`VaultConstants.Tracks = "tracks"` — the one vault name in production use. New vault names go here when adding new vault types (e.g., `VaultConstants.Images = "images"` if image uploads are added).
|
||||
`VaultConstants.Tracks = "tracks"`, `VaultConstants.Images = "images"`, and `VaultConstants.TrackWaveforms = "track-waveforms"` — the vault names in production use. `TrackWaveforms` holds the per-track high-res waveform datum keyed by `TrackEntity.EntryKey` (Phase 12; renamed from the former `mix-waveforms`, which was Mix-only). New vault names go here when adding new vault types.
|
||||
|
||||
## Service registration
|
||||
|
||||
In `DeepDrftContent/Startup.ConfigureDomainServices()` and `DeepDrftCli/Program.cs`:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<WavOffsetService>();
|
||||
services.AddSingleton<FileDatabase>(/* from FileDatabase.FromAsync */);
|
||||
services.AddScoped<AudioProcessor>();
|
||||
services.AddScoped<TrackService>(); // DeepDrftContent.Services.TrackService
|
||||
|
||||
@@ -9,4 +9,23 @@ public static class VaultConstants
|
||||
/// Vault name for storing audio tracks
|
||||
/// </summary>
|
||||
public const string Tracks = "tracks";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey.
|
||||
/// </summary>
|
||||
public const string WaveformProfiles = "waveform-profiles";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for storing cover-art images, keyed by a generated entry key referenced
|
||||
/// from <c>TrackEntity.ImagePath</c>.
|
||||
/// </summary>
|
||||
public const string Images = "images";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for per-track high-resolution waveform datums, keyed by the track's EntryKey.
|
||||
/// Every track (Mix, Session, Cut) carries one — computed at upload, regenerable on demand.
|
||||
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
|
||||
/// The datum resolution is duration-derived (≈333 samples/sec, see <c>WaveformResolution</c>).
|
||||
/// </summary>
|
||||
public const string TrackWaveforms = "track-waveforms";
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -219,6 +219,32 @@ public class AudioVault : MediaVault
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concrete vault for plain <see cref="MediaBinary"/> entries (vault type
|
||||
/// <see cref="MediaVaultType.Media"/>) — bytes plus an extension, no audio/image-specific
|
||||
/// metadata. Used for sidecar artifacts such as waveform loudness profiles. The base
|
||||
/// <see cref="MediaVault"/> already handles Media-typed storage via the registry; this only
|
||||
/// provides the concrete factory the Image and Audio vaults also provide.
|
||||
/// </summary>
|
||||
public class MediaFileVault : MediaVault
|
||||
{
|
||||
private MediaFileVault(string rootPath, VaultIndex index, IndexFactoryService? factoryService = null)
|
||||
: base(rootPath, index, factoryService) { }
|
||||
|
||||
public static async Task<MediaFileVault?> FromAsync(string rootPath, IndexFactoryService? factoryService = null)
|
||||
{
|
||||
var factory = factoryService ?? new IndexFactoryService();
|
||||
var index = await factory.LoadOrCreateVaultIndexAsync(rootPath, MediaVaultType.Media);
|
||||
|
||||
if (index != null)
|
||||
{
|
||||
return new MediaFileVault(rootPath, (VaultIndex)index, factory);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An open read-only stream over a vault entry plus the extension needed to
|
||||
/// resolve its MIME type. Caller owns the stream and must dispose it.
|
||||
|
||||
@@ -11,6 +11,7 @@ public static class MediaVaultFactory
|
||||
{
|
||||
return mediaType switch
|
||||
{
|
||||
MediaVaultType.Media => await MediaFileVault.FromAsync(rootPath, factoryService),
|
||||
MediaVaultType.Image => await ImageVault.FromAsync(rootPath, factoryService),
|
||||
MediaVaultType.Audio => await AudioVault.FromAsync(rootPath, factoryService),
|
||||
_ => null
|
||||
|
||||
@@ -31,7 +31,8 @@ public class SimpleMediaTypeRegistry : IMediaTypeRegistry
|
||||
dto => MediaBinary.From(dto),
|
||||
binary => new MediaBinaryDto(binary),
|
||||
(key, ext, _) => new MetaData(key, ext),
|
||||
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension));
|
||||
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension),
|
||||
async path => await MediaFileVault.FromAsync(path));
|
||||
|
||||
RegisterType<ImageBinary, ImageBinaryParams, ImageBinaryDto, ImageMetaData>(
|
||||
MediaVaultType.Image,
|
||||
|
||||
@@ -28,10 +28,15 @@ public class AudioProcessor
|
||||
{
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var wavInfo = ExtractWavMetadata(buffer);
|
||||
|
||||
|
||||
// EXTENSIBLE-PCM is byte-compatible with standard PCM but carries a 40+ byte fmt chunk
|
||||
// the streaming pipeline never expects. Normalize to a plain 44-byte PCM WAV at storage
|
||||
// time so the vault only ever holds standard PCM and the client decode path stays unchanged.
|
||||
var storedBuffer = wavInfo.IsExtensible ? NormalizeToStandardPcm(buffer, wavInfo) : buffer;
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Buffer: storedBuffer,
|
||||
Size: storedBuffer.Length,
|
||||
Extension: ".wav",
|
||||
Duration: wavInfo.Duration,
|
||||
Bitrate: wavInfo.Bitrate
|
||||
@@ -45,6 +50,67 @@ public class AudioProcessor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the raw PCM data region and format parameters from a WAV buffer, reusing the
|
||||
/// same chunk-walk and validation as metadata extraction. Returns null if the buffer is not
|
||||
/// a valid PCM WAV (callers treat a null as "no profile computable" and continue) — unlike
|
||||
/// <see cref="ExtractWavMetadata"/>, this does NOT fall back to synthetic defaults, because a
|
||||
/// loudness profile over fabricated silence would be misleading.
|
||||
/// </summary>
|
||||
public PcmData? TryExtractPcm(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
// Copy the span to an array so the existing array-based parsers can be reused. The PCM
|
||||
// slice returned is a view over this array (no second copy of the data region).
|
||||
var bytes = buffer.ToArray();
|
||||
|
||||
var validation = ValidateWavStructure(bytes);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Float and padded-container EXTENSIBLE require a sample-level transform to become integer PCM.
|
||||
// TryExtractPcm feeds loudness analysis, not storage, and must not hand back float bytes
|
||||
// mislabeled as integer PCM — out of scope here, so treat them as "no profile computable".
|
||||
if (validation.IsFloat)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
WavMetadata metadata;
|
||||
try
|
||||
{
|
||||
metadata = ParseWavMetadata(bytes, validation);
|
||||
ValidateAudioParameters(metadata);
|
||||
if (metadata.IsPaddedContainer)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Data bytes begin 8 past the "data" chunk id (4 id + 4 size). Clamp the declared size to
|
||||
// what is actually present — some encoders write a size that overshoots the file.
|
||||
var dataStart = validation.DataChunkPos + 8;
|
||||
if (dataStart > bytes.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var available = bytes.Length - dataStart;
|
||||
var dataLength = Math.Min(metadata.DataSize, available);
|
||||
if (dataLength <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pcm = new ReadOnlyMemory<byte>(bytes, dataStart, dataLength);
|
||||
return new PcmData(pcm, metadata.Channels, metadata.SampleRate, metadata.BitsPerSample);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from WAV file buffer with comprehensive validation
|
||||
/// </summary>
|
||||
@@ -107,9 +173,46 @@ public class AudioProcessor
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "fmt chunk too small" };
|
||||
}
|
||||
|
||||
// Validate audio format (PCM only)
|
||||
// Validate audio format. Standard PCM (1) is accepted directly. WAVE_FORMAT_EXTENSIBLE
|
||||
// (0xFFFE) is accepted when its SubFormat GUID indicates PCM (0x0001) or IEEE float
|
||||
// (0x0003). PCM sample data is byte-identical to standard PCM; float data is converted to
|
||||
// 24-bit PCM downstream. Either way the vault only ever holds standard PCM.
|
||||
var audioFormat = BitConverter.ToUInt16(buffer, fmtChunkPos + 8);
|
||||
if (audioFormat != 1)
|
||||
var isExtensible = false;
|
||||
var isFloat = false;
|
||||
if (audioFormat == 0xFFFE)
|
||||
{
|
||||
// EXTENSIBLE requires the full extension: 16 base + 2 cbSize + 22 extension = 40 bytes.
|
||||
if (fmtChunkSize < 40)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE fmt chunk too small" };
|
||||
}
|
||||
|
||||
if (fmtChunkPos + 8 + 40 > buffer.Length)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE fmt chunk extends past end of file" };
|
||||
}
|
||||
|
||||
// SubFormat GUID begins 24 bytes into the fmt chunk data (fmtChunkPos + 8 + 24). Its
|
||||
// first two bytes are the little-endian format tag: 0x0001 == WAVE_FORMAT_PCM,
|
||||
// 0x0003 == WAVE_FORMAT_IEEE_FLOAT.
|
||||
var subFormatPos = fmtChunkPos + 8 + 24;
|
||||
var subFormatTag = BitConverter.ToUInt16(buffer, subFormatPos);
|
||||
if (subFormatTag == 0x0001)
|
||||
{
|
||||
isExtensible = true;
|
||||
}
|
||||
else if (subFormatTag == 0x0003)
|
||||
{
|
||||
isExtensible = true;
|
||||
isFloat = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE SubFormat is neither PCM nor IEEE float" };
|
||||
}
|
||||
}
|
||||
else if (audioFormat != 1)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Only PCM format supported" };
|
||||
}
|
||||
@@ -121,11 +224,13 @@ public class AudioProcessor
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Missing data chunk" };
|
||||
}
|
||||
|
||||
return new WavValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
return new WavValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
FmtChunkPos = fmtChunkPos,
|
||||
DataChunkPos = dataChunkPos
|
||||
DataChunkPos = dataChunkPos,
|
||||
IsExtensible = isExtensible,
|
||||
IsFloat = isFloat
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,6 +246,23 @@ public class AudioProcessor
|
||||
var bitsPerSample = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 22);
|
||||
var dataSize = BitConverter.ToUInt32(buffer, validation.DataChunkPos + 4);
|
||||
|
||||
// For EXTENSIBLE the offset-22 field is the container width; the true sample depth lives in
|
||||
// wValidBitsPerSample (fmtChunkPos + 8 + 18). They usually match (Bandcamp 24-bit = 24/24)
|
||||
// but the valid bits are authoritative for the normalized header and metadata. When they
|
||||
// differ (e.g. 24-bit valid in a 32-bit container) we keep the container width separately so
|
||||
// ValidateAudioParameters can reconcile against the header BlockAlign and NormalizeToStandardPcm
|
||||
// can re-pack the padded frames.
|
||||
var containerBitsPerSample = 0;
|
||||
if (validation.IsExtensible)
|
||||
{
|
||||
var validBits = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 8 + 18);
|
||||
if (validBits != bitsPerSample)
|
||||
{
|
||||
containerBitsPerSample = bitsPerSample;
|
||||
}
|
||||
bitsPerSample = validBits;
|
||||
}
|
||||
|
||||
var duration = byteRate > 0 ? (double)dataSize / byteRate : 0.0;
|
||||
var bitrate = (int)((sampleRate * channels * bitsPerSample) / 1000);
|
||||
|
||||
@@ -151,8 +273,12 @@ public class AudioProcessor
|
||||
SampleRate = (int)sampleRate,
|
||||
Channels = channels,
|
||||
BitsPerSample = bitsPerSample,
|
||||
ContainerBitsPerSample = containerBitsPerSample,
|
||||
BlockAlign = blockAlign,
|
||||
DataSize = (int)dataSize
|
||||
DataSize = (int)dataSize,
|
||||
DataChunkPos = validation.DataChunkPos,
|
||||
IsExtensible = validation.IsExtensible,
|
||||
IsFloat = validation.IsFloat
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,13 +305,140 @@ public class AudioProcessor
|
||||
throw new InvalidDataException($"Unsupported bit depth: {metadata.BitsPerSample}");
|
||||
}
|
||||
|
||||
var expectedBlockAlign = metadata.Channels * (metadata.BitsPerSample / 8);
|
||||
// The header BlockAlign reflects the container width, not the valid bit depth. For a padded
|
||||
// EXTENSIBLE container (e.g. 24-in-32) the container width is authoritative for this check;
|
||||
// NormalizeToStandardPcm re-packs the frames down to the valid depth afterwards.
|
||||
var blockAlignBits = metadata.IsPaddedContainer ? metadata.ContainerBitsPerSample : metadata.BitsPerSample;
|
||||
var expectedBlockAlign = metadata.Channels * (blockAlignBits / 8);
|
||||
if (metadata.BlockAlign != expectedBlockAlign)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid block align: expected {expectedBlockAlign}, got {metadata.BlockAlign}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds an EXTENSIBLE WAV as a canonical 44-byte-header standard PCM WAV (audioFormat = 1)
|
||||
/// so the vault only ever holds a format the streaming pipeline already handles. Three source
|
||||
/// shapes are normalized:
|
||||
/// <list type="bullet">
|
||||
/// <item>EXTENSIBLE-PCM (depth == container): sample bytes are byte-identical to standard PCM and
|
||||
/// copied verbatim; only the header is replaced.</item>
|
||||
/// <item>IEEE float: 32-bit float samples are converted to 24-bit signed integer PCM.</item>
|
||||
/// <item>Padded container (e.g. 24-in-32): the padding/sign-extension bytes are stripped, keeping
|
||||
/// the lowest valid bytes per sample.</item>
|
||||
/// </list>
|
||||
/// The output header always reports the valid bit depth (<see cref="WavMetadata.BitsPerSample"/>).
|
||||
/// </summary>
|
||||
private byte[] NormalizeToStandardPcm(byte[] buffer, WavMetadata metadata)
|
||||
{
|
||||
// Clamp the declared data size to what is actually present; some encoders overshoot.
|
||||
var dataStart = metadata.DataChunkPos + 8;
|
||||
var available = buffer.Length - dataStart;
|
||||
var srcDataSize = Math.Min(metadata.DataSize, available);
|
||||
|
||||
byte[] dataBytes;
|
||||
int outBitsPerSample;
|
||||
if (metadata.IsFloat)
|
||||
{
|
||||
dataBytes = ConvertFloatTo24BitPcm(buffer, dataStart, srcDataSize);
|
||||
outBitsPerSample = 24;
|
||||
}
|
||||
else if (metadata.IsPaddedContainer)
|
||||
{
|
||||
dataBytes = RepackPaddedContainer(buffer, dataStart, srcDataSize, metadata.ContainerBitsPerSample, metadata.BitsPerSample);
|
||||
outBitsPerSample = metadata.BitsPerSample;
|
||||
}
|
||||
else
|
||||
{
|
||||
dataBytes = new byte[srcDataSize];
|
||||
Array.Copy(buffer, dataStart, dataBytes, 0, srcDataSize);
|
||||
outBitsPerSample = metadata.BitsPerSample;
|
||||
}
|
||||
|
||||
var dataSize = dataBytes.Length;
|
||||
const int headerSize = 44;
|
||||
var result = new byte[headerSize + dataSize];
|
||||
|
||||
var blockAlign = (ushort)(metadata.Channels * (outBitsPerSample / 8));
|
||||
var byteRate = (uint)(metadata.SampleRate * blockAlign);
|
||||
|
||||
// RIFF header
|
||||
System.Text.Encoding.ASCII.GetBytes("RIFF").CopyTo(result, 0);
|
||||
BitConverter.GetBytes((uint)(36 + dataSize)).CopyTo(result, 4);
|
||||
System.Text.Encoding.ASCII.GetBytes("WAVE").CopyTo(result, 8);
|
||||
|
||||
// fmt chunk (standard 16-byte PCM)
|
||||
System.Text.Encoding.ASCII.GetBytes("fmt ").CopyTo(result, 12);
|
||||
BitConverter.GetBytes((uint)16).CopyTo(result, 16);
|
||||
BitConverter.GetBytes((ushort)1).CopyTo(result, 20); // audioFormat = PCM
|
||||
BitConverter.GetBytes((ushort)metadata.Channels).CopyTo(result, 22);
|
||||
BitConverter.GetBytes((uint)metadata.SampleRate).CopyTo(result, 24);
|
||||
BitConverter.GetBytes(byteRate).CopyTo(result, 28);
|
||||
BitConverter.GetBytes(blockAlign).CopyTo(result, 32);
|
||||
BitConverter.GetBytes((ushort)outBitsPerSample).CopyTo(result, 34);
|
||||
|
||||
// data chunk
|
||||
System.Text.Encoding.ASCII.GetBytes("data").CopyTo(result, 36);
|
||||
BitConverter.GetBytes((uint)dataSize).CopyTo(result, 40);
|
||||
|
||||
Array.Copy(dataBytes, 0, result, headerSize, dataSize);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts 32-bit little-endian IEEE float samples (range [-1.0, 1.0]) to 24-bit signed PCM.
|
||||
/// Each 4-byte source sample becomes 3 little-endian output bytes; output size is 3/4 of input.
|
||||
/// Trailing bytes that do not form a complete 4-byte sample are ignored.
|
||||
/// </summary>
|
||||
private static byte[] ConvertFloatTo24BitPcm(byte[] buffer, int dataStart, int dataSize)
|
||||
{
|
||||
var sampleCount = dataSize / 4;
|
||||
var output = new byte[sampleCount * 3];
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
var sample = BitConverter.ToSingle(buffer, dataStart + i * 4);
|
||||
var value = (int)(sample * 8388607.0);
|
||||
value = Math.Clamp(value, -8388608, 8388607);
|
||||
|
||||
var o = i * 3;
|
||||
output[o] = (byte)(value & 0xFF);
|
||||
output[o + 1] = (byte)((value >> 8) & 0xFF);
|
||||
output[o + 2] = (byte)((value >> 16) & 0xFF);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips container padding from a padded-container EXTENSIBLE WAV (e.g. 24-bit valid samples
|
||||
/// stored in 32-bit containers), keeping only the lowest <paramref name="validBits"/> bytes of
|
||||
/// each little-endian sample. Output size is (validBits/containerBits) of input.
|
||||
/// Trailing bytes that do not form a complete container sample are ignored.
|
||||
/// </summary>
|
||||
private static byte[] RepackPaddedContainer(byte[] buffer, int dataStart, int dataSize, int containerBits, int validBits)
|
||||
{
|
||||
var containerBytes = containerBits / 8;
|
||||
var validBytes = validBits / 8;
|
||||
var sampleCount = dataSize / containerBytes;
|
||||
var output = new byte[sampleCount * validBytes];
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
var src = dataStart + i * containerBytes;
|
||||
var dst = i * validBytes;
|
||||
// Little-endian: the valid sample occupies the low bytes; the upper bytes are padding /
|
||||
// sign extension and are discarded.
|
||||
for (int b = 0; b < validBytes; b++)
|
||||
{
|
||||
output[dst + b] = buffer[src + b];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default WAV metadata for fallback scenarios
|
||||
/// </summary>
|
||||
@@ -253,9 +506,26 @@ public class AudioProcessor
|
||||
public int Bitrate { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
public int Channels { get; set; }
|
||||
|
||||
/// <summary>The valid sample depth — for EXTENSIBLE, wValidBitsPerSample.</summary>
|
||||
public int BitsPerSample { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The container sample width for a padded EXTENSIBLE WAV whose valid depth is narrower
|
||||
/// (e.g. 32 for a 24-in-32 file). Zero when the container matches the valid depth.
|
||||
/// </summary>
|
||||
public int ContainerBitsPerSample { get; set; }
|
||||
|
||||
public int BlockAlign { get; set; }
|
||||
public int DataSize { get; set; }
|
||||
public int DataChunkPos { get; set; }
|
||||
public bool IsExtensible { get; set; }
|
||||
|
||||
/// <summary>True when the SubFormat is IEEE float (converted to 24-bit PCM on normalization).</summary>
|
||||
public bool IsFloat { get; set; }
|
||||
|
||||
/// <summary>True when valid samples are stored in a wider container that must be re-packed.</summary>
|
||||
public bool IsPaddedContainer => ContainerBitsPerSample != 0 && ContainerBitsPerSample != BitsPerSample;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -267,5 +537,23 @@ public class AudioProcessor
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
public int FmtChunkPos { get; set; }
|
||||
public int DataChunkPos { get; set; }
|
||||
public bool IsExtensible { get; set; }
|
||||
|
||||
/// <summary>True when the EXTENSIBLE SubFormat is IEEE float rather than PCM.</summary>
|
||||
public bool IsFloat { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The raw PCM sample region of a WAV plus the format parameters needed to interpret it.
|
||||
/// <see cref="Pcm"/> is a view over the decoded buffer — the data chunk only, header excluded.
|
||||
/// </summary>
|
||||
/// <param name="Pcm">The PCM sample bytes (interleaved by channel, little-endian).</param>
|
||||
/// <param name="Channels">Number of interleaved channels.</param>
|
||||
/// <param name="SampleRate">Samples per second.</param>
|
||||
/// <param name="BitsPerSample">Bit depth per sample (8, 16, 24, or 32).</param>
|
||||
public readonly record struct PcmData(
|
||||
ReadOnlyMemory<byte> Pcm,
|
||||
int Channels,
|
||||
int SampleRate,
|
||||
int BitsPerSample);
|
||||
@@ -0,0 +1,42 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches an audio file to the correct format processor by extension. The single seam through
|
||||
/// which <see cref="TrackContentService"/> processes uploads, so callers depend on one abstraction
|
||||
/// rather than three concrete processors.
|
||||
/// </summary>
|
||||
public class AudioProcessorRouter
|
||||
{
|
||||
private readonly AudioProcessor _wavProcessor;
|
||||
private readonly Mp3AudioProcessor _mp3Processor;
|
||||
private readonly FlacAudioProcessor _flacProcessor;
|
||||
|
||||
public AudioProcessorRouter(
|
||||
AudioProcessor wavProcessor,
|
||||
Mp3AudioProcessor mp3Processor,
|
||||
FlacAudioProcessor flacProcessor)
|
||||
{
|
||||
_wavProcessor = wavProcessor;
|
||||
_mp3Processor = mp3Processor;
|
||||
_flacProcessor = flacProcessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes <paramref name="filePath"/> with the processor matching its extension, returning an
|
||||
/// <see cref="AudioBinary"/> carrying the stored bytes and extracted metadata. Throws
|
||||
/// <see cref="ArgumentException"/> for unsupported extensions.
|
||||
/// </summary>
|
||||
public async Task<AudioBinary?> ProcessAudioFileAsync(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".wav" => await _wavProcessor.ProcessWavFileAsync(filePath),
|
||||
".mp3" => await _mp3Processor.ProcessMp3FileAsync(filePath),
|
||||
".flac" => await _flacProcessor.ProcessFlacFileAsync(filePath),
|
||||
_ => throw new ArgumentException($"Unsupported audio format: {ext}", nameof(filePath)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from a FLAC file and wraps its <b>unmodified</b> bytes in an
|
||||
/// <see cref="AudioBinary"/> tagged <c>.flac</c>. No transcoding — the vault stores the original
|
||||
/// stream; duration and average bitrate come from the mandatory STREAMINFO metadata block.
|
||||
/// </summary>
|
||||
public class FlacAudioProcessor
|
||||
{
|
||||
private const double FallbackDuration = 180.0;
|
||||
private const int FallbackBitrate = 1411;
|
||||
|
||||
public async Task<AudioBinary?> ProcessFlacFileAsync(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"FLAC file not found: {filePath}");
|
||||
}
|
||||
|
||||
if (!Path.GetExtension(filePath).Equals(".flac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("File must be a FLAC file", nameof(filePath));
|
||||
}
|
||||
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var meta = ExtractFlacMetadata(buffer);
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Extension: ".flac",
|
||||
Duration: meta.Duration,
|
||||
Bitrate: meta.Bitrate);
|
||||
|
||||
return new AudioBinary(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the <c>fLaC</c> magic and the leading STREAMINFO block, then computes duration from
|
||||
/// total-samples / sample-rate and average bitrate from file size. On any parse failure, logs a
|
||||
/// warning and returns synthetic defaults — never throws.
|
||||
/// </summary>
|
||||
private static FlacMetadata ExtractFlacMetadata(byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Magic (4) + metadata block header (4) + STREAMINFO data (34) = 42 bytes minimum.
|
||||
if (buffer.Length < 42)
|
||||
{
|
||||
throw new InvalidDataException("File too short for FLAC STREAMINFO");
|
||||
}
|
||||
|
||||
if (buffer[0] != 'f' || buffer[1] != 'L' || buffer[2] != 'a' || buffer[3] != 'C')
|
||||
{
|
||||
throw new InvalidDataException("Invalid fLaC magic");
|
||||
}
|
||||
|
||||
// Metadata block header at offset 4: bits 6-0 of byte 0 are the block type (0 = STREAMINFO).
|
||||
var blockType = buffer[4] & 0x7F;
|
||||
if (blockType != 0)
|
||||
{
|
||||
throw new InvalidDataException($"First metadata block is not STREAMINFO (type {blockType})");
|
||||
}
|
||||
|
||||
// STREAMINFO data begins at offset 8. Layout (bit-packed, big-endian):
|
||||
// bytes 10-12 + top nibble of 13: sample rate (20 bits)
|
||||
// bits 3-1 of byte 12: channels - 1
|
||||
// bit 0 of byte 12 + top 4 bits of byte 13: bits per sample - 1
|
||||
// low nibble of byte 13 + bytes 14-17: total samples (36 bits)
|
||||
var d = 8;
|
||||
var sampleRate = (buffer[d + 10] << 12) | (buffer[d + 11] << 4) | (buffer[d + 12] >> 4);
|
||||
var totalSamples = ((long)(buffer[d + 13] & 0x0F) << 32)
|
||||
| ((long)buffer[d + 14] << 24)
|
||||
| ((long)buffer[d + 15] << 16)
|
||||
| ((long)buffer[d + 16] << 8)
|
||||
| buffer[d + 17];
|
||||
|
||||
if (sampleRate <= 0)
|
||||
{
|
||||
throw new InvalidDataException("Invalid FLAC sample rate");
|
||||
}
|
||||
|
||||
var duration = (double)totalSamples / sampleRate;
|
||||
var bitrate = duration > 0
|
||||
? (int)(buffer.LongLength * 8L / (duration * 1000))
|
||||
: FallbackBitrate;
|
||||
|
||||
return new FlacMetadata { Duration = duration, Bitrate = bitrate };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: FLAC parsing failed, using defaults: {ex.Message}");
|
||||
return new FlacMetadata { Duration = FallbackDuration, Bitrate = FallbackBitrate };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FlacMetadata
|
||||
{
|
||||
public double Duration { get; init; }
|
||||
public int Bitrate { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for reducing a stream of PCM samples to a fixed-length, peak-normalized loudness
|
||||
/// envelope. Swappable so the loudness measure (RMS today, LUFS later) can change without
|
||||
/// touching <c>WaveformProfileService</c>, the stored wire format, or the frontend renderer.
|
||||
/// </summary>
|
||||
public interface ILoudnessAlgorithm
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a peak-normalized loudness profile from raw interleaved PCM.
|
||||
/// </summary>
|
||||
/// <param name="pcmData">Interleaved, little-endian PCM sample bytes (the WAV data chunk).</param>
|
||||
/// <param name="channels">Number of interleaved channels; averaged to mono per sample.</param>
|
||||
/// <param name="sampleRate">Samples per second (unused by RMS but part of the contract for measures that need it).</param>
|
||||
/// <param name="bitsPerSample">Bit depth (8 unsigned, 16/24/32 signed) used to decode samples.</param>
|
||||
/// <param name="bucketCount">Number of equal time slices to reduce the signal to.</param>
|
||||
/// <returns>
|
||||
/// A <c>double[bucketCount]</c>, each value in [0, 1], peak-normalized so the loudest bucket
|
||||
/// is 1. All zeros when the signal is silent (peak is 0) or no samples are present.
|
||||
/// </returns>
|
||||
double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Buffers.Binary;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Processes raw image bytes into an <see cref="ImageBinary"/>, mirroring the shape of
|
||||
/// <see cref="AudioProcessor"/>. Validates the content type resolves to a known image
|
||||
/// extension, derives the aspect ratio from the image dimensions where cheaply parseable
|
||||
/// (PNG, JPEG), and defaults to 1.0 for formats whose headers we don't parse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Operates entirely in memory — no disk I/O. Follows the FileDatabase error-handling
|
||||
/// philosophy: dimension parsing logs a warning and falls back to a best-effort aspect
|
||||
/// ratio of 1.0 rather than throwing. Content-type rejection is a caller-facing validation
|
||||
/// failure (returns null), distinct from a parse hiccup.
|
||||
/// </remarks>
|
||||
public class ImageProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an <see cref="ImageBinary"/> from raw image bytes and a MIME content type.
|
||||
/// Returns null when the content type does not resolve to a recognised image extension
|
||||
/// (the <c>.bin</c> sentinel from <see cref="MimeTypeExtensions.GetExtension"/>).
|
||||
/// </summary>
|
||||
public ImageBinary? Process(byte[] imageBytes, string contentType)
|
||||
{
|
||||
var extension = MimeTypeExtensions.GetExtension(contentType);
|
||||
if (extension == ".bin")
|
||||
{
|
||||
Console.WriteLine($"Warning: ImageProcessor rejected unsupported content type '{contentType}'");
|
||||
return null;
|
||||
}
|
||||
|
||||
var aspectRatio = ComputeAspectRatio(imageBytes, extension);
|
||||
|
||||
var parameters = new ImageBinaryParams(
|
||||
Buffer: imageBytes,
|
||||
Size: imageBytes.Length,
|
||||
Extension: extension,
|
||||
AspectRatio: aspectRatio);
|
||||
|
||||
return new ImageBinary(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives width/height from the format header and returns width/height. Defaults to 1.0
|
||||
/// for unparsed formats (gif, webp, bmp, svg) and on any parse failure.
|
||||
/// </summary>
|
||||
private static double ComputeAspectRatio(byte[] bytes, string extension)
|
||||
{
|
||||
try
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
".png" => ParsePngAspectRatio(bytes),
|
||||
".jpg" or ".jpeg" => ParseJpegAspectRatio(bytes),
|
||||
_ => 1.0,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: image dimension parsing failed for '{extension}', defaulting aspect ratio to 1.0: {ex.Message}");
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PNG: the IHDR chunk places width at bytes 16–19 and height at 20–23, both big-endian
|
||||
/// uint32. Guards on the "PNG" signature at bytes 1–3.
|
||||
/// </summary>
|
||||
private static double ParsePngAspectRatio(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length < 24 || bytes[1] != 'P' || bytes[2] != 'N' || bytes[3] != 'G')
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
var width = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(16, 4));
|
||||
var height = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(20, 4));
|
||||
return Ratio(width, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JPEG: walk the marker segments from byte 2 looking for SOF0 (0xFF 0xC0) or SOF2
|
||||
/// (0xFF 0xC2). Height is a big-endian uint16 at marker+5, width at marker+7. Guards on
|
||||
/// the SOI marker (0xFF 0xD8) at bytes 0–1.
|
||||
/// </summary>
|
||||
private static double ParseJpegAspectRatio(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
var pos = 2;
|
||||
while (pos + 9 < bytes.Length)
|
||||
{
|
||||
// Marker segments begin with 0xFF; skip any fill bytes before the marker id.
|
||||
if (bytes[pos] != 0xFF)
|
||||
{
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var marker = bytes[pos + 1];
|
||||
if (marker == 0xC0 || marker == 0xC2)
|
||||
{
|
||||
var height = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 5, 2));
|
||||
var width = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 7, 2));
|
||||
return Ratio(width, height);
|
||||
}
|
||||
|
||||
// Standalone markers (RSTn, SOI, EOI, TEM) carry no length payload; everything
|
||||
// else has a 2-byte big-endian segment length immediately after the marker id.
|
||||
if (marker is 0xD8 or 0xD9 or 0x01 || (marker >= 0xD0 && marker <= 0xD7))
|
||||
{
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
var segmentLength = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 2, 2));
|
||||
pos += 2 + segmentLength;
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static double Ratio(uint width, uint height) => height == 0 ? 1.0 : (double)width / height;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from an MP3 file and wraps its <b>unmodified</b> bytes in an
|
||||
/// <see cref="AudioBinary"/> tagged <c>.mp3</c>. No transcoding — the vault stores the original
|
||||
/// stream; only duration/bitrate metadata are computed from the first MPEG frame header (plus a
|
||||
/// Xing/VBRI tag when present for accurate VBR duration).
|
||||
/// </summary>
|
||||
public class Mp3AudioProcessor
|
||||
{
|
||||
// MPEG1 Layer III bitrate table (kbps), indexed by the 4-bit bitrate index. 0 = free, 15 = bad.
|
||||
private static readonly int[] Mpeg1Layer3Bitrates =
|
||||
[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
|
||||
|
||||
// MPEG2/2.5 Layer III bitrate table (kbps), indexed by 4-bit bitrate index. 0 = free, 15 = bad.
|
||||
private static readonly int[] Mpeg2Layer3Bitrates =
|
||||
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
|
||||
|
||||
private static readonly int[] Mpeg1SampleRates = [44100, 48000, 32000];
|
||||
private static readonly int[] Mpeg2SampleRates = [22050, 24000, 16000];
|
||||
private static readonly int[] Mpeg25SampleRates = [11025, 12000, 8000];
|
||||
|
||||
private const double FallbackDuration = 180.0;
|
||||
private const int FallbackBitrate = 320;
|
||||
|
||||
public async Task<AudioBinary?> ProcessMp3FileAsync(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"MP3 file not found: {filePath}");
|
||||
}
|
||||
|
||||
if (!Path.GetExtension(filePath).Equals(".mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("File must be an MP3 file", nameof(filePath));
|
||||
}
|
||||
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var meta = ExtractMp3Metadata(buffer);
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Extension: ".mp3",
|
||||
Duration: meta.Duration,
|
||||
Bitrate: meta.Bitrate);
|
||||
|
||||
return new AudioBinary(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the first valid MPEG frame (after any ID3v2 tag) and any Xing/VBRI tag inside it.
|
||||
/// On any parse failure, logs a warning and returns synthetic defaults — never throws.
|
||||
/// </summary>
|
||||
private static Mp3Metadata ExtractMp3Metadata(byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frameStart = FindFirstFrame(buffer);
|
||||
if (frameStart < 0)
|
||||
{
|
||||
throw new InvalidDataException("No valid MPEG frame sync found");
|
||||
}
|
||||
|
||||
var header = DecodeFrameHeader(buffer, frameStart);
|
||||
var duration = ComputeDuration(buffer, frameStart, header);
|
||||
|
||||
return new Mp3Metadata { Duration = duration, Bitrate = header.BitrateKbps };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: MP3 parsing failed, using defaults: {ex.Message}");
|
||||
return new Mp3Metadata { Duration = FallbackDuration, Bitrate = FallbackBitrate };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the offset of the first valid MPEG frame, skipping a leading ID3v2 tag if present.
|
||||
/// Scans for a 0xFF / 0xE0-syncword pair and fully validates the 4-byte header before accepting.
|
||||
/// </summary>
|
||||
private static int FindFirstFrame(byte[] buffer)
|
||||
{
|
||||
var start = SkipId3v2(buffer);
|
||||
|
||||
for (int i = start; i < buffer.Length - 4; i++)
|
||||
{
|
||||
if (buffer[i] != 0xFF || (buffer[i + 1] & 0xE0) != 0xE0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsValidFrameHeader(buffer, i))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the byte offset just past an ID3v2 tag, or 0 if none. The tag size is a syncsafe
|
||||
/// big-endian uint28 at bytes 6–9 (each byte's MSB is 0). A footer (flag bit 4 of byte 5) adds 10.
|
||||
/// </summary>
|
||||
private static int SkipId3v2(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 10 || buffer[0] != 'I' || buffer[1] != 'D' || buffer[2] != '3')
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var size = (buffer[6] << 21) | (buffer[7] << 14) | (buffer[8] << 7) | buffer[9];
|
||||
var skip = 10 + size;
|
||||
if ((buffer[5] & 0x10) != 0)
|
||||
{
|
||||
skip += 10; // footer present
|
||||
}
|
||||
|
||||
return skip <= buffer.Length ? skip : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fully validates a candidate 4-byte frame header: layer must be III, and version, bitrate
|
||||
/// index, and sample-rate index must all be non-reserved (rejects free bitrate, bad index 0xF,
|
||||
/// and reserved sample rate 3).
|
||||
/// </summary>
|
||||
private static bool IsValidFrameHeader(byte[] buffer, int pos)
|
||||
{
|
||||
var b1 = buffer[pos + 1];
|
||||
var b2 = buffer[pos + 2];
|
||||
|
||||
var versionBits = (b1 >> 3) & 0x03;
|
||||
if (versionBits == 1) // 1 = reserved
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var layerBits = (b1 >> 1) & 0x03;
|
||||
if (layerBits != 1) // 1 = Layer III; this processor handles Layer III only
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bitrateIndex = (b2 >> 4) & 0x0F;
|
||||
if (bitrateIndex == 0 || bitrateIndex == 0x0F) // 0 = free, 0xF = bad
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sampleRateIndex = (b2 >> 2) & 0x03;
|
||||
if (sampleRateIndex == 3) // reserved
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static FrameHeader DecodeFrameHeader(byte[] buffer, int pos)
|
||||
{
|
||||
var b1 = buffer[pos + 1];
|
||||
var b2 = buffer[pos + 2];
|
||||
var b3 = buffer[pos + 3];
|
||||
|
||||
var versionBits = (b1 >> 3) & 0x03;
|
||||
var version = versionBits switch
|
||||
{
|
||||
3 => MpegVersion.Mpeg1,
|
||||
2 => MpegVersion.Mpeg2,
|
||||
_ => MpegVersion.Mpeg25, // 0 = MPEG2.5
|
||||
};
|
||||
|
||||
var bitrateIndex = (b2 >> 4) & 0x0F;
|
||||
var bitrateTable = version == MpegVersion.Mpeg1 ? Mpeg1Layer3Bitrates : Mpeg2Layer3Bitrates;
|
||||
var bitrateKbps = bitrateTable[bitrateIndex];
|
||||
|
||||
var sampleRateIndex = (b2 >> 2) & 0x03;
|
||||
var sampleRate = version switch
|
||||
{
|
||||
MpegVersion.Mpeg1 => Mpeg1SampleRates[sampleRateIndex],
|
||||
MpegVersion.Mpeg2 => Mpeg2SampleRates[sampleRateIndex],
|
||||
_ => Mpeg25SampleRates[sampleRateIndex],
|
||||
};
|
||||
|
||||
var channelMode = (b3 >> 6) & 0x03;
|
||||
var channels = channelMode == 3 ? 1 : 2;
|
||||
var samplesPerFrame = version == MpegVersion.Mpeg1 ? 1152 : 576;
|
||||
|
||||
return new FrameHeader
|
||||
{
|
||||
Version = version,
|
||||
BitrateKbps = bitrateKbps,
|
||||
SampleRate = sampleRate,
|
||||
Channels = channels,
|
||||
SamplesPerFrame = samplesPerFrame,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes duration from a Xing/Info or VBRI tag (accurate for VBR) when present; otherwise
|
||||
/// falls back to the CBR estimate fileSize / (bitrate_kbps * 125). Guards divide-by-zero.
|
||||
/// </summary>
|
||||
private static double ComputeDuration(byte[] buffer, int frameStart, FrameHeader header)
|
||||
{
|
||||
var xingFrames = ReadXingFrameCount(buffer, frameStart, header);
|
||||
if (xingFrames > 0 && header.SampleRate > 0)
|
||||
{
|
||||
return (double)xingFrames * header.SamplesPerFrame / header.SampleRate;
|
||||
}
|
||||
|
||||
var vbriFrames = ReadVbriFrameCount(buffer, frameStart);
|
||||
if (vbriFrames > 0 && header.SampleRate > 0)
|
||||
{
|
||||
return (double)vbriFrames * header.SamplesPerFrame / header.SampleRate;
|
||||
}
|
||||
|
||||
// CBR fallback: bitrate_kbps * 1000 / 8 bytes per second = bitrate_kbps * 125.
|
||||
// Exclude the ID3v2 tag bytes (everything before frameStart) from the estimate.
|
||||
var bytesPerSecond = header.BitrateKbps * 125;
|
||||
return bytesPerSecond > 0 ? (double)(buffer.Length - frameStart) / bytesPerSecond : FallbackDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Xing/Info VBR total-frame count from the side-information region of the first frame,
|
||||
/// or 0 if no Xing tag or no frame-count flag. Side-info offset depends on version and channels.
|
||||
/// </summary>
|
||||
private static int ReadXingFrameCount(byte[] buffer, int frameStart, FrameHeader header)
|
||||
{
|
||||
var sideInfoSize = header.Version == MpegVersion.Mpeg1
|
||||
? (header.Channels == 1 ? 17 : 32)
|
||||
: (header.Channels == 1 ? 9 : 17);
|
||||
|
||||
var tagPos = frameStart + 4 + sideInfoSize;
|
||||
if (tagPos + 12 > buffer.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!MatchesAscii(buffer, tagPos, "Xing") && !MatchesAscii(buffer, tagPos, "Info"))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var flags = ReadUInt32BigEndian(buffer, tagPos + 4);
|
||||
if ((flags & 0x01) == 0) // bit 0 = frame-count present
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)ReadUInt32BigEndian(buffer, tagPos + 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Fraunhofer VBRI total-frame count. The VBRI tag sits at a fixed offset 32 past the
|
||||
/// frame header (frameStart + 4 + 32); the frame count is a big-endian uint32 at tag offset 14.
|
||||
/// </summary>
|
||||
private static int ReadVbriFrameCount(byte[] buffer, int frameStart)
|
||||
{
|
||||
var tagPos = frameStart + 4 + 32;
|
||||
if (tagPos + 18 > buffer.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!MatchesAscii(buffer, tagPos, "VBRI"))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)ReadUInt32BigEndian(buffer, tagPos + 14);
|
||||
}
|
||||
|
||||
private static bool MatchesAscii(byte[] buffer, int pos, string tag)
|
||||
{
|
||||
for (int i = 0; i < tag.Length; i++)
|
||||
{
|
||||
if (buffer[pos + i] != (byte)tag[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static uint ReadUInt32BigEndian(byte[] buffer, int pos) =>
|
||||
((uint)buffer[pos] << 24) | ((uint)buffer[pos + 1] << 16) | ((uint)buffer[pos + 2] << 8) | buffer[pos + 3];
|
||||
|
||||
private enum MpegVersion
|
||||
{
|
||||
Mpeg1,
|
||||
Mpeg2,
|
||||
Mpeg25,
|
||||
}
|
||||
|
||||
private sealed class FrameHeader
|
||||
{
|
||||
public MpegVersion Version { get; init; }
|
||||
public int BitrateKbps { get; init; }
|
||||
public int SampleRate { get; init; }
|
||||
public int Channels { get; init; }
|
||||
public int SamplesPerFrame { get; init; }
|
||||
}
|
||||
|
||||
private sealed class Mp3Metadata
|
||||
{
|
||||
public double Duration { get; init; }
|
||||
public int Bitrate { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned,
|
||||
/// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into
|
||||
/// equal time slices, takes the RMS of each slice, applies a ~15 ms envelope-follower smoothing
|
||||
/// so the contour reads as a smooth curve rather than a spikey polygon, then peak-normalizes so
|
||||
/// the loudest bucket is 1. No external audio dependency — operates directly on the WAV data-chunk bytes.
|
||||
/// </summary>
|
||||
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
|
||||
{
|
||||
/// <summary>
|
||||
/// Envelope-follower time constant, seconds. ~15 ms is the smoothing target (Phase 10
|
||||
/// tuning, reduced from 50 ms which was over-smoothed): long enough to round off the
|
||||
/// per-bucket RMS spikes into a smooth ribbon contour, short enough that real loudness
|
||||
/// transients (kicks, drops) still read. Applied as a symmetric (forward+backward) one-pole
|
||||
/// filter so the smoothing introduces no time lag.
|
||||
/// </summary>
|
||||
public const double SmoothingTimeConstantSeconds = 0.005;
|
||||
|
||||
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
|
||||
{
|
||||
if (bucketCount <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bucketCount), "Bucket count must be positive.");
|
||||
}
|
||||
|
||||
var result = new double[bucketCount];
|
||||
|
||||
if (channels <= 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var bytesPerSample = bitsPerSample / 8;
|
||||
if (bytesPerSample <= 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var bytesPerFrame = bytesPerSample * channels;
|
||||
var frameCount = pcmData.Length / bytesPerFrame;
|
||||
if (frameCount == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sum of squared mono amplitudes and the frame count, per bucket. A frame's bucket is
|
||||
// determined by its position in the timeline so buckets are equal-duration slices.
|
||||
var sumSquares = new double[bucketCount];
|
||||
var counts = new long[bucketCount];
|
||||
|
||||
for (var frame = 0; frame < frameCount; frame++)
|
||||
{
|
||||
var frameStart = frame * bytesPerFrame;
|
||||
|
||||
double channelSum = 0;
|
||||
for (var ch = 0; ch < channels; ch++)
|
||||
{
|
||||
var sampleStart = frameStart + ch * bytesPerSample;
|
||||
channelSum += ReadSampleNormalized(pcmData, sampleStart, bitsPerSample);
|
||||
}
|
||||
|
||||
var mono = channelSum / channels;
|
||||
|
||||
// long math avoids overflow on large files before the divide back into bucket index.
|
||||
var bucket = (int)((long)frame * bucketCount / frameCount);
|
||||
if (bucket >= bucketCount)
|
||||
{
|
||||
bucket = bucketCount - 1;
|
||||
}
|
||||
|
||||
sumSquares[bucket] += mono * mono;
|
||||
counts[bucket]++;
|
||||
}
|
||||
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
if (counts[i] > 0)
|
||||
{
|
||||
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope smoothing (~15 ms): round the spikey per-bucket RMS into a smooth contour before
|
||||
// peak-normalization, so the rendered ribbon reads as a continuous curve, not faceted polygons.
|
||||
// Each bucket spans (totalSeconds / bucketCount) of audio; the filter coefficient is derived
|
||||
// from that against the time constant so the smoothing is duration-aware, not a fixed window.
|
||||
var totalSeconds = (double)frameCount / sampleRate;
|
||||
var bucketSeconds = totalSeconds / bucketCount;
|
||||
SmoothEnvelope(result, bucketSeconds);
|
||||
|
||||
var peak = 0.0;
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
if (result[i] > peak)
|
||||
{
|
||||
peak = result[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (peak <= 0)
|
||||
{
|
||||
// Silence — return all zeros (Array is already zero-initialized).
|
||||
Array.Clear(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
result[i] /= peak;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symmetric one-pole envelope smoothing over the per-bucket loudness, in place. A forward pass
|
||||
/// then a backward pass cancels the single-pole phase lag, so the smoothed contour stays aligned
|
||||
/// with the audio (no rightward time shift). The coefficient <c>a = exp(−bucketSeconds / τ)</c>
|
||||
/// gives a ~<paramref name="bucketSeconds"/>-relative response targeting the ~15 ms time constant:
|
||||
/// each bucket blends <c>(1 − a)</c> of itself with <c>a</c> of the running envelope. A near-zero
|
||||
/// or non-finite bucket duration leaves the data untouched (nothing to smooth meaningfully).
|
||||
/// </summary>
|
||||
private static void SmoothEnvelope(double[] data, double bucketSeconds)
|
||||
{
|
||||
if (data.Length < 2 || bucketSeconds <= 0 || !double.IsFinite(bucketSeconds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var a = Math.Exp(-bucketSeconds / SmoothingTimeConstantSeconds);
|
||||
// a→1 means buckets are far finer than τ (heavy smoothing); a→0 means each bucket already
|
||||
// spans ≫ τ, so smoothing is a no-op. Either extreme is handled by the blend below.
|
||||
|
||||
// Forward pass.
|
||||
var env = data[0];
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
env = a * env + (1 - a) * data[i];
|
||||
data[i] = env;
|
||||
}
|
||||
|
||||
// Backward pass (zero-phase): smooth the forward result in reverse so the net lag is zero.
|
||||
env = data[^1];
|
||||
for (var i = data.Length - 1; i >= 0; i--)
|
||||
{
|
||||
env = a * env + (1 - a) * data[i];
|
||||
data[i] = env;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1].
|
||||
/// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian.
|
||||
/// </summary>
|
||||
private static double ReadSampleNormalized(ReadOnlySpan<byte> data, int offset, int bitsPerSample)
|
||||
{
|
||||
switch (bitsPerSample)
|
||||
{
|
||||
case 8:
|
||||
// Unsigned, midpoint 128.
|
||||
return (data[offset] - 128) / 128.0;
|
||||
|
||||
case 16:
|
||||
{
|
||||
short sample = (short)(data[offset] | (data[offset + 1] << 8));
|
||||
return sample / 32768.0;
|
||||
}
|
||||
|
||||
case 24:
|
||||
{
|
||||
// Sign-extend the 24-bit little-endian value into an int.
|
||||
int raw = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16);
|
||||
if ((raw & 0x800000) != 0)
|
||||
{
|
||||
raw |= unchecked((int)0xFF000000);
|
||||
}
|
||||
return raw / 8388608.0;
|
||||
}
|
||||
|
||||
case 32:
|
||||
{
|
||||
int sample = data[offset]
|
||||
| (data[offset + 1] << 8)
|
||||
| (data[offset + 2] << 16)
|
||||
| (data[offset + 3] << 24);
|
||||
return sample / 2147483648.0;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(bitsPerSample), bitsPerSample, "Unsupported PCM bit depth.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for waveform loudness profiling. <see cref="BucketCount"/> is the stored
|
||||
/// resolution — the number of loudness buckets computed and persisted per track, which is also
|
||||
/// the bar count the frontend WaveformSeeker renders.
|
||||
/// </summary>
|
||||
public class WaveformProfileOptions
|
||||
{
|
||||
public int BucketCount { get; set; } = 512;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a track's waveform loudness profile from its WAV bytes and persists it as a sidecar
|
||||
/// in the <see cref="VaultConstants.WaveformProfiles"/> vault, keyed by the track's EntryKey.
|
||||
/// The profile is the upload-time, off-the-playback-path representation the frontend fetches to
|
||||
/// render the WaveformSeeker. The loudness measure is injected (<see cref="ILoudnessAlgorithm"/>)
|
||||
/// so it can be swapped without changing storage or the wire format.
|
||||
/// </summary>
|
||||
public class WaveformProfileService
|
||||
{
|
||||
private const string ProfileExtension = ".wfp";
|
||||
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly AudioProcessor _audioProcessor;
|
||||
private readonly ILoudnessAlgorithm _loudnessAlgorithm;
|
||||
private readonly WaveformProfileOptions _options;
|
||||
private readonly ILogger<WaveformProfileService> _logger;
|
||||
|
||||
public WaveformProfileService(
|
||||
FileDb fileDatabase,
|
||||
AudioProcessor audioProcessor,
|
||||
ILoudnessAlgorithm loudnessAlgorithm,
|
||||
IOptions<WaveformProfileOptions> options,
|
||||
ILogger<WaveformProfileService> logger)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_audioProcessor = audioProcessor;
|
||||
_loudnessAlgorithm = loudnessAlgorithm;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the loudness profile from <paramref name="wavBytes"/> and stores it under
|
||||
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
|
||||
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
|
||||
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
|
||||
/// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum
|
||||
/// derives its count from the audio duration (≈333 samples/sec, see <c>WaveformResolution</c>) so long
|
||||
/// tracks are not under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
|
||||
/// does not itself decide the count. Returns false (and logs) on any
|
||||
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
|
||||
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
|
||||
/// </summary>
|
||||
public async Task<bool> ComputeAndStoreAsync(
|
||||
ReadOnlyMemory<byte> wavBytes,
|
||||
string entryKey,
|
||||
int? bucketCount = null,
|
||||
string? vaultName = null)
|
||||
{
|
||||
var effectiveBucketCount = bucketCount ?? _options.BucketCount;
|
||||
var effectiveVaultName = vaultName ?? VaultConstants.WaveformProfiles;
|
||||
|
||||
try
|
||||
{
|
||||
var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span);
|
||||
if (pcm is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform profile not computed for {EntryKey}: WAV PCM could not be extracted.",
|
||||
entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = pcm.Value;
|
||||
var profile = _loudnessAlgorithm.Compute(
|
||||
value.Pcm.Span,
|
||||
value.Channels,
|
||||
value.SampleRate,
|
||||
value.BitsPerSample,
|
||||
effectiveBucketCount);
|
||||
|
||||
var quantized = Quantize(profile);
|
||||
|
||||
await EnsureVaultAsync(effectiveVaultName);
|
||||
|
||||
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(effectiveVaultName, entryKey, binary);
|
||||
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogWarning("Waveform profile vault write failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Waveform profile computation failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a track's high-resolution loudness datum and stores it in the
|
||||
/// <see cref="VaultConstants.TrackWaveforms"/> vault keyed by <paramref name="entryKey"/>. The bucket
|
||||
/// count is duration-derived (≈333 samples/sec, clamped — see <see cref="WaveformResolution"/>) so the
|
||||
/// datum captures at a constant time resolution regardless of track length. This is the single home
|
||||
/// for "the high-res per-track datum" — the upload path, the CMS generate action, and the Mix trigger
|
||||
/// all funnel through it, so every track (Mix, Session, Cut) gets an identical datum keyed the same way.
|
||||
/// Returns false (logged) on any failure, per the content-agnostic contract above.
|
||||
/// </summary>
|
||||
public Task<bool> ComputeAndStoreHighResAsync(
|
||||
ReadOnlyMemory<byte> wavBytes,
|
||||
string entryKey,
|
||||
double durationSeconds)
|
||||
{
|
||||
var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds);
|
||||
return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
|
||||
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
|
||||
/// is stored (existing tracks predate profiling, and computation may have failed). Each byte is
|
||||
/// a peak-normalized loudness value in [0, 255].
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetProfileAsync(string entryKey, string? vaultName = null)
|
||||
{
|
||||
var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>(
|
||||
vaultName ?? VaultConstants.WaveformProfiles, entryKey);
|
||||
return binary?.Buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps each [0, 1] bucket to a [0, 255] byte. 1.0 maps to 255; the multiply-by-255 with a
|
||||
/// truncating cast keeps every in-range value within a byte without a clamp branch.
|
||||
/// </summary>
|
||||
private static byte[] Quantize(double[] profile)
|
||||
{
|
||||
var bytes = new byte[profile.Length];
|
||||
for (var i = 0; i < profile.Length; i++)
|
||||
{
|
||||
bytes[i] = (byte)(profile[i] * 255);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task EnsureVaultAsync(string vaultName)
|
||||
{
|
||||
if (!_fileDatabase.HasVault(vaultName))
|
||||
{
|
||||
await _fileDatabase.CreateVaultAsync(vaultName, MediaVaultType.Media);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the bucket count for a track's high-resolution loudness datum from the audio's duration, so
|
||||
/// the stored profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
|
||||
/// Applies to every track (Mix, Session, Cut) — the release is just the host (phase-12 §5).
|
||||
///
|
||||
/// Rationale (phase-9 Mix Visualizer redesign spec §F): the max-zoom window shows one quarter note
|
||||
/// at 180 BPM = 333 ms of audio, and a smooth glassy curve wants ~100+ sample points across that
|
||||
/// window. A fixed 2048-bucket datum gives fractions of a sample per 333 ms window on any real-length
|
||||
/// audio (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
|
||||
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of
|
||||
/// length — the direct expression of "high enough resolution regardless of content length."
|
||||
///
|
||||
/// This is the orchestration-side derivation (duration → bucket count); the actual compute/store stays
|
||||
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
|
||||
/// </summary>
|
||||
public static class WaveformResolution
|
||||
{
|
||||
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
|
||||
public const int SamplesPerSecond = 333;
|
||||
|
||||
/// <summary>
|
||||
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute track at 333/s). Past this length we
|
||||
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
|
||||
/// </summary>
|
||||
public const int MaxBucketCount = 2_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
|
||||
/// near-zero or very-short track still yields a usable profile rather than zero/handful of buckets.
|
||||
/// </summary>
|
||||
public const int MinBucketCount = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
|
||||
/// clamped to [<see cref="MinBucketCount"/>, <see cref="MaxBucketCount"/>]. Non-finite or negative
|
||||
/// durations fall to the floor. A 60-minute track → ~1.2M buckets; a 3-minute track → ~60k.
|
||||
/// </summary>
|
||||
public static int BucketCountForDuration(double durationSeconds)
|
||||
{
|
||||
if (double.IsNaN(durationSeconds) || durationSeconds <= 0)
|
||||
return MinBucketCount;
|
||||
|
||||
// Guard against overflow before the cast: anything at/above the cap clamps anyway.
|
||||
var raw = Math.Ceiling(durationSeconds * SamplesPerSecond);
|
||||
if (raw >= MaxBucketCount)
|
||||
return MaxBucketCount;
|
||||
|
||||
var buckets = (int)raw;
|
||||
return buckets < MinBucketCount ? MinBucketCount : buckets;
|
||||
}
|
||||
}
|
||||
@@ -12,39 +12,43 @@ namespace DeepDrftContent;
|
||||
public class TrackContentService
|
||||
{
|
||||
private readonly FileDatabase.Services.FileDatabase _fileDatabase;
|
||||
private readonly AudioProcessor _audioProcessor;
|
||||
private readonly AudioProcessorRouter _audioProcessorRouter;
|
||||
|
||||
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessor audioProcessor)
|
||||
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessorRouter audioProcessorRouter)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_audioProcessor = audioProcessor;
|
||||
_audioProcessorRouter = audioProcessorRouter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new track from a WAV file to both databases
|
||||
/// Adds a new track from a supported audio file (.wav, .mp3, .flac) to both databases. The
|
||||
/// router selects the processor by extension; original bytes are stored for mp3/flac (no
|
||||
/// transcoding), while EXTENSIBLE WAVs are normalized to standard PCM at storage time.
|
||||
/// </summary>
|
||||
/// <param name="wavFilePath">Path to the WAV file</param>
|
||||
/// <param name="audioFilePath">Path to the audio file</param>
|
||||
/// <param name="trackName">Name of the track</param>
|
||||
/// <param name="artist">Artist name</param>
|
||||
/// <param name="album">Optional album name</param>
|
||||
/// <param name="genre">Optional genre</param>
|
||||
/// <param name="releaseDate">Optional release date</param>
|
||||
/// <param name="originalFileName">Optional original browser filename captured at upload time</param>
|
||||
/// <returns>The track entity with generated ID and media path</returns>
|
||||
public async Task<TrackEntity?> AddTrackFromWavAsync(
|
||||
string wavFilePath,
|
||||
public async Task<TrackEntity?> AddTrackAsync(
|
||||
string audioFilePath,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album = null,
|
||||
string? genre = null,
|
||||
DateOnly? releaseDate = null)
|
||||
DateOnly? releaseDate = null,
|
||||
string? originalFileName = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Process the WAV file
|
||||
var audioBinary = await _audioProcessor.ProcessWavFileAsync(wavFilePath);
|
||||
// Process the audio file (routed by extension)
|
||||
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
|
||||
if (audioBinary == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to process WAV file");
|
||||
throw new InvalidOperationException("Failed to process audio file");
|
||||
}
|
||||
|
||||
// Generate a unique track ID
|
||||
@@ -63,26 +67,39 @@ public class TrackContentService
|
||||
throw new InvalidOperationException("Failed to store audio in FileDatabase");
|
||||
}
|
||||
|
||||
// Create the track entity for SQL database
|
||||
// Create the track entity for SQL database. Post Phase 8 §8.0 the entity holds only
|
||||
// track-cardinal fields; release-cardinal data (artist/album/genre/releaseDate) is
|
||||
// resolved into a ReleaseEntity by the caller (UnifiedTrackService) and linked via FK.
|
||||
var trackEntity = new TrackEntity
|
||||
{
|
||||
EntryKey = trackId, // FileDatabase entry ID
|
||||
TrackName = trackName,
|
||||
Artist = artist,
|
||||
Album = album,
|
||||
Genre = genre,
|
||||
ReleaseDate = releaseDate
|
||||
OriginalFileName = originalFileName
|
||||
};
|
||||
|
||||
return trackEntity;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.AddTrackFromWavAsync failed: {ex.Message}");
|
||||
Console.WriteLine($"TrackContentService.AddTrackAsync failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backward-compatible shim — delegates to <see cref="AddTrackAsync"/>. The router accepts WAV
|
||||
/// alongside MP3 and FLAC, so this carries no WAV-specific logic of its own.
|
||||
/// </summary>
|
||||
public Task<TrackEntity?> AddTrackFromWavAsync(
|
||||
string wavFilePath,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album = null,
|
||||
string? genre = null,
|
||||
DateOnly? releaseDate = null,
|
||||
string? originalFileName = null) =>
|
||||
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves audio binary from FileDatabase
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Data.Data.Configurations;
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
public class MixMetadataConfiguration : BaseEntityConfiguration<MixMetadata>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<MixMetadata> builder)
|
||||
{
|
||||
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
|
||||
base.Configure(builder);
|
||||
|
||||
builder.ToTable("mix_metadata");
|
||||
|
||||
// Map the base audit columns to the snake_case naming the rest of the schema uses.
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
builder.Property(e => e.WaveformEntryKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs.
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
// 1:1 to the parent release. The unique FK index is the DB-level enforcement of the
|
||||
// one-satellite-per-release cardinality. Cascade on delete: removing the release removes its
|
||||
// medium satellite (unlike Track's SetNull — a satellite has no meaning without its release).
|
||||
builder.HasOne(e => e.Release)
|
||||
.WithOne(r => r.MixMetadata)
|
||||
.HasForeignKey<MixMetadata>(e => e.ReleaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(e => e.ReleaseId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||
// "IX_mix_metadata_is_deleted" regardless of auto-naming conventions.
|
||||
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Data.Data.Configurations;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<ReleaseEntity> builder)
|
||||
{
|
||||
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
|
||||
base.Configure(builder);
|
||||
|
||||
builder.ToTable("release");
|
||||
|
||||
// Map the base audit columns to the snake_case naming the rest of the schema uses.
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
|
||||
|
||||
// App-minted GUID-string public handle, configured exactly like TrackConfiguration's
|
||||
// entry_key: required, max 100, snake_case column. The unique index guarantees a release
|
||||
// resolves to one row by its public key.
|
||||
builder.Property(e => e.EntryKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
builder.HasIndex(e => e.EntryKey)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
builder.Property(e => e.Title)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("title");
|
||||
|
||||
builder.Property(e => e.Artist)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("artist");
|
||||
|
||||
builder.Property(e => e.Genre)
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
// Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration).
|
||||
builder.Property(e => e.Description)
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnName("description");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
builder.Property(e => e.ImagePath)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnName("image_path");
|
||||
|
||||
// ReleaseType is meaningful ONLY when Medium == Cut. It is the Cut medium's discriminator
|
||||
// data and lives on the base table by deliberate, named exception:
|
||||
// A CutMetadata satellite (mirroring SessionMetadata/MixMetadata) was considered and
|
||||
// rejected. ReleaseType is read on every card of the /cuts browse — the highest-traffic
|
||||
// read in the system. Moving it to a satellite would put a join on that hot path. So it
|
||||
// stays here. Future media MUST NOT copy this pattern: the default is a satellite metadata
|
||||
// table; this is the one allowed exception, justified solely by the /cuts read volume.
|
||||
//
|
||||
// The "ReleaseType only for Cut" invariant is advisory — enforced at the service layer and
|
||||
// surfaced via the nullable ReleaseDto.ReleaseType (nulled for non-Cut at the converter).
|
||||
// It is NOT a DB check constraint by choice, not necessity: EF supports HasCheckConstraint,
|
||||
// but the invariant is advisory and we keep the schema free of it.
|
||||
builder.Property(e => e.ReleaseType)
|
||||
.IsRequired()
|
||||
.HasConversion<string>() // Store as readable string, not int ordinal
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("release_type")
|
||||
.HasDefaultValue(ReleaseType.Single);
|
||||
|
||||
builder.Property(e => e.Medium)
|
||||
.IsRequired()
|
||||
.HasConversion<string>() // Store as readable string, not int ordinal
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("medium")
|
||||
.HasDefaultValue(ReleaseMedium.Cut); // Existing rows migrate to Cut with no data migration.
|
||||
|
||||
builder.Property(e => e.CreatedByUserId)
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||
// "IX_release_is_deleted" regardless of auto-naming conventions.
|
||||
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
// Unique constraint on the natural key (title + artist). Prevents duplicate release rows
|
||||
// from concurrent uploads of the same album. The FindOrCreateRelease path catches the
|
||||
// resulting UniqueViolation and re-queries for the winning row.
|
||||
// Partial filter excludes soft-deleted rows so re-uploading a deleted release does not
|
||||
// hit a uniqueness conflict when FindOrCreateRelease creates a fresh row.
|
||||
builder.HasIndex(e => new { e.Title, e.Artist })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Data.Data.Configurations;
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
public class SessionMetadataConfiguration : BaseEntityConfiguration<SessionMetadata>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<SessionMetadata> builder)
|
||||
{
|
||||
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
|
||||
base.Configure(builder);
|
||||
|
||||
builder.ToTable("session_metadata");
|
||||
|
||||
// Map the base audit columns to the snake_case naming the rest of the schema uses.
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
builder.Property(e => e.HeroImageEntryKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs.
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
// 1:1 to the parent release. The unique FK index is the DB-level enforcement of the
|
||||
// one-satellite-per-release cardinality. Cascade on delete: removing the release removes its
|
||||
// medium satellite (unlike Track's SetNull — a satellite has no meaning without its release).
|
||||
builder.HasOne(e => e.Release)
|
||||
.WithOne(r => r.SessionMetadata)
|
||||
.HasForeignKey<SessionMetadata>(e => e.ReleaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(e => e.ReleaseId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||
// "IX_session_metadata_is_deleted" regardless of auto-naming conventions.
|
||||
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
}
|
||||
}
|
||||
@@ -30,28 +30,24 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("track_name");
|
||||
|
||||
builder.Property(e => e.Artist)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("artist");
|
||||
|
||||
builder.Property(e => e.Album)
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("album");
|
||||
|
||||
builder.Property(e => e.Genre)
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
builder.Property(e => e.ImagePath)
|
||||
builder.Property(e => e.OriginalFileName)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnName("image_path");
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
builder.Property(e => e.CreatedByUserId)
|
||||
.HasColumnName("created_by_user_id");
|
||||
builder.Property(e => e.TrackNumber)
|
||||
.IsRequired()
|
||||
.HasColumnName("track_number")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
// Nullable FK to the release-cardinal row. SetNull on delete: removing a release leaves its
|
||||
// tracks intact as loose tracks rather than cascading them away.
|
||||
builder.HasOne(e => e.Release)
|
||||
.WithMany(r => r.Tracks)
|
||||
.HasForeignKey(e => e.ReleaseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||
|
||||
@@ -11,11 +11,17 @@ public class DeepDrftContext : DbContext
|
||||
}
|
||||
|
||||
public DbSet<TrackEntity> Tracks { get; set; }
|
||||
public DbSet<ReleaseEntity> Releases { get; set; }
|
||||
public DbSet<SessionMetadata> SessionMetadata { get; set; }
|
||||
public DbSet<MixMetadata> MixMetadata { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.ApplyConfiguration(new TrackConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// SQL-side release service. Repository outputs entities; this service outputs DTOs via TrackConverter.
|
||||
/// Backs the medium-aware release read endpoints (paged list + by-id detail) and the two metadata
|
||||
/// write paths (Session hero image, Mix waveform). The entity never escapes the service layer.
|
||||
/// </summary>
|
||||
public interface IReleaseService
|
||||
{
|
||||
/// <summary>Paginated releases, optionally narrowed by medium and a free-text/genre filter. The matching medium's metadata satellite is included in the result. Omit medium for all releases; omit filter for no search/genre narrowing.</summary>
|
||||
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The public addressing read: single release resolved by its opaque EntryKey (Phase 11 §3e). Both metadata navs included (nulls for non-matching media).</summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary>
|
||||
Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Find-or-create the Session satellite and set its hero image entry key. Fails when the release is not a Session.</summary>
|
||||
Task<Result> SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Find-or-create the Mix satellite and set its waveform entry key. Fails when the release is not a Mix.</summary>
|
||||
Task<Result> SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -12,9 +12,49 @@ namespace DeepDrftData;
|
||||
public interface ITrackService
|
||||
{
|
||||
Task<ResultContainer<TrackDto?>> GetById(long id);
|
||||
Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single track chosen uniformly at random, or null when the library is empty
|
||||
/// (a valid state, not a failure). Backs the public "Stream Now" instant-play feature.
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default);
|
||||
Task<ResultContainer<List<TrackDto>>> GetAll();
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>All releases, title-ascending, each carrying its non-deleted track count.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
|
||||
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
|
||||
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
|
||||
/// </summary>
|
||||
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only peek for an existing release by its natural key, or null when none exists — a find
|
||||
/// with no create side-effect. Backs the upload cardinality pre-check, which must read a release's
|
||||
/// medium and live-track count before deciding whether to admit an upload, without creating a
|
||||
/// release for an upload it may reject. The returned DTO carries TrackCount.
|
||||
/// </summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(
|
||||
string title, string artist, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
||||
Task<ResultContainer<TrackDto>> Update(TrackDto track);
|
||||
Task<Result> Delete(long id);
|
||||
|
||||
/// <summary>Soft-delete a release row by id. Idempotent — a missing or already-deleted row is a no-op.</summary>
|
||||
Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count of non-deleted tracks on a release. Backs the delete-cascade decision: when a track
|
||||
/// delete leaves a release with zero live tracks, the release is soft-deleted too.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260607124422_AddOriginalFileName")]
|
||||
partial class AddOriginalFileName
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("album");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOriginalFileName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "original_file_name",
|
||||
table: "track",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "original_file_name",
|
||||
table: "track");
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611005700_AddReleaseTypeAndTrackNumber")]
|
||||
partial class AddReleaseTypeAndTrackNumber
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("album");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseTypeAndTrackNumber : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "release_type",
|
||||
table: "track",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Single");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "track_number",
|
||||
table: "track",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "release_type",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "track_number",
|
||||
table: "track");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611164537_NormalizeReleaseTrack")]
|
||||
partial class NormalizeReleaseTrack
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NormalizeReleaseTrack : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1. Create the release table.
|
||||
migrationBuilder.CreateTable(
|
||||
name: "release",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
artist = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
genre = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
release_date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
image_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
release_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Single"),
|
||||
created_by_user_id = table.Column<long>(type: "bigint", nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_release", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_is_deleted",
|
||||
table: "release",
|
||||
column: "is_deleted");
|
||||
|
||||
// 2. Add the nullable FK column to track. A fresh column (not a rename of
|
||||
// created_by_user_id) so existing rows start with a null release until back-filled.
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "release_id",
|
||||
table: "track",
|
||||
type: "bigint",
|
||||
nullable: true);
|
||||
|
||||
// 3. Data migration — must run after the release table exists and release_id is added,
|
||||
// and before the release-cardinal columns are dropped from track (the SELECT reads them).
|
||||
// Create one release row per distinct (album, artist) from existing tracks, carrying
|
||||
// the release-cardinal fields. Tracks with a null album remain release_id = null.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO release (title, artist, genre, release_date, image_path, release_type,
|
||||
created_by_user_id, created_at, updated_at, is_deleted)
|
||||
SELECT DISTINCT ON (album, artist)
|
||||
album, artist, genre, release_date, image_path, release_type,
|
||||
created_by_user_id, NOW(), NOW(), false
|
||||
FROM track
|
||||
WHERE album IS NOT NULL
|
||||
ORDER BY album, artist, id;
|
||||
");
|
||||
|
||||
// Back-fill the FK: match each track to the release created from its (album, artist).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE track
|
||||
SET release_id = r.id
|
||||
FROM release r
|
||||
WHERE track.album = r.title
|
||||
AND track.artist = r.artist;
|
||||
");
|
||||
|
||||
// 4. Index + FK now that the column carries its back-filled values.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_track_release_id",
|
||||
table: "track",
|
||||
column: "release_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_track_release_release_id",
|
||||
table: "track",
|
||||
column: "release_id",
|
||||
principalTable: "release",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
// 5. Drop the now-migrated release-cardinal columns from track.
|
||||
migrationBuilder.DropColumn(name: "album", table: "track");
|
||||
migrationBuilder.DropColumn(name: "artist", table: "track");
|
||||
migrationBuilder.DropColumn(name: "genre", table: "track");
|
||||
migrationBuilder.DropColumn(name: "image_path", table: "track");
|
||||
migrationBuilder.DropColumn(name: "release_date", table: "track");
|
||||
migrationBuilder.DropColumn(name: "release_type", table: "track");
|
||||
migrationBuilder.DropColumn(name: "created_by_user_id", table: "track");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1. Re-add the track release-cardinal columns. artist is non-nullable with a default so
|
||||
// the add succeeds against existing rows before the back-fill repopulates it.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "album",
|
||||
table: "track",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "artist",
|
||||
table: "track",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "genre",
|
||||
table: "track",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "image_path",
|
||||
table: "track",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "release_date",
|
||||
table: "track",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "release_type",
|
||||
table: "track",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Single");
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "created_by_user_id",
|
||||
table: "track",
|
||||
type: "bigint",
|
||||
nullable: true);
|
||||
|
||||
// 2. Re-populate the track columns from the release join before the release table and FK go.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE track
|
||||
SET artist = r.artist,
|
||||
album = r.title,
|
||||
genre = r.genre,
|
||||
release_date = r.release_date,
|
||||
image_path = r.image_path,
|
||||
release_type = r.release_type,
|
||||
created_by_user_id = r.created_by_user_id
|
||||
FROM release r
|
||||
WHERE track.release_id = r.id;
|
||||
");
|
||||
|
||||
// 3. Drop the FK, index, the release_id column, and the release table.
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_track_release_release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_track_release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611184732_AddReleaseUniqueTitleArtist")]
|
||||
partial class AddReleaseUniqueTitleArtist
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseUniqueTitleArtist : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release",
|
||||
columns: new[] { "title", "artist" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260612000000_SoftDeleteOrphanedReleases")]
|
||||
partial class SoftDeleteOrphanedReleases
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Data-only migration: no schema change, snapshot unchanged.
|
||||
public partial class SoftDeleteOrphanedReleases : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Backfill: soft-delete any live release whose tracks were all soft-deleted before the
|
||||
// delete-cascade in UnifiedTrackService existed. These show as 0-track rows in the albums
|
||||
// browser; this clears the pre-existing orphans the cascade now prevents going forward.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE release
|
||||
SET is_deleted = true,
|
||||
updated_at = now()
|
||||
WHERE id IN (
|
||||
SELECT r.id
|
||||
FROM release r
|
||||
WHERE r.is_deleted = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM track t
|
||||
WHERE t.release_id = r.id
|
||||
AND t.is_deleted = false
|
||||
)
|
||||
);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("-- no-op: orphaned release soft-deletes are not rolled back");
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260612102604_MakeReleaseTitleArtistUniquePartial")]
|
||||
partial class MakeReleaseTitleArtistUniquePartial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeReleaseTitleArtistUniquePartial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release",
|
||||
columns: new[] { "title", "artist" },
|
||||
unique: true,
|
||||
filter: "\"is_deleted\" = false");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release",
|
||||
columns: new[] { "title", "artist" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260613013826_AddReleaseMedium")]
|
||||
partial class AddReleaseMedium
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseMedium : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "medium",
|
||||
table: "release",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Cut");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "mix_metadata",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
release_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
waveform_entry_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_mix_metadata", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_mix_metadata_release_release_id",
|
||||
column: x => x.release_id,
|
||||
principalTable: "release",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "session_metadata",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
release_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
hero_image_entry_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_session_metadata", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_session_metadata_release_release_id",
|
||||
column: x => x.release_id,
|
||||
principalTable: "release",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_mix_metadata_is_deleted",
|
||||
table: "mix_metadata",
|
||||
column: "is_deleted");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_mix_metadata_release_id",
|
||||
table: "mix_metadata",
|
||||
column: "release_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_session_metadata_is_deleted",
|
||||
table: "session_metadata",
|
||||
column: "is_deleted");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_session_metadata_release_id",
|
||||
table: "session_metadata",
|
||||
column: "release_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "mix_metadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "session_metadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "medium",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260616035252_AddReleaseDescription")]
|
||||
partial class AddReleaseDescription
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseDescription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "description",
|
||||
table: "release",
|
||||
type: "character varying(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "description",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260616210143_AddReleaseEntryKey")]
|
||||
partial class AddReleaseEntryKey
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseEntryKey : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 11.H — front the int PK with an app-minted GUID-string EntryKey (Phase 11 §3e). The
|
||||
// scaffolded single non-null-with-"" add is hand-edited into the three-step backfill the
|
||||
// spec requires (§3e.5(2)): existing rows must each get a UNIQUE, non-null key, so a shared
|
||||
// "" default would collide on the unique index. Add nullable → backfill a GUID per row →
|
||||
// mark non-null. No DB default is set on the final column: new rows are app-populated by
|
||||
// FindOrCreateRelease (Guid.NewGuid().ToString()), exactly as tracks mint their EntryKey.
|
||||
|
||||
// 1) Add the column nullable so the backfill can run before the NOT NULL constraint.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "entry_key",
|
||||
table: "release",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
// 2) Backfill a unique GUID string per existing row. gen_random_uuid()::text yields the
|
||||
// lowercase 36-char hyphenated shape Guid.NewGuid().ToString() produces, so migrated and
|
||||
// app-minted keys are indistinguishable. Per-row evaluation → each row gets a distinct key.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE \"release\" SET \"entry_key\" = gen_random_uuid()::text WHERE \"entry_key\" IS NULL;");
|
||||
|
||||
// 3) Now every row is populated and unique — enforce NOT NULL.
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "entry_key",
|
||||
table: "release",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(100)",
|
||||
oldMaxLength: 100,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_entry_key",
|
||||
table: "release",
|
||||
column: "entry_key",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_entry_key",
|
||||
table: "release");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "entry_key",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ namespace DeepDrftData.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -31,10 +31,50 @@ namespace DeepDrftData.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("album");
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
@@ -50,6 +90,11 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -72,16 +117,144 @@ namespace DeepDrftData.Migrations
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@@ -91,8 +264,51 @@ namespace DeepDrftData.Migrations
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Linq.Expressions;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// SQL-side release service implementing <see cref="IReleaseService"/>. Deliberately does NOT extend
|
||||
/// <c>Manager<></c>: that CRUD base does not fit this read-projection + satellite-write purpose.
|
||||
/// The layer boundary holds — ReleaseRepository outputs entities, this manager outputs DTOs via
|
||||
/// TrackConverter, the single authoritative conversion path.
|
||||
/// </summary>
|
||||
public class ReleaseManager : IReleaseService
|
||||
{
|
||||
// Distinguishes "release does not exist" from a real failure so the controller can map to 404.
|
||||
public const string ReleaseNotFoundMessage = "Release not found.";
|
||||
|
||||
private readonly ReleaseRepository _repository;
|
||||
private readonly ILogger<ReleaseManager> _logger;
|
||||
|
||||
public ReleaseManager(ReleaseRepository repository, ILogger<ReleaseManager> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Nulls sort to end via the coalescing sentinels, matching TrackManager's convention.
|
||||
private static Expression<Func<ReleaseEntity, object>> GetOrderExpression(string? sortColumn)
|
||||
=> sortColumn switch
|
||||
{
|
||||
"Title" => r => r.Title,
|
||||
"Artist" => r => r.Artist,
|
||||
"ReleaseDate" => r => (object)(r.ReleaseDate ?? DateOnly.MaxValue),
|
||||
"Medium" => r => r.Medium,
|
||||
_ => r => r.Id
|
||||
};
|
||||
|
||||
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameters = new PagingParameters<ReleaseEntity>
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
OrderBy = GetOrderExpression(sortColumn),
|
||||
IsDescending = sortDescending,
|
||||
};
|
||||
|
||||
// Collapse an all-null filter to null so the repository skips the predicate block entirely.
|
||||
var effectiveFilter = filter is { IsEmpty: false } ? filter : null;
|
||||
var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, effectiveFilter, cancellationToken);
|
||||
|
||||
var releaseIds = entityPage.Items.Select(r => r.Id).ToList();
|
||||
var counts = await _repository.GetTrackCountsByReleaseIdsAsync(releaseIds, cancellationToken);
|
||||
|
||||
var dtos = entityPage.Items
|
||||
.Select(r =>
|
||||
{
|
||||
var dto = TrackConverter.Convert(r);
|
||||
dto.TrackCount = counts.GetValueOrDefault(r.Id);
|
||||
return dto;
|
||||
});
|
||||
|
||||
var dtoPage = PagedResult<ReleaseDto>.From(entityPage, dtos);
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreatePassResult(dtoPage);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _repository.GetByIdWithMetadataAsync(id, cancellationToken);
|
||||
// TrackConverter nulls the non-matching satellite. TrackCount is not loaded for the detail
|
||||
// read (the Tracks collection isn't Include'd) and is not needed by detail consumers.
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _repository.GetByEntryKeyWithMetadataAsync(entryKey, cancellationToken);
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var keys = await _repository.GetTrackEntryKeysByReleaseIdAsync(releaseId, cancellationToken);
|
||||
return ResultContainer<List<string>>.CreatePassResult(keys);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<string>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken);
|
||||
if (release is null)
|
||||
return Result.CreateFailResult(ReleaseNotFoundMessage);
|
||||
|
||||
if (release.Medium != ReleaseMedium.Session)
|
||||
return Result.CreateFailResult($"Release {releaseId} is not a Session medium.");
|
||||
|
||||
await _repository.SetHeroImageEntryKeyAsync(releaseId, heroImageEntryKey, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken);
|
||||
if (release is null)
|
||||
return Result.CreateFailResult(ReleaseNotFoundMessage);
|
||||
|
||||
if (release.Medium != ReleaseMedium.Mix)
|
||||
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
|
||||
|
||||
await _repository.SetWaveformEntryKeyAsync(releaseId, waveformEntryKey, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftData.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Medium-aware release queries. Deliberately does NOT extend <c>Repository<DeepDrftContext, ReleaseEntity></c>:
|
||||
/// that base is generic CRUD, while this repository's purpose is read-projection (paged, medium-filtered)
|
||||
/// and satellite-row management (Session/Mix metadata find-or-create). Injects <see cref="DeepDrftContext"/>
|
||||
/// directly so reads/writes stay in one unit of work.
|
||||
/// </summary>
|
||||
public class ReleaseRepository
|
||||
{
|
||||
private readonly DeepDrftContext _context;
|
||||
private readonly ILogger<ReleaseRepository> _logger;
|
||||
|
||||
public ReleaseRepository(DeepDrftContext context, ILogger<ReleaseRepository> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Single location where the medium↔metadata correlation is determined on a list read: a satellite
|
||||
// is loaded only when the caller's medium filter matches it. TrackConverter.Convert(ReleaseEntity)
|
||||
// enforces the same rule at the DTO boundary (nulling non-matching satellites); this map ensures a
|
||||
// non-matching satellite is never even queried. Cut (or no filter) loads no satellite on list reads.
|
||||
private static IQueryable<ReleaseEntity> ApplyMediumInclude(IQueryable<ReleaseEntity> query, ReleaseMedium? medium)
|
||||
=> medium switch
|
||||
{
|
||||
ReleaseMedium.Session => query.Include(r => r.SessionMetadata),
|
||||
ReleaseMedium.Mix => query.Include(r => r.MixMetadata),
|
||||
_ => query
|
||||
};
|
||||
|
||||
// Paged release list, optionally narrowed by medium and a free-text/genre filter. The matching
|
||||
// medium's satellite is Include'd; total count reflects every applied predicate (all before
|
||||
// Skip/Take). The filter predicates mirror TrackRepository.GetPagedFilteredAsync so the release
|
||||
// browse path searches and filters identically to the track path.
|
||||
public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
|
||||
PagingParameters<ReleaseEntity> paging,
|
||||
ReleaseMedium? medium,
|
||||
ReleaseFilter? filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
|
||||
if (medium.HasValue)
|
||||
query = query.Where(r => r.Medium == medium.Value);
|
||||
|
||||
if (filter is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
||||
{
|
||||
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
||||
// EF-translatable where ToLower().Contains() is not. Title/Artist are non-null columns
|
||||
// on the release itself, so no navigation guard is needed (unlike the track path).
|
||||
var pattern = $"%{filter.SearchText}%";
|
||||
query = query.Where(r =>
|
||||
EF.Functions.ILike(r.Title, pattern)
|
||||
|| EF.Functions.ILike(r.Artist, pattern));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
||||
query = query.Where(r => r.Genre == filter.Genre);
|
||||
}
|
||||
|
||||
query = ApplyMediumInclude(query, medium);
|
||||
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
|
||||
if (paging.OrderBy is not null)
|
||||
query = paging.IsDescending ? query.OrderByDescending(paging.OrderBy) : query.OrderBy(paging.OrderBy);
|
||||
|
||||
var items = await query.Skip(paging.Skip).Take(paging.PageSize).ToListAsync(ct);
|
||||
|
||||
return new PagedResult<ReleaseEntity>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
Page = paging.Page,
|
||||
PageSize = paging.PageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// Single release with both satellites Include'd: the medium is unknown until fetched, and both are
|
||||
// 1:1 FK-indexed joins. TrackConverter nulls the non-matching satellite at the DTO boundary.
|
||||
public async Task<ReleaseEntity?> GetByIdWithMetadataAsync(long id, CancellationToken ct)
|
||||
=> await _context.Releases
|
||||
.Where(r => r.Id == id && !r.IsDeleted)
|
||||
.Include(r => r.SessionMetadata)
|
||||
.Include(r => r.MixMetadata)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// The public addressing read: resolve a release by its opaque EntryKey (Phase 11 §3e). Mirrors
|
||||
// GetByIdWithMetadataAsync but keys on the unique entry_key column — the int PK never reaches the
|
||||
// public surface. The resolved entity still carries its int Id for internal joins (track page).
|
||||
public async Task<ReleaseEntity?> GetByEntryKeyWithMetadataAsync(string entryKey, CancellationToken ct)
|
||||
=> await _context.Releases
|
||||
.Where(r => r.EntryKey == entryKey && !r.IsDeleted)
|
||||
.Include(r => r.SessionMetadata)
|
||||
.Include(r => r.MixMetadata)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
|
||||
// list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
|
||||
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
|
||||
IEnumerable<long> releaseIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ids = releaseIds.ToList();
|
||||
return await _context.Tracks
|
||||
.Where(t => !t.IsDeleted && t.ReleaseId != null && ids.Contains(t.ReleaseId.Value))
|
||||
.GroupBy(t => t.ReleaseId!.Value)
|
||||
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
||||
}
|
||||
|
||||
// Vault entry keys of the non-deleted tracks on a release, track-number ascending. Single-entry for
|
||||
// Session/Mix (enforced at upload); may be multiple for Cut.
|
||||
public async Task<List<string>> GetTrackEntryKeysByReleaseIdAsync(long releaseId, CancellationToken ct)
|
||||
=> await _context.Tracks
|
||||
.Where(t => !t.IsDeleted && t.ReleaseId == releaseId)
|
||||
.OrderBy(t => t.TrackNumber)
|
||||
.Select(t => t.EntryKey)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Find-or-create the Session satellite for a release and set its hero-image entry key. The 1:1 FK
|
||||
// makes (ReleaseId) the natural key; a repeat call updates the existing row in place.
|
||||
public async Task SetHeroImageEntryKeyAsync(long releaseId, string heroImageEntryKey, CancellationToken ct)
|
||||
{
|
||||
var existing = await _context.SessionMetadata.FirstOrDefaultAsync(s => s.ReleaseId == releaseId, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.HeroImageEntryKey = heroImageEntryKey;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
_context.SessionMetadata.Add(new SessionMetadata
|
||||
{
|
||||
ReleaseId = releaseId,
|
||||
HeroImageEntryKey = heroImageEntryKey,
|
||||
});
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Find-or-create the Mix satellite for a release and set its waveform entry key. Same 1:1 find-or-create
|
||||
// pattern as SetHeroImageEntryKeyAsync.
|
||||
public async Task SetWaveformEntryKeyAsync(long releaseId, string waveformEntryKey, CancellationToken ct)
|
||||
{
|
||||
var existing = await _context.MixMetadata.FirstOrDefaultAsync(m => m.ReleaseId == releaseId, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.WaveformEntryKey = waveformEntryKey;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
_context.MixMetadata.Add(new MixMetadata
|
||||
{
|
||||
ReleaseId = releaseId,
|
||||
WaveformEntryKey = waveformEntryKey,
|
||||
});
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,216 @@
|
||||
using Data.Data.Repositories;
|
||||
using Data.Errors;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftData.Repositories;
|
||||
|
||||
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
{
|
||||
// The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) but no
|
||||
// DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference
|
||||
// to the injected context rather than reaching for a service locator — it is the same scoped
|
||||
// instance the base holds, so reads/writes stay in one unit of work.
|
||||
private readonly DeepDrftContext _context;
|
||||
|
||||
public TrackRepository(
|
||||
DeepDrftContext context,
|
||||
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
|
||||
IDbExceptionClassifier? classifier = null)
|
||||
: base(context, logger, classifier: classifier)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// Override base GetByIdAsync to include the Release navigation. Without this, the base
|
||||
// Query has no .Include, so Release is null on every entity (no lazy-loading proxies).
|
||||
public override async Task<TrackEntity?> GetByIdAsync(long id)
|
||||
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
// Override base GetAllAsync for the same reason — include Release so callers (e.g.
|
||||
// TrackManager.GetAll) receive fully-populated entities without a separate query.
|
||||
public override async Task<IEnumerable<TrackEntity>> GetAllAsync()
|
||||
=> await Query.Include(t => t.Release).ToListAsync();
|
||||
|
||||
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
|
||||
// uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the
|
||||
// converter can project the release-cardinal fields.
|
||||
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
|
||||
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
||||
|
||||
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
|
||||
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
|
||||
// is small today but this keeps the cost flat as it grows. Returns null when empty so the
|
||||
// service surfaces a valid empty-library state, not an error. Uses Query (soft-delete
|
||||
// filtered) so deleted tracks are never candidates.
|
||||
public async Task<TrackEntity?> GetRandomAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = await Query.CountAsync(cancellationToken);
|
||||
if (count == 0)
|
||||
return null;
|
||||
|
||||
var index = Random.Shared.Next(count);
|
||||
return await Query
|
||||
.Include(t => t.Release)
|
||||
.OrderBy(t => t.Id)
|
||||
.Skip(index)
|
||||
.Take(1)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Paged query with optional filter predicates. Built off Query (soft-delete filtered) rather than the
|
||||
// base GetPagedAsync(paging) overload, which takes no where-clause. The OrderBy expression and
|
||||
// direction ride in on the PagingParameters the manager already built, so sort + filter +
|
||||
// pagination compose. Filter predicates apply before sort and Skip/Take so TotalCount reflects
|
||||
// the filtered set.
|
||||
public async Task<PagedResult<TrackEntity>> GetPagedFilteredAsync(
|
||||
PagingParameters<TrackEntity> paging,
|
||||
TrackFilter? filter,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Include Release so both the filter predicates and the converter can read release-cardinal
|
||||
// fields through the navigation.
|
||||
IQueryable<TrackEntity> query = Query.Include(t => t.Release);
|
||||
|
||||
if (filter is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
||||
{
|
||||
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
||||
// EF-translatable where ToLower().Contains() is not. Artist/Title live on the joined
|
||||
// Release, which is null for loose tracks — guard the navigation before ILike.
|
||||
var pattern = $"%{filter.SearchText}%";
|
||||
query = query.Where(t =>
|
||||
EF.Functions.ILike(t.TrackName, pattern)
|
||||
|| (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern))
|
||||
|| (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Album))
|
||||
query = query.Where(t => t.Release != null && t.Release.Title == filter.Album);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
||||
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
|
||||
|
||||
// Exact release-id join. ReleaseId is a column on the track itself, so this needs no
|
||||
// navigation guard — it is the authoritative alternative to the Album title match.
|
||||
if (filter.ReleaseId is { } releaseId)
|
||||
query = query.Where(t => t.ReleaseId == releaseId);
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
|
||||
if (paging.OrderBy is not null)
|
||||
{
|
||||
query = paging.IsDescending
|
||||
? query.OrderByDescending(paging.OrderBy)
|
||||
: query.OrderBy(paging.OrderBy);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.Skip(paging.Skip)
|
||||
.Take(paging.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new PagedResult<TrackEntity>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
Page = paging.Page,
|
||||
PageSize = paging.PageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks.
|
||||
// The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto.
|
||||
public async Task<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.Where(r => !r.IsDeleted)
|
||||
.OrderBy(r => r.Title)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks
|
||||
// (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks
|
||||
// (no release) carry no genre and are excluded.
|
||||
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.Release != null && t.Release.Genre != null)
|
||||
.GroupBy(t => t.Release!.Genre!)
|
||||
.Select(g => new GenreSummaryDto
|
||||
{
|
||||
Genre = g.Key,
|
||||
TrackCount = g.Count(),
|
||||
})
|
||||
.OrderBy(g => g.Genre)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against
|
||||
// GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out.
|
||||
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.ReleaseId != null)
|
||||
.GroupBy(t => t.ReleaseId!.Value)
|
||||
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
||||
|
||||
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
|
||||
// signalling the manager to create one. Soft-deleted releases never match.
|
||||
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
|
||||
string title, string artist, CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct);
|
||||
|
||||
// Persist a new release row and return it with its assigned Id. Lives here (not the manager)
|
||||
// because the repository owns the DbContext — the manager stays free of direct context access.
|
||||
public async Task<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||
{
|
||||
_context.Set<ReleaseEntity>().Add(release);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return release;
|
||||
}
|
||||
|
||||
// Load a tracked release by id so the manager can edit its fields in place and save. Returns
|
||||
// null when the id does not resolve (or the release is soft-deleted).
|
||||
public async Task<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct);
|
||||
|
||||
// Persist edits to a release. Update marks the whole entity modified, so it works whether the
|
||||
// instance is the change-tracked one from GetReleaseByIdAsync or a detached graph.
|
||||
public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||
{
|
||||
_context.Set<ReleaseEntity>().Update(release);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Soft-delete a release row in a single set-based UPDATE (no load round-trip). The !IsDeleted
|
||||
// guard makes a repeat call a no-op rather than re-stamping updated_at on an already-deleted row.
|
||||
public async Task SoftDeleteReleaseAsync(long id, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Set<ReleaseEntity>()
|
||||
.Where(r => r.Id == id && !r.IsDeleted)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.IsDeleted, true)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow), ct);
|
||||
}
|
||||
|
||||
// Count of non-deleted tracks on a single release. Backs the delete-cascade decision in
|
||||
// UnifiedTrackService: a release with zero live tracks after a delete is soft-deleted too.
|
||||
// Uses Query (soft-delete filtered) so just-deleted tracks are excluded from the count.
|
||||
public async Task<int> CountLiveTracksByReleaseAsync(long releaseId, CancellationToken ct = default)
|
||||
=> await Query.CountAsync(t => t.ReleaseId == releaseId, ct);
|
||||
|
||||
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
|
||||
{
|
||||
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
|
||||
target.EntryKey = source.EntryKey;
|
||||
target.TrackName = source.TrackName;
|
||||
target.Artist = source.Artist;
|
||||
target.Album = source.Album;
|
||||
target.Genre = source.Genre;
|
||||
target.ReleaseDate = source.ReleaseDate;
|
||||
target.ImagePath = source.ImagePath;
|
||||
target.CreatedByUserId = source.CreatedByUserId;
|
||||
target.TrackNumber = source.TrackNumber;
|
||||
target.OriginalFileName = source.OriginalFileName;
|
||||
target.ReleaseId = source.ReleaseId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Converters;
|
||||
|
||||
namespace DeepDrftData;
|
||||
@@ -9,9 +10,67 @@ namespace DeepDrftData;
|
||||
/// The DTO side mirrors the entity field-for-field; the audit columns
|
||||
/// (CreatedAt, UpdatedAt) come from BaseEntity / BaseModel.
|
||||
/// IsDeleted does not round-trip — soft-deleted rows are not exposed via the model.
|
||||
///
|
||||
/// Post Phase 8 §8.0: TrackEntity carries only track-cardinal fields plus a nullable
|
||||
/// ReleaseId/Release. The release-cardinal data converts through the Release maps below.
|
||||
/// </summary>
|
||||
public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
{
|
||||
public static ReleaseDto Convert(ReleaseEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
EntryKey = entity.EntryKey,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
Title = entity.Title,
|
||||
Artist = entity.Artist,
|
||||
Genre = entity.Genre,
|
||||
Description = entity.Description,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
Medium = entity.Medium,
|
||||
// ReleaseType is meaningful only for Cut; null it for Session/Mix at the mapping point so no
|
||||
// consumer mistakes a stale studio-format value for a live/mix release.
|
||||
ReleaseType = entity.Medium == ReleaseMedium.Cut ? entity.ReleaseType : (ReleaseType?)null,
|
||||
SessionMetadata = entity.SessionMetadata is null
|
||||
? null
|
||||
: new SessionMetadataDto
|
||||
{
|
||||
ReleaseId = entity.SessionMetadata.ReleaseId,
|
||||
HeroImageEntryKey = entity.SessionMetadata.HeroImageEntryKey
|
||||
},
|
||||
MixMetadata = entity.MixMetadata is null
|
||||
? null
|
||||
: new MixMetadataDto
|
||||
{
|
||||
ReleaseId = entity.MixMetadata.ReleaseId,
|
||||
WaveformEntryKey = entity.MixMetadata.WaveformEntryKey
|
||||
},
|
||||
CreatedByUserId = entity.CreatedByUserId
|
||||
};
|
||||
|
||||
public static ReleaseEntity Convert(ReleaseDto dto) => new()
|
||||
{
|
||||
Id = dto.Id,
|
||||
// Round-trips the public handle. On the create path (FindOrCreateRelease) the DTO carries no
|
||||
// EntryKey yet, so that path overrides this with a freshly minted GUID — the same shape as the
|
||||
// natural-key (Title/Artist) override there.
|
||||
EntryKey = dto.EntryKey,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
UpdatedAt = dto.UpdatedAt,
|
||||
Title = dto.Title,
|
||||
Artist = dto.Artist,
|
||||
Genre = dto.Genre,
|
||||
Description = dto.Description,
|
||||
ReleaseDate = dto.ReleaseDate,
|
||||
ImagePath = dto.ImagePath,
|
||||
Medium = dto.Medium,
|
||||
// Entity ReleaseType is non-nullable; default back to Single when the DTO nulled it for a
|
||||
// non-Cut release. Primarily a write-path reconstruction concern.
|
||||
ReleaseType = dto.ReleaseType ?? ReleaseType.Single,
|
||||
CreatedByUserId = dto.CreatedByUserId
|
||||
};
|
||||
|
||||
public static TrackDto Convert(TrackEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
@@ -19,14 +78,15 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
EntryKey = entity.EntryKey,
|
||||
TrackName = entity.TrackName,
|
||||
Artist = entity.Artist,
|
||||
Album = entity.Album,
|
||||
Genre = entity.Genre,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
CreatedByUserId = entity.CreatedByUserId
|
||||
OriginalFileName = entity.OriginalFileName,
|
||||
TrackNumber = entity.TrackNumber,
|
||||
ReleaseId = entity.ReleaseId,
|
||||
Release = entity.Release is null ? null : Convert(entity.Release)
|
||||
};
|
||||
|
||||
// DTO → entity maps track-cardinal fields + ReleaseId only. The Release navigation is left
|
||||
// unset: the manager resolves/attaches the release row against the tracked context so a detached
|
||||
// graph never overwrites a shared release record.
|
||||
public static TrackEntity Convert(TrackDto model) => new()
|
||||
{
|
||||
Id = model.Id,
|
||||
@@ -34,11 +94,8 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
UpdatedAt = model.UpdatedAt,
|
||||
EntryKey = model.EntryKey,
|
||||
TrackName = model.TrackName,
|
||||
Artist = model.Artist,
|
||||
Album = model.Album,
|
||||
Genre = model.Genre,
|
||||
ReleaseDate = model.ReleaseDate,
|
||||
ImagePath = model.ImagePath,
|
||||
CreatedByUserId = model.CreatedByUserId
|
||||
OriginalFileName = model.OriginalFileName,
|
||||
TrackNumber = model.TrackNumber,
|
||||
ReleaseId = model.ReleaseId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Data.Errors;
|
||||
using Data.Managers;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
@@ -46,6 +48,38 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup by vault entry key. No base-name conflict (unlike GetById), so this is a plain
|
||||
// public method. Mirrors the nullable-on-miss shape of ITrackService.GetById.
|
||||
public async Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await Repository.GetByEntryKeyAsync(entryKey);
|
||||
return ResultContainer<TrackDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// No base-name conflict, so this is a plain public method. Mirrors the nullable-on-empty
|
||||
// shape of GetById: pass with null when the library has no tracks.
|
||||
public async Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await Repository.GetRandomAsync(cancellationToken);
|
||||
return ResultContainer<TrackDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<TrackDto>>> GetAll()
|
||||
{
|
||||
try
|
||||
@@ -65,6 +99,7 @@ public class TrackManager
|
||||
int pageSize,
|
||||
string? sortColumn,
|
||||
bool sortDescending,
|
||||
TrackFilter? filter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -74,18 +109,28 @@ public class TrackManager
|
||||
Page = pageNumber,
|
||||
PageSize = pageSize,
|
||||
IsDescending = sortDescending,
|
||||
// Sorts navigate through the nullable Release relation; the null-coalescing
|
||||
// sentinels push loose tracks (no release) to the end, matching the prior
|
||||
// nulls-last behaviour on the flat columns.
|
||||
OrderBy = sortColumn switch
|
||||
{
|
||||
"TrackName" => e => e.TrackName,
|
||||
"Artist" => e => e.Artist,
|
||||
"Album" => e => (object)(e.Album ?? string.Empty),
|
||||
"Genre" => e => (object)(e.Genre ?? string.Empty),
|
||||
"ReleaseDate" => e => (object)(e.ReleaseDate ?? DateOnly.MaxValue),
|
||||
_ => e => e.Id
|
||||
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
|
||||
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
|
||||
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
|
||||
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
|
||||
"TrackNumber" => e => e.TrackNumber,
|
||||
_ => e => e.Id
|
||||
}
|
||||
};
|
||||
|
||||
var page = await Repository.GetPagedAsync(parameters);
|
||||
// Always route through GetPagedFilteredAsync — it handles a null filter by skipping
|
||||
// all Where predicates, and it always includes Release. This removes the base-class
|
||||
// GetPagedAsync path, which has no .Include and would return entities with null Release.
|
||||
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
|
||||
|
||||
var page = await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
|
||||
|
||||
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
|
||||
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
|
||||
}
|
||||
@@ -95,10 +140,122 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var releases = await Repository.GetReleasesAsync(cancellationToken);
|
||||
var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken);
|
||||
|
||||
var dtos = releases
|
||||
.Select(r =>
|
||||
{
|
||||
var dto = TrackConverter.Convert(r);
|
||||
dto.TrackCount = counts.GetValueOrDefault(r.Id);
|
||||
return dto;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return ResultContainer<List<ReleaseDto>>.CreatePassResult(dtos);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (existing is not null)
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
|
||||
|
||||
// The natural key (title + artist) is authoritative — override whatever the caller put
|
||||
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
||||
var entity = TrackConverter.Convert(releaseData);
|
||||
entity.Id = 0;
|
||||
// Mint the public EntryKey app-side at creation — the identical call tracks make in
|
||||
// TrackContentService (Phase 11 §3e.4). The incoming DTO carries no key on the create path.
|
||||
entity.EntryKey = Guid.NewGuid().ToString();
|
||||
entity.Title = title;
|
||||
entity.Artist = artist;
|
||||
|
||||
try
|
||||
{
|
||||
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||
}
|
||||
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
|
||||
{
|
||||
// Concurrent upload inserted the same (title, artist) between our read and write.
|
||||
// Re-query and return the winning row. Should not return null here since the
|
||||
// constraint just fired, but re-throw if it does so the caller sees an error.
|
||||
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (race is null) throw;
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(
|
||||
string title, string artist, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (existing is null)
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
|
||||
|
||||
var dto = TrackConverter.Convert(existing);
|
||||
dto.TrackCount = await Repository.CountLiveTracksByReleaseAsync(existing.Id, cancellationToken);
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(dto);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var genres = await Repository.GetDistinctGenresAsync(cancellationToken);
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
|
||||
{
|
||||
try
|
||||
{
|
||||
// A track with release context resolves (or creates) the shared release first so the FK
|
||||
// is set before insert. A standalone track (Release null) stays a loose track, ReleaseId
|
||||
// null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and
|
||||
// a populated ReleaseId, which falls straight through.
|
||||
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
|
||||
{
|
||||
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
|
||||
if (!resolved.Success || resolved.Value is null)
|
||||
{
|
||||
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
|
||||
return ResultContainer<TrackDto>.CreateFailResult(error);
|
||||
}
|
||||
|
||||
newTrack.ReleaseId = resolved.Value.Id;
|
||||
}
|
||||
|
||||
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
|
||||
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||
}
|
||||
@@ -115,6 +272,30 @@ public class TrackManager
|
||||
try
|
||||
{
|
||||
await Repository.UpdateAsync(TrackConverter.Convert(track));
|
||||
|
||||
// Release-cardinal edits flow through the linked release row, not the track. When the
|
||||
// track carries a Release payload and a resolved FK, load the tracked release, apply the
|
||||
// edited fields, and save. EntryKey/track fields are already persisted above.
|
||||
if (track.Release is { } release && track.ReleaseId is { } releaseId)
|
||||
{
|
||||
var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId);
|
||||
if (releaseEntity is not null)
|
||||
{
|
||||
releaseEntity.Title = release.Title;
|
||||
releaseEntity.Artist = release.Artist;
|
||||
releaseEntity.Genre = release.Genre;
|
||||
releaseEntity.Description = release.Description;
|
||||
releaseEntity.ReleaseDate = release.ReleaseDate;
|
||||
releaseEntity.ImagePath = release.ImagePath;
|
||||
releaseEntity.Medium = release.Medium;
|
||||
// DTO ReleaseType is nullable (meaningful only for Cut); the entity field is not.
|
||||
// Default to Single when null, matching TrackConverter.Convert(ReleaseDto).
|
||||
releaseEntity.ReleaseType = release.ReleaseType ?? ReleaseType.Single;
|
||||
releaseEntity.CreatedByUserId = release.CreatedByUserId;
|
||||
await Repository.UpdateReleaseAsync(releaseEntity);
|
||||
}
|
||||
}
|
||||
|
||||
var updated = await Repository.GetByIdAsync(track.Id);
|
||||
return updated is not null
|
||||
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
|
||||
@@ -128,4 +309,30 @@ public class TrackManager
|
||||
|
||||
// Delete(long) → Result is inherited from Manager<> and satisfies ITrackService.Delete
|
||||
// by signature. No override.
|
||||
|
||||
public async Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Repository.SoftDeleteReleaseAsync(id, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await Repository.CountLiveTracksByReleaseAsync(releaseId, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<DeepDrftFontLinks />
|
||||
<link href=@Assets["_content/MudBlazor/MudBlazor.min.css"] rel="stylesheet" />
|
||||
<link rel="stylesheet" href="@Assets["DeepDrftManager.styles.css"]" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/styles/deepdrft-tokens.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using DeepDrftShared.Client.Common
|
||||
|
||||
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
|
||||
<MudPopoverProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
|
||||
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
|
||||
Deep Drft — Admin
|
||||
</MudText>
|
||||
</MudAppBar>
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.Small"
|
||||
Class="d-flex flex-column justify-center align-center"
|
||||
Style="min-height: calc(100vh - 48px);">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -12,9 +12,9 @@
|
||||
Deep Drft — Admin
|
||||
</MudText>
|
||||
<MudSpacer />
|
||||
<MudTooltip Text="Back to site">
|
||||
<MudTooltip Text="Catalogue">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Home"
|
||||
Href="/"
|
||||
Href="/catalogue"
|
||||
Color="Color.Inherit" />
|
||||
</MudTooltip>
|
||||
</MudAppBar>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
@page "/"
|
||||
@layout Layout.CmsHomeLayout
|
||||
|
||||
<PageTitle>Deep Drft — Admin</PageTitle>
|
||||
|
||||
<HierarchicalRoleAuthorizeView>
|
||||
<Authorized>
|
||||
<RedirectToCatalogue />
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4" Class="my-8">
|
||||
<MudImage Fluid="true" Src="img/cms-hero.png" Alt="Deep Drft" />
|
||||
<MudText Typo="Typo.h2" Align="Align.Center">Deep Drft</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Align="Align.Center" Class="text-uppercase mud-text-secondary">
|
||||
Catalogue Management
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Href="@LoginHref"
|
||||
Class="mt-4"
|
||||
Style="min-width: 200px;">
|
||||
Login
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</NotAuthorized>
|
||||
</HierarchicalRoleAuthorizeView>
|
||||
|
||||
@code {
|
||||
private static readonly string LoginHref =
|
||||
$"/account/login?returnUrl={Uri.EscapeDataString("catalogue")}";
|
||||
}
|
||||
@@ -1,11 +1,103 @@
|
||||
@page "/"
|
||||
@page "/catalogue"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
@layout Layout.CmsLayout
|
||||
|
||||
@inject NavigationManager Nav
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject ILogger<Index> Logger
|
||||
|
||||
<PageTitle>DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h3" GutterBottom="true">DeepDrft CMS</MudText>
|
||||
<MudText Typo="Typo.body1">Administration panel — under construction.</MudText>
|
||||
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
||||
|
||||
<MudGrid Spacing="4">
|
||||
@foreach (var card in Cards)
|
||||
{
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard(card)
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// One card per release medium. Each deep-links to /releases with the medium tab pre-selected via the
|
||||
// same ?medium= convention the Add Track buttons use. The count is that medium's release total.
|
||||
private sealed record MediumCard(ReleaseMedium Medium, string Label, string Icon, Color Color);
|
||||
|
||||
private static readonly IReadOnlyList<MediumCard> Cards = new[]
|
||||
{
|
||||
new MediumCard(ReleaseMedium.Cut, "CUTS", Icons.Material.Filled.Album, Color.Primary),
|
||||
new MediumCard(ReleaseMedium.Session, "SESSIONS", Icons.Material.Filled.Mic, Color.Secondary),
|
||||
new MediumCard(ReleaseMedium.Mix, "MIXES", Icons.Material.Filled.GraphicEq, Color.Tertiary),
|
||||
};
|
||||
|
||||
// Medium → release count (null while loading or on failure). Each medium's count is one cheap paged
|
||||
// read (pageSize 1) for its TotalCount, run concurrently.
|
||||
private readonly Dictionary<ReleaseMedium, int?> _counts = new();
|
||||
private readonly HashSet<ReleaseMedium> _loading = Cards.Select(c => c.Medium).ToHashSet();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Each loader calls StateHasChanged in its finally block so its card updates as soon as its own
|
||||
// fetch returns, rather than blocking on the slowest of the three.
|
||||
await Task.WhenAll(Cards.Select(c => LoadCountAsync(c.Medium)));
|
||||
}
|
||||
|
||||
private async Task LoadCountAsync(ReleaseMedium medium)
|
||||
{
|
||||
try
|
||||
{
|
||||
// pageSize 1 — we only need TotalCount, not the rows. Sort column is required by the API but
|
||||
// immaterial to the count.
|
||||
var result = await CmsReleaseService.GetPagedAsync(
|
||||
medium, page: 1, pageSize: 1, sortColumn: "Title", sortDescending: false);
|
||||
_counts[medium] = result.Success && result.Value is not null ? result.Value.TotalCount : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard {Medium} count failed: {Error}",
|
||||
medium, result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading.Remove(medium);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment SummaryCard(MediumCard card) => __builder =>
|
||||
{
|
||||
var loading = _loading.Contains(card.Medium);
|
||||
var count = _counts.GetValueOrDefault(card.Medium);
|
||||
<MudCard Elevation="8" Style="height: 100%;">
|
||||
<MudCardContent>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
|
||||
<MudIcon Icon="@card.Icon" Color="@card.Color" Size="Size.Large" />
|
||||
@if (loading)
|
||||
{
|
||||
<MudProgressCircular Color="@card.Color" Indeterminate="true" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h3" Color="@card.Color">@(count?.ToString() ?? "—")</MudText>
|
||||
}
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@card.Label</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
<MudCardActions Class="justify-center pb-4">
|
||||
<MudButton Variant="Variant.Text" Color="@card.Color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
||||
OnClick="@(() => Nav.NavigateTo(ReleasesHref(card.Medium)))">
|
||||
View
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
};
|
||||
|
||||
// Deep-link to the Releases page with this medium's tab pre-selected. Mirrors the ?medium= seed the
|
||||
// Add Track buttons use; the Releases page reads it to set the active tab.
|
||||
private static string ReleasesHref(ReleaseMedium medium) =>
|
||||
$"/releases?medium={medium.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<MudPaper Class="pa-6 mb-4" Elevation="2">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="AlbumName" ValueChanged="@((string v) => AlbumNameChanged.InvokeAsync(v))"
|
||||
T="string" Label="Release Name" Required="true" RequiredError="Release Name is required"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="Artist" ValueChanged="@((string v) => ArtistChanged.InvokeAsync(v))"
|
||||
T="string" Label="Artist" Required="true" RequiredError="Artist is required"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="Genre" ValueChanged="@((string v) => GenreChanged.InvokeAsync(v))"
|
||||
T="string" Label="Genre" Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="ReleaseDate" ValueChanged="@((string v) => ReleaseDateChanged.InvokeAsync(v))"
|
||||
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
|
||||
T="string" Label="Description" Lines="4" MaxLength="4000"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (SelectedImageFile is { } selectedImage)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="Disabled"
|
||||
OnClick="ClearSelectedFile"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else if (ExistingImagePreviewUrl is { } previewUrl)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudImage Src="@previewUrl"
|
||||
Alt="Current cover art"
|
||||
Elevation="1"
|
||||
Style="max-width: 120px; height: auto; border-radius: 4px;" />
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Current cover art.</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@Disabled" />
|
||||
@if (SelectedImageFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
|
||||
<MediumFields @bind-Medium="MediumBinding"
|
||||
@bind-ReleaseType="ReleaseTypeBinding"
|
||||
HeroImageFile="HeroImageFile"
|
||||
HeroImageFileChanged="HeroImageFileChanged"
|
||||
AllowHeroUpload="AllowHeroUpload"
|
||||
Disabled="Disabled" />
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> AlbumNameChanged { get; set; }
|
||||
[Parameter] public string Artist { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
|
||||
[Parameter] public string Genre { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> GenreChanged { get; set; }
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
|
||||
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
|
||||
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
|
||||
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
|
||||
|
||||
// Session-only — the held hero-image file, threaded through MediumFields to SessionFields.
|
||||
// Ignored for Cut/Mix media. The parent (BatchUpload) owns it and uploads it after create.
|
||||
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
|
||||
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
|
||||
|
||||
// Gates the hero file picker in SessionFields (threaded to MediumFields → SessionFields).
|
||||
// Set true only on the BatchUpload create path; leave false/absent on all edit paths.
|
||||
[Parameter] public bool AllowHeroUpload { get; set; }
|
||||
|
||||
// BatchEdit only: when set (and no new file picked), preview the release's current cover.
|
||||
// The parent nulls this to drop the preview when the admin clears the existing cover.
|
||||
[Parameter] public string? ExistingImagePath { get; set; }
|
||||
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
private string? ExistingImagePreviewUrl =>
|
||||
string.IsNullOrEmpty(ExistingImagePath)
|
||||
? null
|
||||
: $"/api/image/{Uri.EscapeDataString(ExistingImagePath)}";
|
||||
|
||||
// MediumFields uses two-way @bind; bridge its bindings to this component's own
|
||||
// parameter/EventCallback pairs so the parent form stays the single owner of the values.
|
||||
private ReleaseMedium MediumBinding
|
||||
{
|
||||
get => Medium;
|
||||
set => MediumChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private ReleaseType ReleaseTypeBinding
|
||||
{
|
||||
get => ReleaseType;
|
||||
set => ReleaseTypeChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
|
||||
SelectedImageFileChanged.InvokeAsync(e.File);
|
||||
|
||||
private Task ClearSelectedFile() =>
|
||||
SelectedImageFileChanged.InvokeAsync(null);
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
@page "/tracks/album/{AlbumName}/edit"
|
||||
@page "/tracks/{TrackId:long}/edit"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject ILogger<BatchEdit> Logger
|
||||
|
||||
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_loadError is { } loadError)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mt-4">@loadError</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-Description="_description"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
MediumChanged="OnMediumChanged"
|
||||
@bind-SelectedImageFile="_selectedImageFile"
|
||||
ExistingImagePath="_existingImagePath"
|
||||
Disabled="_saving" />
|
||||
|
||||
@if (_existingImagePath is not null && _selectedImageFile is null)
|
||||
{
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
Disabled="_saving"
|
||||
OnClick="RemoveCover">
|
||||
Remove cover
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@* Session/Mix are single-track releases (§9.3): suppress the add-track affordance and keep the
|
||||
list collapsed to one row — OnMediumChanged trims rows 2..n when the medium switches to a
|
||||
single-track medium, mirroring BatchUpload's same-named collapse. Cut keeps the full list. *@
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_saving"
|
||||
AllowNewTracks="@(_medium == ReleaseMedium.Cut)"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||
Disabled="_saving"
|
||||
ShowTrackName="@(!MediumRules.CardinalityOf(_medium).IsSingleTrack)"
|
||||
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
||||
Disabled="_saving">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SaveAsync"
|
||||
Disabled="@(_saving || _tracks.Count == 0)">
|
||||
@if (_saving)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Saving @_processedCount / @_tracks.Count…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Save Changes</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
|
||||
// Track-id addressing (Track-mode per-row Edit, §8.M): loads the addressed track's parent
|
||||
// release and pre-selects that track's row, so editing a single Cut track lands the admin on
|
||||
// the track they clicked rather than on the release with no row context. Null for the
|
||||
// release-title route. The two routes are mutually exclusive — only one segment binds.
|
||||
[Parameter] public long? TrackId { get; set; }
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _loading = true;
|
||||
private string? _loadError;
|
||||
private bool _saving;
|
||||
private int _processedCount;
|
||||
private string? _errorMessage;
|
||||
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
private string? _existingImagePath;
|
||||
private bool _clearExistingImage;
|
||||
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
|
||||
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
|
||||
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
|
||||
// default server-side for a non-Cut medium.
|
||||
//
|
||||
// Switching to a single-track medium collapses any multi-track list to the first row so the
|
||||
// single-track invariant (§9.3) holds before save — the same collapse BatchUpload.OnMediumChanged
|
||||
// performs, reading the same MediumRules cardinality the upload service enforces. Dropping rows
|
||||
// 2..n is an in-memory trim only; existing tracks are not deleted server-side (RemoveRow owns
|
||||
// deletion), so the hidden rows simply fall out of this edit session.
|
||||
private void OnMediumChanged(ReleaseMedium medium)
|
||||
{
|
||||
_medium = medium;
|
||||
if (MediumRules.CardinalityOf(medium).IsSingleTrack && _tracks.Count > 1)
|
||||
{
|
||||
_tracks.RemoveRange(1, _tracks.Count - 1);
|
||||
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Track-addressed entry (§8.M): resolve the addressed track to its parent release title,
|
||||
// then fall through to the shared release-load path below. The clicked track's id is held
|
||||
// for row pre-selection once the list is built.
|
||||
var albumName = AlbumName;
|
||||
if (TrackId is { } trackId)
|
||||
{
|
||||
var trackResult = await CmsTrackService.GetByIdAsync(trackId);
|
||||
if (!trackResult.Success || trackResult.Value is not { } track)
|
||||
{
|
||||
_loadError = trackResult.Messages.FirstOrDefault()?.Message ?? "Track not found.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
albumName = track.Release?.Title;
|
||||
if (string.IsNullOrEmpty(albumName))
|
||||
{
|
||||
_loadError = "This track has no parent release to edit.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A single page of 100 covers the full release (albums are small — same assumption as
|
||||
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: albumName);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
_loadError = result.Messages.FirstOrDefault()?.Message ?? "Failed to load release.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var tracks = result.Value.Items.ToList();
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
_loadError = $"No tracks found for release '{albumName}'.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var release = tracks[0].Release;
|
||||
_albumName = albumName;
|
||||
_artist = release?.Artist ?? string.Empty;
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
_description = release?.Description ?? string.Empty;
|
||||
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
||||
_medium = release?.Medium ?? ReleaseMedium.Cut;
|
||||
_existingImagePath = release?.ImagePath;
|
||||
|
||||
_tracks = tracks.Select(t => new BatchRowModel
|
||||
{
|
||||
Id = t.Id,
|
||||
EntryKey = t.EntryKey,
|
||||
OriginalFileName = t.OriginalFileName,
|
||||
TrackName = t.TrackName,
|
||||
TrackNumber = t.TrackNumber,
|
||||
WavFile = null,
|
||||
Status = BatchRowStatus.Queued
|
||||
}).ToList();
|
||||
|
||||
// Same single-track collapse on the load path, via the shared MediumRules declaration: a
|
||||
// release whose stored medium is single-track surfaces only its first row for editing.
|
||||
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 1)
|
||||
{
|
||||
_tracks.RemoveRange(1, _tracks.Count - 1);
|
||||
}
|
||||
|
||||
// Track-addressed entry pre-selects the clicked row (§8.M Option 2). For a multi-track Cut
|
||||
// the addressed track may be any ordinal; for single-track media it is always row 0 (the
|
||||
// collapse above leaves one row). Fall back to row 0 if the id is absent or trimmed away.
|
||||
_selectedIndex = ResolveInitialSelection();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private int ResolveInitialSelection()
|
||||
{
|
||||
if (_tracks.Count == 0) return -1;
|
||||
if (TrackId is { } trackId)
|
||||
{
|
||||
var addressed = _tracks.FindIndex(t => t.Id == trackId);
|
||||
if (addressed >= 0) return addressed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
_errorMessage = null;
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
// New rows carry no Id — they take the upload path on save.
|
||||
_tracks.Add(new BatchRowModel
|
||||
{
|
||||
WavFile = file,
|
||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||
});
|
||||
}
|
||||
|
||||
if (_selectedIndex < 0 && _tracks.Count > 0)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveUp(int i)
|
||||
{
|
||||
if (i == 0) return;
|
||||
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i - 1;
|
||||
else if (_selectedIndex == i - 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void MoveDown(int i)
|
||||
{
|
||||
if (i == _tracks.Count - 1) return;
|
||||
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i + 1;
|
||||
else if (_selectedIndex == i + 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private async Task RemoveRow(int index)
|
||||
{
|
||||
var row = _tracks[index];
|
||||
if (row.Id.HasValue)
|
||||
{
|
||||
// Existing track — confirm before deleting.
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Remove track",
|
||||
$"Remove '{row.TrackName}' from this release? This deletes the track permanently.",
|
||||
yesText: "Remove", cancelText: "Cancel");
|
||||
if (confirmed != true) return;
|
||||
|
||||
var result = await CmsTrackService.DeleteTrackAsync(row.Id.Value);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// New track (not yet uploaded) or confirmed existing delete — remove from list.
|
||||
_tracks.RemoveAt(index);
|
||||
if (index < _selectedIndex) _selectedIndex--;
|
||||
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||
}
|
||||
|
||||
private void RemoveCover()
|
||||
{
|
||||
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
|
||||
// the existing path here drops the preview in AlbumHeaderFields.
|
||||
_clearExistingImage = true;
|
||||
_existingImagePath = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_albumName))
|
||||
{
|
||||
_errorMessage = "Release Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tracks.Count == 0)
|
||||
{
|
||||
_errorMessage = "A release must have at least one track.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
// New rows (no Id) need a WAV; existing rows keep their vault audio.
|
||||
foreach (var t in _tracks)
|
||||
{
|
||||
if (!t.Id.HasValue && t.WavFile is null)
|
||||
{
|
||||
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// [Authorize]/Admin-gated page — an unparseable id here is a configuration bug.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
DateOnly? releaseDate = string.IsNullOrWhiteSpace(_releaseDate)
|
||||
? null
|
||||
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
|
||||
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
|
||||
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
|
||||
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
|
||||
|
||||
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
||||
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
|
||||
// carry through to the stored track name.
|
||||
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
|
||||
{
|
||||
_tracks[0].TrackName = _albumName;
|
||||
}
|
||||
|
||||
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
||||
_saving = true;
|
||||
_processedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Upload any newly picked cover art once; abort if it fails so we never point metadata
|
||||
// at an image that was never stored.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
// Tri-state cover for UpdateAsync: a freshly uploaded path sets it; an explicit clear
|
||||
// sends ""; otherwise null leaves the existing cover untouched.
|
||||
string? imagePathForUpdate =
|
||||
_imagePath is { } newPath ? newPath
|
||||
: _clearExistingImage ? ""
|
||||
: null;
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
for (int i = 0; i < _tracks.Count; i++)
|
||||
{
|
||||
var row = _tracks[i];
|
||||
|
||||
if (row.Status == BatchRowStatus.Done)
|
||||
{
|
||||
_processedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var trackNumber = i + 1; // 1-based ordinal from list position
|
||||
|
||||
row.Status = BatchRowStatus.Uploading;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
if (row.Id.HasValue)
|
||||
{
|
||||
// Existing track — metadata-only update; audio stays in the vault.
|
||||
var updateResult = await CmsTrackService.UpdateAsync(
|
||||
row.Id.Value,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
imagePathForUpdate,
|
||||
_releaseType,
|
||||
_medium,
|
||||
trackNumber);
|
||||
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
var error = updateResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch edit: update for '{TrackName}' (id={Id}) failed: {Error}",
|
||||
row.TrackName, row.Id.Value, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New track — upload, then link cover art with a follow-up update (same
|
||||
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
|
||||
row.UploadedBytes = 0;
|
||||
row.TotalBytes = row.WavFile!.Size;
|
||||
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
|
||||
|
||||
// Re-render only on whole-percent change so a large upload paints ~100 frames,
|
||||
// not thousands. Progress<T> marshals back onto the renderer dispatcher.
|
||||
var lastPercent = -1;
|
||||
var progress = new Progress<long>(written =>
|
||||
{
|
||||
row.UploadedBytes = written;
|
||||
if (row.UploadPercent != lastPercent)
|
||||
{
|
||||
lastPercent = row.UploadPercent;
|
||||
StateHasChanged();
|
||||
}
|
||||
});
|
||||
|
||||
var uploadResult = await CmsTrackService.UploadTrackAsync(
|
||||
wavStream,
|
||||
row.WavFile.Size,
|
||||
row.WavFile.Name,
|
||||
row.WavFile.ContentType,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber,
|
||||
_medium,
|
||||
progress);
|
||||
|
||||
if (!uploadResult.Success || uploadResult.Value is null)
|
||||
{
|
||||
var error = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch edit: upload for new track '{TrackName}' failed: {Error}",
|
||||
row.TrackName, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Link a cover only when one is actively set ("" clear doesn't apply to
|
||||
// a brand-new track that has no cover yet).
|
||||
if (imagePathForUpdate is { Length: > 0 } linkPath)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
uploadResult.Value.Id,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
linkPath,
|
||||
_releaseType,
|
||||
_medium,
|
||||
trackNumber);
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track persisted; cover can be re-linked by re-editing.
|
||||
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
||||
row.TrackName, uploadResult.Value.Id);
|
||||
}
|
||||
}
|
||||
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Batch edit: exception processing '{TrackName}'", row.TrackName);
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = "Save failed — please try again.";
|
||||
failed++;
|
||||
}
|
||||
|
||||
_processedCount++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
||||
Navigation.NavigateTo("/releases");
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Saved {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||
// Stay on page so the admin can see the failed rows.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
|
||||
/// <summary>
|
||||
/// A single track row shared by <c>BatchUpload</c> (all rows are new uploads) and
|
||||
/// <c>BatchEdit</c> (existing rows carry <see cref="Id"/>; admins may also add new upload rows).
|
||||
/// </summary>
|
||||
public class BatchRowModel
|
||||
{
|
||||
/// <summary>SQL id of an existing track. <c>null</c> means a new row to upload.</summary>
|
||||
public long? Id { get; set; }
|
||||
|
||||
/// <summary>Vault entry key — existing rows only.</summary>
|
||||
public string? EntryKey { get; set; }
|
||||
|
||||
/// <summary>Original upload filename — existing rows only, read-only display.</summary>
|
||||
public string? OriginalFileName { get; set; }
|
||||
|
||||
/// <summary>Selected WAV — new rows only.</summary>
|
||||
public IBrowserFile? WavFile { get; set; }
|
||||
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
|
||||
public int TrackNumber { get; set; }
|
||||
|
||||
public BatchRowStatus Status { get; set; } = BatchRowStatus.Queued;
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>Bytes pushed to the wire so far for this row's in-flight upload. Reset per attempt.</summary>
|
||||
public long UploadedBytes { get; set; }
|
||||
|
||||
/// <summary>Total payload bytes for this row (the WAV file size), the progress denominator.</summary>
|
||||
public long TotalBytes { get; set; }
|
||||
|
||||
/// <summary>Upload completion as a 0–100 percent, or 0 when the total is unknown.</summary>
|
||||
public int UploadPercent => TotalBytes > 0
|
||||
? (int)Math.Clamp(UploadedBytes * 100 / TotalBytes, 0, 100)
|
||||
: 0;
|
||||
}
|
||||
|
||||
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
|
||||
@@ -0,0 +1,69 @@
|
||||
@if (SelectedTrack is null)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Color="Color.Default">Select a track from the list to edit its details.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
@if (ShowTrackName)
|
||||
{
|
||||
<MudTextField Value="SelectedTrack.TrackName"
|
||||
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
|
||||
T="string"
|
||||
Label="Track Name"
|
||||
Required="true"
|
||||
RequiredError="Track Name is required"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="Disabled" />
|
||||
}
|
||||
|
||||
@if (SelectedTrack.Id.HasValue)
|
||||
{
|
||||
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">Existing track — audio is not editable.</MudText>
|
||||
</MudField>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudField Label="WAV File" Variant="Variant.Outlined" InnerPadding="false">
|
||||
@if (SelectedTrack.WavFile is { } wav)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@wav.Name (@FormatBytes(wav.Size))</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Error">No WAV file selected.</MudText>
|
||||
}
|
||||
</MudField>
|
||||
}
|
||||
|
||||
@if (SelectedTrack.Status == BatchRowStatus.Failed)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@SelectedTrack.ErrorMessage</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public BatchRowModel? SelectedTrack { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public EventCallback<string> TrackNameChanged { get; set; }
|
||||
/// <summary>
|
||||
/// When false (single-track Session/Mix), the Track Name field is suppressed — the name is
|
||||
/// derived from the Release Name by the parent form and never entered independently.
|
||||
/// Defaults to true so the Cut multi-track path is unchanged.
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowTrackName { get; set; } = true;
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
const long KB = 1024;
|
||||
const long MB = KB * 1024;
|
||||
const long GB = MB * 1024;
|
||||
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
|
||||
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
|
||||
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
|
||||
return $"{bytes} bytes";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" GutterBottom="true">Tracks</MudText>
|
||||
|
||||
@if (AllowNewTracks)
|
||||
{
|
||||
<InputFile OnChange="HandleWavFilesSelected" accept=".wav,audio/wav,audio/x-wav" multiple disabled="@Disabled" />
|
||||
}
|
||||
|
||||
@if (Tracks.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default" Class="mt-3">No tracks added yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="BatchRowModel" Class="mt-3">
|
||||
@for (var i = 0; i < Tracks.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var row = Tracks[index];
|
||||
<div style="@RowStyle(index)" @onclick="() => SelectRow(index)">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="pa-2">
|
||||
<MudText Typo="Typo.body2" Style="min-width: 1.5rem;">@(index + 1).</MudText>
|
||||
<MudText Typo="Typo.body2" Style="flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@row.TrackName</MudText>
|
||||
@StatusChip(row)
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
||||
Size="Size.Small"
|
||||
Disabled="@(index == 0 || Disabled)"
|
||||
OnClick="@(() => OnMoveUp.InvokeAsync(index))"
|
||||
aria-label="Move track up" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
||||
Size="Size.Small"
|
||||
Disabled="@(index == Tracks.Count - 1 || Disabled)"
|
||||
OnClick="@(() => OnMoveDown.InvokeAsync(index))"
|
||||
aria-label="Move track down" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="@Disabled"
|
||||
OnClick="@(() => OnRemove.InvokeAsync(index))"
|
||||
aria-label="Remove track" />
|
||||
</MudStack>
|
||||
@if (row.Status == BatchRowStatus.Uploading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Info"
|
||||
Value="@row.UploadPercent"
|
||||
Class="mx-2 mb-2"
|
||||
aria-label="@($"Uploading {row.TrackName}")" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<BatchRowModel> Tracks { get; set; } = new();
|
||||
[Parameter] public int SelectedIndex { get; set; }
|
||||
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNewTracks { get; set; } = true;
|
||||
[Parameter] public EventCallback<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMoveUp { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMoveDown { get; set; }
|
||||
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||
|
||||
private const int MaxFilesPerPick = 50;
|
||||
|
||||
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
|
||||
|
||||
private Task HandleWavFilesSelected(InputFileChangeEventArgs e) =>
|
||||
OnWavFilesSelected.InvokeAsync(e.GetMultipleFiles(MaxFilesPerPick));
|
||||
|
||||
private string RowStyle(int index)
|
||||
{
|
||||
const string baseStyle = "cursor: pointer; border-radius: 4px;";
|
||||
return index == SelectedIndex
|
||||
? $"{baseStyle} background-color: var(--mud-palette-action-default-hover);"
|
||||
: baseStyle;
|
||||
}
|
||||
|
||||
private RenderFragment StatusChip(BatchRowModel row) => row.Status switch
|
||||
{
|
||||
BatchRowStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
|
||||
BatchRowStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
|
||||
BatchRowStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
|
||||
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
@page "/tracks/upload"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<BatchUpload> Logger
|
||||
|
||||
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
|
||||
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-Description="_description"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
MediumChanged="OnMediumChanged"
|
||||
@bind-SelectedImageFile="_selectedImageFile"
|
||||
@bind-HeroImageFile="_heroImageFile"
|
||||
AllowHeroUpload="true"
|
||||
Disabled="_uploading" />
|
||||
|
||||
@if (_medium == ReleaseMedium.Cut)
|
||||
{
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_uploading"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||
Disabled="_uploading"
|
||||
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Session/Mix are single-track releases — no multi-track master list. A single WAV slot. *@
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.subtitle1">Track</MudText>
|
||||
<InputFile OnChange="HandleSingleWavSelected" accept=".wav,audio/wav,audio/x-wav" disabled="@_uploading" />
|
||||
@if (_tracks.Count > 0)
|
||||
{
|
||||
@* Track name is derived from the Release Name for Session/Mix — no separate input. *@
|
||||
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
|
||||
@if (_tracks[0].Status == BatchRowStatus.Uploading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Info"
|
||||
Value="@_tracks[0].UploadPercent"
|
||||
aria-label="Uploading track" />
|
||||
}
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_warningMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mt-4">@_warningMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
||||
Disabled="_uploading">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SubmitAsync"
|
||||
Disabled="@(_uploading || _tracks.Count == 0)">
|
||||
@if (_uploading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Uploading @_uploadedCount / @_tracks.Count…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Upload Release</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _uploading;
|
||||
private int _uploadedCount;
|
||||
private string? _errorMessage;
|
||||
// Separate from _errorMessage: a soft non-blocking nudge (Severity.Warning), not a hard failure.
|
||||
private string? _warningMessage;
|
||||
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
|
||||
// Session-only: the hero image is resource-addressed and cannot be uploaded until the release
|
||||
// exists, so it is held here and POSTed to api/release/{id}/session/hero-image after create.
|
||||
private IBrowserFile? _heroImageFile;
|
||||
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
|
||||
private bool _heroWarningAcknowledged;
|
||||
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
|
||||
// Optional pre-select from the Add-Track buttons (§8.E): /tracks/upload?medium=session lands the
|
||||
// form already in Session mode. A seed only — the medium selector stays user-changeable after load.
|
||||
// Unrecognised/absent values fall through to the Cut default (same defensive posture as the API's
|
||||
// TrackController.UploadTrack medium parse).
|
||||
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Seed the medium from the query param so a pre-selected upload form (e.g. the Sessions tab's
|
||||
// Add Track) lands already showing that medium's conditional fields. Goes through OnMediumChanged
|
||||
// so the single-track collapse runs identically to a user selector change.
|
||||
if (!string.IsNullOrWhiteSpace(MediumParam)
|
||||
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
|
||||
&& Enum.IsDefined(medium))
|
||||
{
|
||||
OnMediumChanged(medium);
|
||||
}
|
||||
}
|
||||
|
||||
// Switching to a single-track medium collapses any multi-track selection to the first row so the
|
||||
// single-track invariant holds before submit. The predicate reads the same MediumRules cardinality
|
||||
// declaration the upload service enforces, so the form and the domain cannot drift.
|
||||
private void OnMediumChanged(ReleaseMedium medium)
|
||||
{
|
||||
_medium = medium;
|
||||
if (MediumRules.CardinalityOf(medium).IsSingleTrack && _tracks.Count > 1)
|
||||
{
|
||||
_tracks.RemoveRange(1, _tracks.Count - 1);
|
||||
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-track WAV picker for Session/Mix: replaces the one row rather than appending.
|
||||
private void HandleSingleWavSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_errorMessage = null;
|
||||
var file = e.File;
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
_tracks.Clear();
|
||||
_tracks.Add(new BatchRowModel
|
||||
{
|
||||
WavFile = file,
|
||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||
});
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
_errorMessage = null;
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
_tracks.Add(new BatchRowModel
|
||||
{
|
||||
WavFile = file,
|
||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||
});
|
||||
}
|
||||
|
||||
if (_selectedIndex < 0 && _tracks.Count > 0)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveUp(int i)
|
||||
{
|
||||
if (i == 0) return;
|
||||
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i - 1;
|
||||
else if (_selectedIndex == i - 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void MoveDown(int i)
|
||||
{
|
||||
if (i == _tracks.Count - 1) return;
|
||||
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i + 1;
|
||||
else if (_selectedIndex == i + 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void RemoveRow(int i)
|
||||
{
|
||||
_tracks.RemoveAt(i);
|
||||
if (i < _selectedIndex) _selectedIndex--;
|
||||
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
_warningMessage = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_albumName))
|
||||
{
|
||||
_errorMessage = "Release Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tracks.Count == 0)
|
||||
{
|
||||
_errorMessage = "Add at least one track.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var t in _tracks)
|
||||
{
|
||||
if (t.WavFile is null)
|
||||
{
|
||||
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// The page is gated by [Authorize] under the Admin role, so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
// A Session's hero is its primary visual identity on the public detail page. It is optional —
|
||||
// a Session can be authored without one and set later from the Sessions browser — but a missing
|
||||
// hero is usually an oversight, so warn (do not block). The first submit without a hero shows the
|
||||
// warning and primes acknowledgment; a second submit proceeds.
|
||||
if (_medium == ReleaseMedium.Session && _heroImageFile is null && !_heroWarningAcknowledged)
|
||||
{
|
||||
_heroWarningAcknowledged = true;
|
||||
_warningMessage = "No hero image selected. A Session usually needs one — you can add it now, "
|
||||
+ "or submit again to create the Session without it (set the hero later from the Sessions browser).";
|
||||
return;
|
||||
}
|
||||
|
||||
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
||||
// no separate Track Name input is shown. Sync here so the stored name always matches.
|
||||
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
|
||||
{
|
||||
_tracks[0].TrackName = _albumName;
|
||||
}
|
||||
|
||||
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
||||
_uploading = true;
|
||||
_uploadedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Upload any selected cover art once; abort the submit if it fails so we never
|
||||
// create tracks expecting an image that was never stored in the vault.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
for (int i = 0; i < _tracks.Count; i++)
|
||||
{
|
||||
var row = _tracks[i];
|
||||
var trackNumber = i + 1; // 1-based ordinal from list position
|
||||
|
||||
row.Status = BatchRowStatus.Uploading;
|
||||
StateHasChanged();
|
||||
|
||||
row.UploadedBytes = 0;
|
||||
row.TotalBytes = row.WavFile!.Size;
|
||||
|
||||
try
|
||||
{
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in ProgressStreamContent so the whole file is never materialised
|
||||
// in memory before DeepDrftAPI receives it, and reports bytes-on-the-wire back here.
|
||||
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
|
||||
|
||||
// Progress ticks fire ~once per 80 KB; re-render only when the whole-percent changes
|
||||
// so a half-gig upload paints ~100 frames, not thousands. Progress<T> marshals the
|
||||
// callback onto the component's renderer dispatcher, so StateHasChanged is safe here.
|
||||
var lastPercent = -1;
|
||||
var progress = new Progress<long>(written =>
|
||||
{
|
||||
row.UploadedBytes = written;
|
||||
if (row.UploadPercent != lastPercent)
|
||||
{
|
||||
lastPercent = row.UploadPercent;
|
||||
StateHasChanged();
|
||||
}
|
||||
});
|
||||
|
||||
var result = await CmsTrackService.UploadTrackAsync(
|
||||
wavStream,
|
||||
row.WavFile.Size,
|
||||
row.WavFile.Name,
|
||||
row.WavFile.ContentType,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber,
|
||||
_medium,
|
||||
progress);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch upload: track '{TrackName}' failed: {Error}", row.TrackName, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with
|
||||
// a follow-up metadata update — same two-step pattern BatchEdit uses.
|
||||
if (_imagePath is { } imgPath)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
result.Value.Id,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath,
|
||||
_releaseType,
|
||||
_medium,
|
||||
trackNumber);
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track is persisted; cover art can be re-linked by editing.
|
||||
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
|
||||
row.TrackName, result.Value.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Session hero image is resource-addressed, so it is uploaded here — after the
|
||||
// release exists and we have its id — within the same submit gesture. Non-blocking:
|
||||
// the Session is persisted; a failed hero upload is recoverable from the Sessions
|
||||
// browser's per-row Set/Replace hero action.
|
||||
if (_medium == ReleaseMedium.Session
|
||||
&& _heroImageFile is { } heroFile
|
||||
&& result.Value.ReleaseId is { } sessionReleaseId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var heroStream = heroFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var heroResult = await CmsReleaseService.UploadSessionHeroImageAsync(
|
||||
sessionReleaseId, heroStream, heroFile.Name, heroFile.ContentType);
|
||||
if (!heroResult.Success)
|
||||
{
|
||||
var heroError = heroResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Logger.LogWarning("Batch upload: hero image upload failed for release {ReleaseId} ('{TrackName}'): {Error}",
|
||||
sessionReleaseId, row.TrackName, heroError);
|
||||
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
catch (Exception heroEx)
|
||||
{
|
||||
Logger.LogError(heroEx, "Batch upload: exception uploading hero image for release {ReleaseId}", sessionReleaseId);
|
||||
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
else if (_medium == ReleaseMedium.Session && _heroImageFile is not null)
|
||||
{
|
||||
// ReleaseId was null on a Session track result — internal inconsistency.
|
||||
// Hero file is held but cannot be uploaded without a release id; log and
|
||||
// surface so the admin can set it from the Sessions browser.
|
||||
Logger.LogWarning("Batch upload: Session track '{TrackName}' (id={Id}) has no ReleaseId — hero image dropped",
|
||||
row.TrackName, result.Value.Id);
|
||||
Snackbar.Add("Session uploaded, but the hero image could not be linked (no release id). Set it from the Sessions browser.", Severity.Warning);
|
||||
}
|
||||
|
||||
// Mix uploads fire the server-side high-res waveform trigger (§3.4). The CMS
|
||||
// computes nothing — the API derives the datum from the audio it just stored.
|
||||
// Non-blocking: the track is persisted; a failed trigger is recoverable from
|
||||
// the Mixes browser's per-row Generate action.
|
||||
if (_medium == ReleaseMedium.Mix && result.Value.ReleaseId is { } mixReleaseId)
|
||||
{
|
||||
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
|
||||
if (!waveformResult.Success)
|
||||
{
|
||||
Logger.LogWarning("Batch upload: mix waveform trigger failed for release {ReleaseId} ('{TrackName}')",
|
||||
mixReleaseId, row.TrackName);
|
||||
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName);
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = "Upload failed — please try again.";
|
||||
failed++;
|
||||
}
|
||||
|
||||
_uploadedCount++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
|
||||
Navigation.NavigateTo("/releases");
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||
// Stay on page so the admin can see the failed rows.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_uploading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsAlbumBrowser> Logger
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No releases found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable T="AlbumRow"
|
||||
Items="_rows"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%;"></MudTh>
|
||||
<MudTh Style="width: 1%;">Art</MudTh>
|
||||
<MudTh>Album</MudTh>
|
||||
<MudTh>Artist</MudTh>
|
||||
<MudTh>Genre</MudTh>
|
||||
<MudTh>Release Date</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
|
||||
@foreach (var column in SpecialColumns)
|
||||
{
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">@column.Header</MudTh>
|
||||
}
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@(context.IsExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => ToggleExpand(context))" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Art">
|
||||
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
|
||||
{
|
||||
<div class="cms-album-thumb"
|
||||
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Album">@context.Release.Title</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Type">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">
|
||||
@(context.Release.Medium == ReleaseMedium.Cut
|
||||
? context.Release.ReleaseType?.ToString() ?? "—"
|
||||
: MediumTypeLabels[context.Release.Medium])
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
|
||||
@foreach (var column in SpecialColumns)
|
||||
{
|
||||
@* One dedicated cell per host-declared special-action column (Mix waveform, Session hero).
|
||||
The Cell fragment recovers its typed row state via the host's RowFor lookup. Sits between
|
||||
Tracks and Actions so the universal Edit/Delete stay rightmost. *@
|
||||
<MudTd DataLabel="@column.Header">@column.Cell(context.Release)</MudTd>
|
||||
}
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Batch Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/album/{Uri.EscapeDataString(context.Release.Title)}/edit")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete release">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
Disabled="@context.IsDeleting"
|
||||
OnClick="@(() => ConfirmAndDeleteAlbum(context))" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<ChildRowContent>
|
||||
@if (context.IsExpanded)
|
||||
{
|
||||
<MudTr>
|
||||
<MudTd colspan="@ColumnCount" Style="padding: 0;">
|
||||
@if (context.IsLoading)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Typo="Typo.body2">Loading tracks…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else if (context.Tracks is { Count: 0 })
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="pa-4">No tracks found.</MudText>
|
||||
}
|
||||
else if (context.Tracks is not null)
|
||||
{
|
||||
<MudTable T="TrackDto" Items="context.Tracks" Context="track" Dense="true" Hover="false"
|
||||
Elevation="0" Style="background: transparent;">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
||||
<MudTh>Track Name</MudTh>
|
||||
@* Per-track waveform-datum status + generate (migrated from the retired
|
||||
CmsTrackGrid). The expanded child row is the releases view's only
|
||||
per-track surface, so the unique per-track Profile / High-res columns
|
||||
live here. *@
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
|
||||
@* Info column: per-track EntryKey + OriginalFileName tooltip (migrated
|
||||
from the retired CmsTrackGrid's .cms-track-info monospace block). *@
|
||||
<MudTh Style="width: 1%;"></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
||||
<MudTd DataLabel="Profile">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
@if (HasProfile(track.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="Profile generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@(HasProfile(track.EntryKey) ? "Regenerate profile" : "Generate profile")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@_generating.Contains(track.EntryKey)"
|
||||
OnClick="@(() => GenerateProfileAsync(track))" />
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="High-res">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
@if (HasHighRes(track.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="High-res datum generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@(HasHighRes(track.EntryKey) ? "Regenerate high-res datum" : "Generate high-res datum")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Waves"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@_generatingHighRes.Contains(track.EntryKey)"
|
||||
OnClick="@(() => GenerateHighResAsync(track))" />
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
@* Per-track info tooltip (restored from the retired CmsTrackGrid's
|
||||
.cms-track-info monospace block): EntryKey + OriginalFileName. *@
|
||||
<MudTd>
|
||||
<MudTooltip Placement="Placement.Left">
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.EntryKey</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(track.OriginalFileName))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.OriginalFileName</MudText>
|
||||
}
|
||||
</TooltipContent>
|
||||
<ChildContent>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.Info"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default" />
|
||||
</ChildContent>
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudTd>
|
||||
</MudTr>
|
||||
}
|
||||
</ChildRowContent>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ReleaseDto> Releases { get; set; } = Array.Empty<ReleaseDto>();
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires after any per-row waveform generate (profile or high-res) succeeds. The parent page
|
||||
/// wires this to its own <c>RefreshWaveformStatusAsync</c> so its missing-count badges stay
|
||||
/// current after an individual-row generate inside an expanded album row.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached per-track waveform status so the next row expand re-fetches fresh data
|
||||
/// from the API. Called by the parent page after a catalogue-wide bulk run so already-expanded
|
||||
/// rows reflect the new state on the next expand interaction.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync()
|
||||
{
|
||||
_profileStatus = null;
|
||||
_highResStatus = null;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Zero or more dedicated, header-labelled special-action columns (Session hero upload, Mix waveform
|
||||
// generate), each rendered as its own header cell + per-row cell between the Tracks and Actions
|
||||
// columns. The ALL and Cut tabs leave this empty and render exactly as before — only the standard
|
||||
// columns plus Edit/Delete. A per-medium host supplies its bespoke affordances here so the rich
|
||||
// expand/delete/Type-chip/edit logic stays single-sourced in this grid rather than forked.
|
||||
[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; } = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
// Base columns: expand, Art, Album, Artist, Genre, Release Date, Type, Tracks, Actions = 9.
|
||||
private const int BaseColumnCount = 9;
|
||||
|
||||
// Total rendered columns, driving the expanded child-row colspan so it always spans the full table
|
||||
// regardless of how many special-action columns the host declared.
|
||||
private int ColumnCount => BaseColumnCount + SpecialColumns.Count;
|
||||
|
||||
private List<AlbumRow> _rows = new();
|
||||
|
||||
// Tracks the Releases reference last projected into _rows. Guards against OnParametersSet
|
||||
// resurrecting a row we removed locally on delete: while the parent holds the same Releases
|
||||
// instance (e.g. a mid-operation re-render under IsDeleting, before any refresh hands us a new
|
||||
// list), a blind rebuild every render would bring the deleted row back. We only re-project when
|
||||
// the parent hands us a genuinely new list.
|
||||
private IReadOnlyList<ReleaseDto>? _projectedReleases;
|
||||
|
||||
// Re-project rows only when the parent supplies a genuinely new release list (reference change).
|
||||
// Local edits to _rows (a removed row after delete) must survive re-renders triggered by the
|
||||
// same Releases instance.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_projectedReleases, Releases))
|
||||
{
|
||||
_projectedReleases = Releases;
|
||||
_rows = Releases.Select(r => new AlbumRow { Release = r }).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
private static string ThumbUrl(string imagePath) =>
|
||||
$"/api/image/{Uri.EscapeDataString(imagePath)}";
|
||||
|
||||
// Medium → Type-chip display label for non-Cut media. Cut rows show ReleaseType instead.
|
||||
// One entry per non-Cut medium; a future medium adds one line here, no markup change needed.
|
||||
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTypeLabels =
|
||||
new Dictionary<ReleaseMedium, string>
|
||||
{
|
||||
[ReleaseMedium.Session] = "Session",
|
||||
[ReleaseMedium.Mix] = "DJ Mix",
|
||||
};
|
||||
|
||||
// EntryKey → HasProfile / HasHighRes for the expanded-row per-track waveform columns (migrated from
|
||||
// the retired CmsTrackGrid). Loaded once per grid instance on first row expand; a per-row generate
|
||||
// flips a single entry to true. Null until first loaded.
|
||||
private Dictionary<string, bool>? _profileStatus;
|
||||
private Dictionary<string, bool>? _highResStatus;
|
||||
private readonly HashSet<string> _generating = new();
|
||||
private readonly HashSet<string> _generatingHighRes = new();
|
||||
|
||||
private bool HasProfile(string entryKey) =>
|
||||
_profileStatus is not null && _profileStatus.TryGetValue(entryKey, out var has) && has;
|
||||
|
||||
private bool HasHighRes(string entryKey) =>
|
||||
_highResStatus is not null && _highResStatus.TryGetValue(entryKey, out var has) && has;
|
||||
|
||||
// Fetch the catalogue-wide waveform status once and cache it. The admin catalogue is small (one unpaged
|
||||
// call covers it), and per-track status only matters for rows the admin actually expands.
|
||||
private async Task EnsureWaveformStatusAsync()
|
||||
{
|
||||
if (_profileStatus is not null) return;
|
||||
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
_profileStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile);
|
||||
_highResStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasHighRes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Leave both empty (not null) so we do not re-fetch on every expand after a transient failure;
|
||||
// the next OnReleasesChanged refresh path will rebuild the grid and retry.
|
||||
_profileStatus = new Dictionary<string, bool>();
|
||||
_highResStatus = new Dictionary<string, bool>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateProfileAsync(TrackDto track)
|
||||
{
|
||||
_generating.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
(_profileStatus ??= new())[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
|
||||
await OnWaveformGenerated.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generating.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateHighResAsync(TrackDto track)
|
||||
{
|
||||
_generatingHighRes.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateHighResWaveformAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
(_highResStatus ??= new())[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated high-res datum for '{track.TrackName}'.", Severity.Success);
|
||||
await OnWaveformGenerated.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"High-res generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"High-res generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generatingHighRes.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleExpand(AlbumRow row)
|
||||
{
|
||||
row.IsExpanded = !row.IsExpanded;
|
||||
if (row.IsExpanded && row.Tracks is null && !row.IsLoading)
|
||||
{
|
||||
row.IsLoading = true;
|
||||
StateHasChanged();
|
||||
row.Tracks = await LoadTracksAsync(row.Release.Title);
|
||||
// The per-track Profile / High-res columns need waveform status for the rows just loaded.
|
||||
// Loaded once for the catalogue on first expand and cached for this grid instance.
|
||||
await EnsureWaveformStatusAsync();
|
||||
row.IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Albums are small releases; a single page of 100 always covers the full track list (see brief).
|
||||
private async Task<List<TrackDto>> LoadTracksAsync(string albumTitle)
|
||||
{
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: albumTitle);
|
||||
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
return result.Value.Items.ToList();
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks for '{albumTitle}': {error}", Severity.Error);
|
||||
return new List<TrackDto>();
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDeleteAlbum(AlbumRow row)
|
||||
{
|
||||
// Need track IDs to delete; load them if the row was never expanded.
|
||||
row.Tracks ??= await LoadTracksAsync(row.Release.Title);
|
||||
var tracks = row.Tracks;
|
||||
var count = tracks.Count;
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
// Orphaned release: every track was soft-deleted earlier, leaving a 0-track row that
|
||||
// cannot be cleared by deleting tracks. Delete the release record directly instead.
|
||||
await ConfirmAndDeleteEmptyReleaseAsync(row);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete release",
|
||||
markupMessage: new MarkupString(
|
||||
$"Delete all <strong>{count}</strong> track(s) in <strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong>? This removes metadata and audio for every track."),
|
||||
yesText: "Delete all",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
row.IsDeleting = true;
|
||||
StateHasChanged();
|
||||
|
||||
var failures = 0;
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var del = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (!del.Success) failures++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
row.IsDeleting = false;
|
||||
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
|
||||
_rows.Remove(row);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"{count - failures} of {count} track(s) deleted; {failures} failed.", Severity.Warning);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade
|
||||
// delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the
|
||||
// parent so its release list stays in sync with what is shown.
|
||||
private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete release",
|
||||
markupMessage: new MarkupString(
|
||||
$"<strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong> has no tracks. Delete this empty release record?"),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
row.IsDeleting = true;
|
||||
StateHasChanged();
|
||||
|
||||
var result = await CmsTrackService.DeleteReleaseAsync(row.Release.Id);
|
||||
|
||||
row.IsDeleting = false;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
|
||||
_rows.Remove(row);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private sealed class AlbumRow
|
||||
{
|
||||
public required ReleaseDto Release { get; init; }
|
||||
public List<TrackDto>? Tracks { get; set; } // null = not yet loaded
|
||||
public bool IsExpanded { get; set; }
|
||||
public bool IsLoading { get; set; }
|
||||
public bool IsDeleting { get; set; }
|
||||
|
||||
// Server-projected count from GetReleasesAsync. Drives the Tracks column without a lazy load.
|
||||
public int TrackCount => Release.TrackCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.cms-album-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-album-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@* The ALL-tab content (Phase 9 §8.B): the cross-medium all-releases grid (CUTS, SESSIONS, MIXES
|
||||
together) with per-row edit, delete, expand-tracks, and the 8.D Type chip. Self-contained — owns its
|
||||
own data load so a host (TrackList today, the 8.A tab strip later) renders it with no parameters and
|
||||
no VM plumbing. Re-loads on first render and re-fetches after a row mutation so the list stays in
|
||||
sync with the catalogue. *@
|
||||
<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="_releases"
|
||||
IsLoading="_loading"
|
||||
OnReleasesChanged="OnGridReleasesChanged"
|
||||
OnWaveformGenerated="OnWaveformGenerated" />
|
||||
|
||||
@code {
|
||||
// Fires after a row mutation (delete) so a host can invalidate sibling caches derived from the same
|
||||
// catalogue — e.g. TrackList's genre cache. The grid refreshes its own list regardless; this is a
|
||||
// notification, not the data source. Optional: an embed that has no sibling state leaves it unset.
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
private IReadOnlyList<ReleaseDto> _releases = Array.Empty<ReleaseDto>();
|
||||
private bool _loading = true;
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override Task OnInitializedAsync() => ReloadAsync();
|
||||
|
||||
private async Task OnGridReleasesChanged()
|
||||
{
|
||||
await ReloadAsync();
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
|
||||
// Single load path: the initial fetch and the post-mutation refresh both run through here. After a
|
||||
// delete CmsAlbumBrowser has already dropped the row from its own projection, so this re-fetch
|
||||
// reconciles the authoritative list (track counts, orphaned-release cleanup) without a stale cache.
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var result = await CmsTrackService.GetReleasesAsync();
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
_releases = result.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_releases = Array.Empty<ReleaseDto>();
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load releases: {error}", Severity.Error);
|
||||
}
|
||||
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@inherits CmsMediumBrowserBase<CmsCutBrowser.CutRow>
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
|
||||
@* CUTS tab content (Phase 9 §8.A/§8.C): the rich CmsAlbumBrowser grid filtered to Cut releases, so the
|
||||
tab carries expand-tracks, delete, the Type chip, and per-row edit identically to the ALL tab — no
|
||||
forked grid. Cuts have no medium-specific action, so no SpecialColumns are supplied; the grid renders
|
||||
its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
|
||||
<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated" />
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Cut;
|
||||
protected override string MediumNoun => "cuts";
|
||||
|
||||
protected override CutRow ToRow(ReleaseDto release) => new() { Release = release };
|
||||
protected override ReleaseDto ReleaseOf(CutRow row) => row.Release;
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
public sealed class CutRow
|
||||
{
|
||||
public required ReleaseDto Release { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using DeepDrftManager.Services;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
|
||||
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fetch + state logic for the per-medium browsers (Cuts, Sessions, Mixes). Each subclass feeds
|
||||
/// the rich <c>CmsAlbumBrowser</c> grid a medium-filtered release list, so the per-medium tabs gain the
|
||||
/// same expand-tracks / delete / Type-chip / edit behaviour as the ALL tab without re-implementing any of
|
||||
/// it (§8.C parity — reuse, don't fork). This base owns the loading flag, the medium-filtered load, the
|
||||
/// per-release row projection, and a cover-thumbnail helper; subclasses supply the <see cref="Medium"/>,
|
||||
/// an error noun, and their bespoke per-row action (Session hero upload, Mix waveform generate) via the
|
||||
/// rich grid's <c>SpecialColumns</c> column model, looking their action-state row up with <see cref="RowFor"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRow">The subclass's row model wrapping a <see cref="ReleaseDto"/> plus its
|
||||
/// medium-specific action state (upload/generate flags). The rich grid renders from the bare
|
||||
/// <see cref="Releases"/> projection; <typeparamref name="TRow"/> only carries the action state.</typeparam>
|
||||
public abstract class CmsMediumBrowserBase<TRow> : ComponentBase where TRow : class
|
||||
{
|
||||
[Inject] public required ICmsReleaseService CmsReleaseService { get; set; }
|
||||
[Inject] public required ISnackbar Snackbar { get; set; }
|
||||
|
||||
/// <summary>The medium this browser lists. Subclass-supplied constant.</summary>
|
||||
protected abstract ReleaseMedium Medium { get; }
|
||||
|
||||
/// <summary>Plural noun for this medium used in error text (e.g. "sessions", "mixes").</summary>
|
||||
protected abstract string MediumNoun { get; }
|
||||
|
||||
/// <summary>Projects a fetched release into the subclass's row model.</summary>
|
||||
protected abstract TRow ToRow(ReleaseDto release);
|
||||
|
||||
/// <summary>The release carried by a subclass row, for keying the action-state lookup.</summary>
|
||||
protected abstract ReleaseDto ReleaseOf(TRow row);
|
||||
|
||||
protected List<TRow> Rows { get; private set; } = new();
|
||||
protected bool Loading { get; private set; } = true;
|
||||
|
||||
// Bare release projection handed to the rich grid. The grid does the expand/delete/edit/Type-chip;
|
||||
// it never sees TRow. Rebuilt on every (re)load so the grid re-projects against a fresh reference.
|
||||
protected IReadOnlyList<ReleaseDto> Releases { get; private set; } = Array.Empty<ReleaseDto>();
|
||||
|
||||
// release.Id → action-state row, so a SpecialColumns cell delegate (which the grid hands a ReleaseDto)
|
||||
// can recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind.
|
||||
private Dictionary<long, TRow> _rowsById = new();
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
/// <summary>Recovers the action-state row for a release the rich grid is rendering. Null if the
|
||||
/// release is not in the current page (e.g. just deleted), in which case the action is skipped.</summary>
|
||||
protected TRow? RowFor(ReleaseDto release) =>
|
||||
_rowsById.TryGetValue(release.Id, out var row) ? row : null;
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the medium-filtered release list. Wired to the rich grid's <c>OnReleasesChanged</c> so a
|
||||
/// delete re-fetches the authoritative list (track counts, orphan cleanup) — the same single-load
|
||||
/// posture <c>CmsAllReleasesGrid</c> uses for the ALL tab.
|
||||
/// </summary>
|
||||
protected async Task ReloadAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
Loading = true;
|
||||
// Single-track releases; a single generous page covers the CMS catalogue (same small-catalogue
|
||||
// assumption the album browser makes).
|
||||
var result = await CmsReleaseService.GetPagedAsync(
|
||||
Medium, page: 1, pageSize: 100,
|
||||
sortColumn: "Title", sortDescending: false);
|
||||
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
Rows = result.Value.Items.Select(ToRow).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load {MediumNoun}: {error}", Severity.Error);
|
||||
Rows = new List<TRow>();
|
||||
}
|
||||
|
||||
Releases = Rows.Select(ReleaseOf).ToList();
|
||||
_rowsById = Rows.ToDictionary(r => ReleaseOf(r).Id);
|
||||
Loading = false;
|
||||
}
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
protected static string ThumbUrl(string entryKey) =>
|
||||
$"/api/image/{Uri.EscapeDataString(entryKey)}";
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
@page "/tracks/mixes"
|
||||
@inherits CmsMediumBrowserBase<CmsMixBrowser.MixRow>
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
@inject ILogger<CmsMixBrowser> Logger
|
||||
|
||||
@* Embedded as the MIXES tab content of the Release Archive (Phase 9 §8.A), and still routable at
|
||||
/tracks/mixes for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Mixes (§8.C
|
||||
parity: expand-tracks, delete, Type chip, per-row edit), with the Mix waveform generate supplied as
|
||||
its medium-specific special-action column so that affordance survives the move off the thin table.
|
||||
When embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive"
|
||||
button) is suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved
|
||||
in both. *@
|
||||
@if (Embedded)
|
||||
{
|
||||
@GridContent
|
||||
}
|
||||
else
|
||||
{
|
||||
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/releases"
|
||||
Class="mb-4">
|
||||
Back to Releases
|
||||
</MudButton>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
|
||||
|
||||
@GridContent
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// True when rendered as tab content inside the Release Archive; suppresses the standalone page
|
||||
/// chrome (title, container, back button). False (default) renders the full routable page.
|
||||
/// </summary>
|
||||
[Parameter] public bool Embedded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
|
||||
protected override string MediumNoun => "mixes";
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override MixRow ToRow(ReleaseDto release) => new()
|
||||
{
|
||||
Release = release,
|
||||
HasWaveform = !string.IsNullOrEmpty(release.MixMetadata?.WaveformEntryKey)
|
||||
};
|
||||
|
||||
protected override ReleaseDto ReleaseOf(MixRow row) => row.Release;
|
||||
|
||||
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
|
||||
// both branches above render the same markup without duplication. The Mix declares one dedicated
|
||||
// "Waveform" special-action column; the grid renders it between Tracks and Actions, handing the cell
|
||||
// each release, and RowFor recovers the matching MixRow's generate state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated"
|
||||
SpecialColumns="_specialColumns" />;
|
||||
|
||||
// Allocated once per component instance in OnInitialized (field initializers cannot reference
|
||||
// instance members, so initialization is deferred to the first lifecycle hook).
|
||||
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_specialColumns = new[] { new SpecialActionColumn("Waveform", WaveformCell) };
|
||||
base.OnInitialized();
|
||||
}
|
||||
|
||||
// Per-row cell for the dedicated "Waveform" column: status icon plus generate/regenerate button with
|
||||
// progress. Recovers the typed MixRow via RowFor; skips rendering for a release not on the page.
|
||||
private RenderFragment<ReleaseDto> WaveformCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
@if (row.HasWaveform)
|
||||
{
|
||||
<MudTooltip Text="Waveform generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="No waveform — incomplete">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||
Disabled="@row.IsGenerating"
|
||||
OnClick="@(() => GenerateWaveformAsync(row))">
|
||||
@if (row.IsGenerating)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
</text>;
|
||||
|
||||
private async Task GenerateWaveformAsync(MixRow row)
|
||||
{
|
||||
row.IsGenerating = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsReleaseService.GenerateMixWaveformAsync(row.Release.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
// Optimistic update: the trigger succeeded, so the waveform is stored. Unlike SessionBrowser's
|
||||
// re-fetch (which retrieves the server-generated HeroImageEntryKey), there is nothing to reflect
|
||||
// back here — HasWaveform is derived from WaveformEntryKey being non-null, which we know is now set.
|
||||
row.HasWaveform = true;
|
||||
Snackbar.Add($"Generated waveform for '{row.Release.Title}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Waveform generation failed for '{row.Release.Title}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for release {ReleaseId}", row.Release.Id);
|
||||
Snackbar.Add($"Waveform generation failed for '{row.Release.Title}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
row.IsGenerating = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MixRow
|
||||
{
|
||||
public required ReleaseDto Release { get; set; }
|
||||
public bool HasWaveform { get; set; }
|
||||
public bool IsGenerating { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
@page "/tracks/sessions"
|
||||
@inherits CmsMediumBrowserBase<CmsSessionBrowser.SessionRow>
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
@inject ILogger<CmsSessionBrowser> Logger
|
||||
|
||||
@* Embedded as the SESSIONS tab content of the Release Archive (Phase 9 §8.A), and still routable at
|
||||
/tracks/sessions for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Sessions
|
||||
(§8.C parity: expand-tracks, delete, Type chip, per-row edit), with the Session hero upload supplied
|
||||
as its medium-specific special-action column so that affordance survives the move off the thin table.
|
||||
When embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive"
|
||||
button) is suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in
|
||||
both contexts. *@
|
||||
@if (Embedded)
|
||||
{
|
||||
@GridContent
|
||||
}
|
||||
else
|
||||
{
|
||||
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/releases"
|
||||
Class="mb-4">
|
||||
Back to Releases
|
||||
</MudButton>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
|
||||
|
||||
@GridContent
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// True when rendered as tab content inside the Release Archive; suppresses the standalone page
|
||||
/// chrome (title, container, back button). False (default) renders the full routable page.
|
||||
/// </summary>
|
||||
[Parameter] public bool Embedded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Session;
|
||||
protected override string MediumNoun => "sessions";
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override SessionRow ToRow(ReleaseDto release) => new()
|
||||
{
|
||||
Release = release,
|
||||
HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey
|
||||
};
|
||||
|
||||
protected override ReleaseDto ReleaseOf(SessionRow row) => row.Release;
|
||||
|
||||
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
|
||||
// both branches above render the same markup without duplication. The Session declares one dedicated
|
||||
// "Hero" special-action column; the grid renders it between Tracks and Actions, handing the cell each
|
||||
// release, and RowFor recovers the matching SessionRow's upload state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated"
|
||||
SpecialColumns="_specialColumns" />;
|
||||
|
||||
// Allocated once per component instance in OnInitialized (field initializers cannot reference
|
||||
// instance members, so initialization is deferred to the first lifecycle hook).
|
||||
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_specialColumns = new[]
|
||||
{
|
||||
new SpecialActionColumn("Hero", HeroThumbCell),
|
||||
new SpecialActionColumn("", HeroButtonCell),
|
||||
};
|
||||
base.OnInitialized();
|
||||
}
|
||||
|
||||
// Per-row cell for the "Hero" thumbnail column: just the image preview div.
|
||||
private RenderFragment<ReleaseDto> HeroThumbCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
|
||||
{
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
}
|
||||
</text>;
|
||||
|
||||
// Per-row cell for the "Hero Image" upload button column: set/replace upload button with progress.
|
||||
private RenderFragment<ReleaseDto> HeroButtonCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept="image/*"
|
||||
FilesChanged="@(file => UploadHeroAsync(row, file))"
|
||||
Disabled="@row.IsUploading">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Image"
|
||||
Disabled="@row.IsUploading">
|
||||
@if (row.IsUploading)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Uploading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
</MudFileUpload>
|
||||
}
|
||||
</text>;
|
||||
|
||||
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
|
||||
{
|
||||
if (file is null) return;
|
||||
row.IsUploading = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var result = await CmsReleaseService.UploadSessionHeroImageAsync(
|
||||
row.Release.Id, stream, file.Name, file.ContentType);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// The endpoint returns no payload; the entry key is server-generated. Re-fetch the
|
||||
// release so the hero thumbnail reflects the new key without guessing it.
|
||||
var refreshed = await CmsReleaseService.GetByIdAsync(row.Release.Id);
|
||||
if (refreshed.Success && refreshed.Value is { } release)
|
||||
{
|
||||
row.Release = release;
|
||||
row.HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey;
|
||||
}
|
||||
Snackbar.Add($"Hero image set for '{row.Release.Title}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Hero image upload failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Hero image upload failed for release {ReleaseId}", row.Release.Id);
|
||||
Snackbar.Add("Hero image upload failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
row.IsUploading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SessionRow
|
||||
{
|
||||
public required ReleaseDto Release { get; set; }
|
||||
public string? HeroImageEntryKey { get; set; }
|
||||
public bool IsUploading { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Hero-thumbnail idiom for the session row's action cell. The hero <div> is authored in this
|
||||
component's RowActions fragment, so Blazor stamps it with this component's scope attribute even
|
||||
though CmsAlbumBrowser renders it — this scoped copy reaches it (CSS isolation follows authoring
|
||||
component, not rendering host). The grid's own cover thumb lives in CmsAlbumBrowser's scoped CSS. */
|
||||
.cms-album-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-album-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@using DeepDrftModels.Enums
|
||||
|
||||
@* Cut-medium fields: the commercial release format. Plain explicit markup — no generics. *@
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="ReleaseType"
|
||||
Value="ReleaseType"
|
||||
ValueChanged="@(v => ReleaseTypeChanged.InvokeAsync(v))"
|
||||
Label="Release Type"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="Disabled">
|
||||
@foreach (var rt in Enum.GetValues<ReleaseType>())
|
||||
{
|
||||
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
@code {
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
@* The single dispatch point for medium-conditional form fields. All five upload/edit forms embed this
|
||||
one component; the @switch below is the ONLY place medium-specific form shape is decided. Adding a
|
||||
medium is one new section component + one new switch arm here — nowhere else. *@
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="ReleaseMedium"
|
||||
Value="Medium"
|
||||
ValueChanged="@(v => MediumChanged.InvokeAsync(v))"
|
||||
Label="Medium"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="Disabled">
|
||||
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
|
||||
{
|
||||
<MudSelectItem T="ReleaseMedium" Value="medium">@medium</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
@switch (Medium)
|
||||
{
|
||||
case ReleaseMedium.Cut:
|
||||
<CutFields ReleaseType="ReleaseType"
|
||||
ReleaseTypeChanged="ReleaseTypeChanged"
|
||||
Disabled="Disabled" />
|
||||
break;
|
||||
case ReleaseMedium.Session:
|
||||
<SessionFields HeroImageFile="HeroImageFile"
|
||||
HeroImageFileChanged="HeroImageFileChanged"
|
||||
AllowHeroUpload="AllowHeroUpload"
|
||||
Disabled="Disabled" />
|
||||
break;
|
||||
case ReleaseMedium.Mix:
|
||||
<MixFields />
|
||||
break;
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
|
||||
|
||||
// Cut-only — bound through to CutFields. Ignored for Session/Mix.
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||
|
||||
// Session-only — the held hero-image file, uploaded after create. Ignored for Cut/Mix.
|
||||
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
|
||||
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
|
||||
|
||||
// Gates the hero file picker in SessionFields. True on the BatchUpload create path;
|
||||
// false/absent on all edit paths so SessionFields falls back to the guidance alert.
|
||||
[Parameter] public bool AllowHeroUpload { get; set; }
|
||||
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@* Mix-medium fields. The high-res waveform is a server-side derived datum: the CMS fires a body-less
|
||||
trigger (POST api/release/{id}/mix/waveform) after the release exists, so generation is managed
|
||||
per-row in the Mixes browser, not at create time. On upload the trigger is fired automatically; this
|
||||
section states that contract and carries no input of its own. *@
|
||||
<MudItem xs="12">
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
|
||||
Mixes are single-track DJ releases. The high-resolution waveform is generated automatically
|
||||
after upload; regenerate it any time from the <strong>Release Archive → Mixes</strong> browser.
|
||||
</MudAlert>
|
||||
</MudItem>
|
||||
@@ -0,0 +1,294 @@
|
||||
@page "/releases"
|
||||
@page "/tracks"
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/archive"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<Releases> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Releases — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
<MudText Typo="Typo.h3">Releases</MudText>
|
||||
|
||||
@* Catalogue-wide waveform backfill (migrated from the retired /tracks view). Both buttons act over
|
||||
every track's waveform status — independent of any single grid — so the page owns the status map
|
||||
directly: it computes the missing counts and re-fetches after a run. No grid reference involved. *@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || MissingProfileCount == 0)"
|
||||
OnClick="GenerateAllMissingAsync">
|
||||
@if (_bulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating @_bulkDone / @_bulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Generate All Profiles (@MissingProfileCount)</span>
|
||||
}
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Waves"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || MissingHighResCount == 0)"
|
||||
OnClick="GenerateAllMissingHighResAsync">
|
||||
@if (_highResBulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Backfill High-res (@MissingHighResCount)</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@* Medium tab strip: an ALL tab plus one explicit MudTabPanel per ReleaseMedium, ALL left-most. Each
|
||||
panel is hand-declared in markup (not enum-driven) so @ref captures of the per-tab grid components
|
||||
are possible. Adding a future medium requires a hand-added MudTabPanel; its position in markup must
|
||||
match ReleaseMedium enum order, since the ?medium= deep-link seed and ActiveMedium getter are
|
||||
position-based (panel 0 = ALL, panels 1.. = enum values in order). *@
|
||||
@* Medium-aware Add Track: the button reflects the active tab and pre-selects the upload form to that
|
||||
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a seed
|
||||
only — the upload form's selector stays user-changeable after landing. *@
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="@AddTrackHref(ActiveMedium)">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
|
||||
@bind-ActivePanelIndex="_activeTabIndex">
|
||||
<MudTabPanel Text="ALL">
|
||||
<CmsAllReleasesGrid @ref="_allGrid"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Cut]">
|
||||
<CmsCutBrowser @ref="_cutBrowser"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Session]">
|
||||
<CmsSessionBrowser @ref="_sessionBrowser"
|
||||
Embedded="true"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Mix]">
|
||||
<CmsMixBrowser @ref="_mixBrowser"
|
||||
Embedded="true"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// Active tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in order. Seeded
|
||||
// from the ?medium= query param so the catalogue cards can deep-link straight to a medium's tab.
|
||||
private int _activeTabIndex;
|
||||
|
||||
// Optional deep-link target from the catalogue cards (?medium=session selects the Sessions tab) and the
|
||||
// seed for the Add Track button on the ALL tab. Read once on init; the user can switch tabs freely after.
|
||||
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
|
||||
|
||||
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut; each
|
||||
// medium tab maps to its enum value by position, so a fourth medium tab gets a correct Add Track for
|
||||
// free — no markup fork.
|
||||
private ReleaseMedium ActiveMedium =>
|
||||
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
|
||||
|
||||
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
|
||||
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
|
||||
private static string AddTrackHref(ReleaseMedium medium) =>
|
||||
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
|
||||
|
||||
// Medium → tab label. The one place medium display text lives for the tab strip. The ALL tab is
|
||||
// rendered separately (it is not a medium). Tabs are explicit markup so @ref captures work.
|
||||
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
|
||||
new Dictionary<ReleaseMedium, string>
|
||||
{
|
||||
[ReleaseMedium.Cut] = "CUTS",
|
||||
[ReleaseMedium.Session] = "SESSIONS",
|
||||
[ReleaseMedium.Mix] = "MIXES",
|
||||
};
|
||||
|
||||
// @ref handles for the per-tab grids. Used to (a) invalidate their cached per-track waveform status
|
||||
// after a page-level bulk run, and (b) to wire OnWaveformGenerated so per-row generates bubble up
|
||||
// and refresh the page-level missing-count badges. Tabs are now explicit markup rather than the
|
||||
// former enum-driven MediumGrid() switch so @ref captures are possible.
|
||||
private CmsAllReleasesGrid? _allGrid;
|
||||
private CmsCutBrowser? _cutBrowser;
|
||||
private CmsSessionBrowser? _sessionBrowser;
|
||||
private CmsMixBrowser? _mixBrowser;
|
||||
|
||||
// EntryKey → HasProfile / HasHighRes, loaded once on init so the bulk buttons can show accurate missing
|
||||
// counts without depending on any rendered grid. Re-fetched after each bulk run so the counts settle.
|
||||
private IReadOnlyList<WaveformStatusDto> _waveformStatus = Array.Empty<WaveformStatusDto>();
|
||||
|
||||
private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile);
|
||||
private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes);
|
||||
|
||||
// Local state for the parent-owned "Generate All Profiles" bulk run.
|
||||
private bool _bulkRunning;
|
||||
private int _bulkTotal;
|
||||
private int _bulkDone;
|
||||
|
||||
// Local state for the "Backfill High-res" bulk run. Independent of the profile bulk above.
|
||||
private bool _highResBulkRunning;
|
||||
private int _highResBulkTotal;
|
||||
private int _highResBulkDone;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Seed the active tab from ?medium= so a catalogue card deep-links straight to its medium. Panel 0
|
||||
// is ALL; a recognised medium maps to its 1-based position. Unrecognised/absent falls through to ALL.
|
||||
if (!string.IsNullOrWhiteSpace(MediumParam)
|
||||
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
|
||||
&& Enum.IsDefined(medium))
|
||||
{
|
||||
_activeTabIndex = Array.IndexOf(Enum.GetValues<ReleaseMedium>(), medium) + 1;
|
||||
}
|
||||
|
||||
await RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshWaveformStatusAsync()
|
||||
{
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
_waveformStatus = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<WaveformStatusDto>();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Invalidates the cached per-track waveform status on all embedded grids so the next row expand
|
||||
// re-fetches fresh data. Called after each catalogue-wide bulk run so already-expanded rows
|
||||
// reflect the new waveform state on the next expand interaction.
|
||||
private async Task InvalidateAllGridsAsync()
|
||||
{
|
||||
var tasks = new[]
|
||||
{
|
||||
_allGrid?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_cutBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_sessionBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_mixBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
};
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill every track missing a waveform profile, one request at a time so a large backfill does not
|
||||
/// flood the API with concurrent WAV decodes. On completion, re-reads the status map so the missing
|
||||
/// count settles.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingAsync()
|
||||
{
|
||||
var missing = _waveformStatus.Where(s => !s.HasProfile).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_bulkRunning = true;
|
||||
_bulkTotal = missing.Count;
|
||||
_bulkDone = 0;
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_bulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_bulkRunning = false;
|
||||
await RefreshWaveformStatusAsync();
|
||||
await InvalidateAllGridsAsync();
|
||||
|
||||
var succeeded = missing.Count - failures;
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill the per-track high-res visualizer datum for every track missing one, one request at a time
|
||||
/// so a large backfill does not flood the API with concurrent WAV decodes. Re-runnable (a second run
|
||||
/// re-reads status and only retries what is still missing). On completion, re-reads the status map.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingHighResAsync()
|
||||
{
|
||||
var missing = _waveformStatus.Where(s => !s.HasHighRes).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_highResBulkRunning = true;
|
||||
_highResBulkTotal = missing.Count;
|
||||
_highResBulkDone = 0;
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateHighResWaveformAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_highResBulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_highResBulkRunning = false;
|
||||
await RefreshWaveformStatusAsync();
|
||||
await InvalidateAllGridsAsync();
|
||||
|
||||
var succeeded = missing.Count - failures;
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Backfilled {succeeded} high-res datum(s).", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
@* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
|
||||
file picker is shown — the file is held by the parent and POSTed after the release is created.
|
||||
When false (edit path: BatchEdit), the original guidance alert is rendered
|
||||
instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
|
||||
a dead/inert control on edit forms that do not wire the hero callbacks. *@
|
||||
@if (AllowHeroUpload)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudField Label="Hero Image" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">
|
||||
Sessions are single-track live releases. The hero image is the session's primary visual identity.
|
||||
</MudText>
|
||||
|
||||
@if (HeroImageFile is { } selectedHero)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedHero.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="Disabled"
|
||||
OnClick="ClearHeroFile"
|
||||
aria-label="Cancel hero image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No hero image — optional, but recommended.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleHeroFileSelected" accept="image/*" disabled="@Disabled" />
|
||||
@if (HeroImageFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
</MudItem>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
|
||||
Sessions are single-track live releases. After upload, set the hero image from the
|
||||
<strong>Release Archive → Sessions</strong> browser.
|
||||
</MudAlert>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
|
||||
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
|
||||
// When true (BatchUpload create path), render the hero file picker.
|
||||
// When false/absent (edit paths), render the guidance alert directing the admin to the
|
||||
// Sessions browser — no dead control where callbacks are unwired.
|
||||
[Parameter] public bool AllowHeroUpload { get; set; }
|
||||
|
||||
private Task HandleHeroFileSelected(InputFileChangeEventArgs e) =>
|
||||
HeroImageFileChanged.InvokeAsync(e.File);
|
||||
|
||||
private Task ClearHeroFile() =>
|
||||
HeroImageFileChanged.InvokeAsync(null);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
|
||||
/// <summary>
|
||||
/// A dedicated, header-labelled grid column for a medium-specific row affordance (e.g. Mix waveform
|
||||
/// generate, Session hero upload) in <see cref="CmsAlbumBrowser"/>. A per-medium host declares zero or
|
||||
/// more of these; the grid renders one extra header cell and one extra per-row cell for each, positioned
|
||||
/// between the Tracks column and the universal Actions (Edit/Delete) column. The <see cref="Cell"/>
|
||||
/// fragment is handed each release; the host recovers its typed row state via its own RowFor lookup.
|
||||
/// </summary>
|
||||
/// <param name="Header">Column header label (e.g. "Waveform", "Hero").</param>
|
||||
/// <param name="Cell">Per-row cell content for a given release.</param>
|
||||
public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell);
|
||||
@@ -1,213 +1,16 @@
|
||||
@page "/tracks/{Id:long}"
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [Authorize]
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@inject ILogger<TrackEdit> Logger
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Edit Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks"
|
||||
Class="mb-4">
|
||||
Back to tracks
|
||||
</MudButton>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" />
|
||||
}
|
||||
else if (_track is null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">
|
||||
Track not found.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Track</MudText>
|
||||
|
||||
<MudPaper Class="pa-6" Elevation="2">
|
||||
<MudStack Spacing="4">
|
||||
<MudField Label="Entry Key" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudText Typo="Typo.body1" Style="font-family: monospace;">@_track.EntryKey</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">
|
||||
Vault reference — not editable.
|
||||
</MudText>
|
||||
</MudField>
|
||||
|
||||
<MudTextField @bind-Value="_form.TrackName"
|
||||
Label="Track Name"
|
||||
Required="true"
|
||||
RequiredError="Track name is required"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Artist"
|
||||
Label="Artist"
|
||||
Required="true"
|
||||
RequiredError="Artist is required"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Album"
|
||||
Label="Album"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Genre"
|
||||
Label="Genre"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudDatePicker @bind-Date="_form.ReleaseDate"
|
||||
Label="Release Date"
|
||||
DateFormat="yyyy-MM-dd"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
Disabled="_busy"
|
||||
OnClick="ConfirmDelete">
|
||||
Delete
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
Disabled="_busy || !CanSave"
|
||||
OnClick="SaveAsync">
|
||||
Save Changes
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudContainer>
|
||||
@* §8.M: the legacy single-track edit form is retired. Its edit responsibility is absorbed by
|
||||
BatchEdit's track-addressed entry (/tracks/{id}/edit), which loads the parent release and
|
||||
pre-selects the addressed track's row. This route is kept only as a redirect so any bookmarked
|
||||
/tracks/{id} lands on the live edit surface; CmsTrackGrid's per-row Edit targets the new route
|
||||
directly. *@
|
||||
|
||||
@code {
|
||||
[Parameter] public long Id { get; set; }
|
||||
|
||||
private TrackDto? _track;
|
||||
private TrackEditForm _form = new();
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
|
||||
private bool CanSave =>
|
||||
!string.IsNullOrWhiteSpace(_form.TrackName)
|
||||
&& !string.IsNullOrWhiteSpace(_form.Artist);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
var result = await CmsTrackService.GetByIdAsync(Id);
|
||||
_track = result.Success ? result.Value : null;
|
||||
if (_track is not null)
|
||||
{
|
||||
_form = TrackEditForm.From(_track);
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_track is null || !CanSave) return;
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
// Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content
|
||||
// API loads the authoritative row and applies these fields.
|
||||
var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
|
||||
var updated = await CmsTrackService.UpdateAsync(
|
||||
Id, _form.TrackName, _form.Artist,
|
||||
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
|
||||
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
|
||||
releaseDate);
|
||||
if (updated.Success)
|
||||
{
|
||||
Snackbar.Add("Track updated.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = updated.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Save failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Save failed for track {TrackId}", Id);
|
||||
Snackbar.Add("Save failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfirmDelete()
|
||||
{
|
||||
if (_track is null) return;
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Delete track",
|
||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Artist}? This cannot be undone.",
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add("Track deleted.", Severity.Success);
|
||||
Nav.NavigateTo("/tracks");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackEditForm
|
||||
{
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Album { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public DateTime? ReleaseDate { get; set; }
|
||||
|
||||
public static TrackEditForm From(TrackDto track) => new()
|
||||
{
|
||||
TrackName = track.TrackName,
|
||||
Artist = track.Artist,
|
||||
Album = track.Album,
|
||||
Genre = track.Genre,
|
||||
ReleaseDate = track.ReleaseDate is { } d
|
||||
? d.ToDateTime(TimeOnly.MinValue)
|
||||
: null
|
||||
};
|
||||
}
|
||||
protected override void OnInitialized() =>
|
||||
Navigation.NavigateTo($"/tracks/{Id}/edit", replace: true);
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
@page "/tracks"
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [Authorize]
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackList> Logger
|
||||
|
||||
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
<MudText Typo="Typo.h3">Tracks</MudText>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/tracks/new">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTable T="TrackDto"
|
||||
@ref="_table"
|
||||
ServerData="LoadServerData"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true"
|
||||
RowsPerPage="20"
|
||||
AllowUnsorted="false">
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
||||
</LoadingContent>
|
||||
<HeaderContent>
|
||||
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
|
||||
<MudTh>Entry Key</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Artist</MudTd>
|
||||
<MudTd DataLabel="Album">@(context.Album ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/{context.Id}")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private MudTable<TrackDto>? _table;
|
||||
|
||||
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
|
||||
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
|
||||
var sortDescending = state.SortDirection == SortDirection.Descending;
|
||||
|
||||
var result = await CmsTrackService.GetPagedAsync(pageNumber, state.PageSize, sortColumn, sortDescending, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
|
||||
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
||||
}
|
||||
|
||||
var page = result.Value;
|
||||
return new TableData<TrackDto>
|
||||
{
|
||||
Items = page.Items,
|
||||
TotalItems = page.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDelete(TrackDto track)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete track",
|
||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Artist)}? This removes both the metadata row and the underlying audio entry."),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
||||
if (_table is not null) await _table.ReloadServerData();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,186 +1,12 @@
|
||||
@page "/tracks/new"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackNew> Logger
|
||||
|
||||
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Add Track</MudText>
|
||||
|
||||
<MudPaper Class="pa-6" Elevation="2">
|
||||
<MudStack Spacing="4">
|
||||
<MudText Typo="Typo.subtitle1">WAV file</MudText>
|
||||
<InputFile OnChange="OnFileSelected" accept=".wav,audio/wav,audio/x-wav" />
|
||||
@if (_selectedFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size))
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="_trackName" Label="Track Name" Required="true" RequiredError="Track Name is required" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.FlexEnd">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="Cancel"
|
||||
Disabled="_isUploading">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SubmitAsync"
|
||||
Disabled="_isUploading">
|
||||
@if (_isUploading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Uploading…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Upload</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
@* §8.M: the legacy single-track add form is retired. Its add responsibility is absorbed by
|
||||
BatchUpload's single-track branch. This route is kept only as a redirect so any bookmarked
|
||||
/tracks/new lands on the live upload surface; there are no in-app callers. *@
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
private IBrowserFile? _selectedFile;
|
||||
private string _trackName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _album = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private string? _errorMessage;
|
||||
private bool _isUploading;
|
||||
|
||||
private void OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedFile = e.File;
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
if (_selectedFile is null)
|
||||
{
|
||||
_errorMessage = "Please select a WAV file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_selectedFile.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_errorMessage = "Selected file must be a .wav file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_trackName))
|
||||
{
|
||||
_errorMessage = "Track Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// The page is gated by [HierarchicalRoleAuthorize(Admin)], so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in StreamContent so the whole file is never materialised in
|
||||
// memory before DeepDrftAPI receives it.
|
||||
await using var fileStream = _selectedFile.OpenReadStream(MaxUploadBytes);
|
||||
|
||||
var result = await CmsTrackService.UploadTrackAsync(
|
||||
fileStream,
|
||||
_selectedFile.Name,
|
||||
_selectedFile.ContentType,
|
||||
_trackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
createdByUserId);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
|
||||
Navigation.NavigateTo("/tracks");
|
||||
return;
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Upload failed: {error}";
|
||||
Logger.LogWarning("CMS upload rejected: {Error}", error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Upload failed in TrackNew");
|
||||
_errorMessage = "Upload failed. Please try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
Navigation.NavigateTo("/tracks");
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
const long KB = 1024;
|
||||
const long MB = KB * 1024;
|
||||
const long GB = MB * 1024;
|
||||
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
|
||||
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
|
||||
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
|
||||
return $"{bytes} bytes";
|
||||
}
|
||||
protected override void OnInitialized() =>
|
||||
Navigation.NavigateTo("/tracks/upload", replace: true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo("/catalogue", forceLoad: false, replace: true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Proxies image API calls to DeepDrftAPI so the browser never makes cross-origin requests.
|
||||
/// The CMS host runs server-side only, so rendered image URLs must resolve against the Manager's
|
||||
/// own origin, not the internal API address. This controller forwards unauthenticated
|
||||
/// <c>api/image/{entryKey}</c> requests upstream using the "DeepDrft.Content" named client
|
||||
/// (no API key — the image endpoint is public).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/image")]
|
||||
public class ImageProxyController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _upstream;
|
||||
private readonly ILogger<ImageProxyController> _logger;
|
||||
|
||||
public ImageProxyController(IHttpClientFactory httpClientFactory, ILogger<ImageProxyController> logger)
|
||||
{
|
||||
_upstream = httpClientFactory.CreateClient("DeepDrft.Content");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Proxies image binary streaming by vault entry key from DeepDrftAPI.</summary>
|
||||
[HttpGet("{entryKey}")]
|
||||
public async Task<ActionResult> GetImage(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Proxying image {EntryKey}", entryKey);
|
||||
|
||||
var path = $"api/image/{Uri.EscapeDataString(entryKey)}";
|
||||
|
||||
HttpResponseMessage upstream;
|
||||
try
|
||||
{
|
||||
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Upstream call to DeepDrftAPI image/{EntryKey} failed", entryKey);
|
||||
return StatusCode(502, "Upstream unavailable");
|
||||
}
|
||||
|
||||
if (!upstream.IsSuccessStatusCode)
|
||||
{
|
||||
upstream.Dispose();
|
||||
_logger.LogWarning("DeepDrftAPI image/{EntryKey} returned {Status}", entryKey, (int)upstream.StatusCode);
|
||||
return StatusCode((int)upstream.StatusCode);
|
||||
}
|
||||
|
||||
// Do NOT dispose upstream here — File() takes ownership of the response stream
|
||||
// and disposes it after the body is sent.
|
||||
var contentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
||||
var contentLength = upstream.Content.Headers.ContentLength;
|
||||
|
||||
if (contentLength.HasValue)
|
||||
Response.ContentLength = contentLength.Value;
|
||||
|
||||
var stream = await upstream.Content.ReadAsStreamAsync(ct);
|
||||
HttpContext.Response.RegisterForDispose(upstream);
|
||||
return File(stream, contentType, enableRangeProcessing: false);
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,3 @@
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ builder.Services.AddMudServices();
|
||||
// DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer.
|
||||
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
||||
|
||||
// CMS release operations (medium-filtered browse + Session/Mix media ops) over HTTP to the
|
||||
// DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService.
|
||||
builder.Services.AddScoped<ICmsReleaseService, CmsReleaseService>();
|
||||
|
||||
// AuthBlocksWeb: server-side cascading auth state plus the JWT client services used by the
|
||||
// /account/login + /account/logout Razor pages that ship in the AuthBlocksWeb RCL.
|
||||
// The auth API lives on DeepDrftAPI, so pass its URL — not Manager's own Kestrel URL.
|
||||
@@ -37,8 +41,10 @@ builder.Services.AddHttpClient("DeepDrft.Content", client =>
|
||||
client.BaseAddress = new Uri(contentApiUrl);
|
||||
});
|
||||
|
||||
// Named HttpClient for ApiKey-protected Content API calls (CmsTrackService's vault delete).
|
||||
// API key baked into the default request headers so callers need not add it manually.
|
||||
// Named HttpClient for ApiKey-protected Content API calls (CmsTrackService's non-upload operations:
|
||||
// delete, paged list, metadata read/write, waveform jobs, releases, genres).
|
||||
// Timeout left at the default 100s — these are short request/response pairs and an infinite timeout
|
||||
// would hang an InteractiveServer circuit forever on a dead connection.
|
||||
var contentApiKey = builder.Configuration["Api:ContentApiKey"]
|
||||
?? throw new InvalidOperationException("Api:ContentApiKey is required");
|
||||
builder.Services.AddHttpClient("DeepDrft.Content.Cms", client =>
|
||||
@@ -47,6 +53,17 @@ builder.Services.AddHttpClient("DeepDrft.Content.Cms", client =>
|
||||
client.DefaultRequestHeaders.Add("ApiKey", contentApiKey);
|
||||
});
|
||||
|
||||
// Dedicated upload client — inherits the API key but removes the whole-request timeout.
|
||||
// Large WAV uploads (several hundred MB) outrun the 100s default. The upload path enforces an
|
||||
// idle/heartbeat deadline instead (body-streaming phase via ProgressStreamContent) plus a separate
|
||||
// response-wait budget (CmsTrackService), so the client itself must not impose a total cap.
|
||||
builder.Services.AddHttpClient("DeepDrft.Content.Cms.Upload", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(contentApiUrl);
|
||||
client.DefaultRequestHeaders.Add("ApiKey", contentApiKey);
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
|
||||
// Reverse-proxy support (nginx in production).
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
@@ -58,6 +75,11 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
// MVC controllers — required for the ImageProxyController that forwards browser image requests
|
||||
// to DeepDrftAPI so rendered URLs resolve against the Manager's own origin, not the internal
|
||||
// API address.
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// InteractiveServer only — no WASM render mode on the CMS host.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
@@ -95,6 +117,7 @@ app.UseAntiforgery();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapControllers();
|
||||
|
||||
// The AuthBlocks API surface (/api/auth/*, /api/users/*, etc.) now lives on DeepDrftAPI; this host
|
||||
// only renders the AuthBlocksWeb Razor pages (/account/login, /account/logout), which call that API.
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client over DeepDrftAPI's <c>api/release</c> family for CMS release operations. Mirrors
|
||||
/// <see cref="CmsTrackService"/>: the Manager is InteractiveServer-only with no in-process data
|
||||
/// layer, so every read and write is a network call. The ApiKey is baked into the
|
||||
/// <c>DeepDrft.Content.Cms</c> named client's default headers; the unauthenticated reads still go
|
||||
/// through it (the extra header is harmless on public endpoints).
|
||||
/// </summary>
|
||||
public class CmsReleaseService : ICmsReleaseService
|
||||
{
|
||||
private const string ContentCmsClientName = "DeepDrft.Content.Cms";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<CmsReleaseService> _logger;
|
||||
|
||||
public CmsReleaseService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<CmsReleaseService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
||||
ReleaseMedium? medium,
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
var query = $"api/release?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
|
||||
if (medium is { } m)
|
||||
{
|
||||
query += $"&medium={Uri.EscapeDataString(m.ToString())}";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(sortColumn))
|
||||
{
|
||||
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync(query, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for release page (medium {Medium})", medium);
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API release page failed: {Status}", (int)response.StatusCode);
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Failed to load releases.");
|
||||
}
|
||||
|
||||
PagedResult<ReleaseDto>? paged;
|
||||
try
|
||||
{
|
||||
paged = await response.Content.ReadFromJsonAsync<PagedResult<ReleaseDto>>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize release page from Content API response");
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (paged is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null release page");
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreatePassResult(paged);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync($"api/release/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for release {ReleaseId}", id);
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API release lookup failed for {ReleaseId}: {Status}", id, (int)response.StatusCode);
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult("Failed to load release.");
|
||||
}
|
||||
|
||||
ReleaseDto? release;
|
||||
try
|
||||
{
|
||||
release = await response.Content.ReadFromJsonAsync<ReleaseDto>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize ReleaseDto from Content API response");
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(release);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> UploadSessionHeroImageAsync(
|
||||
long releaseId,
|
||||
Stream imageStream,
|
||||
string fileName,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var multipart = new MultipartFormDataContent();
|
||||
var imageContent = new StreamContent(imageStream);
|
||||
imageContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
|
||||
// Field name "image" matches the controller's [FromForm] IFormFile image parameter.
|
||||
multipart.Add(imageContent, "image", fileName);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"api/release/{releaseId}/session/hero-image")
|
||||
{
|
||||
Content = multipart
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for hero-image upload of release {ReleaseId}", releaseId);
|
||||
return Result.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return Result.CreateFailResult("Release not found.");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
if (statusCode >= 500)
|
||||
{
|
||||
_logger.LogError("Content API returned {Status} for hero-image upload of release {ReleaseId}: {Body}", statusCode, releaseId, body);
|
||||
return Result.CreateFailResult("Hero image upload failed on the content server.");
|
||||
}
|
||||
|
||||
// 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is.
|
||||
_logger.LogWarning("Content API rejected hero-image upload for release {ReleaseId}: {Status} {Body}", releaseId, statusCode, body);
|
||||
return Result.CreateFailResult(
|
||||
string.IsNullOrWhiteSpace(body) ? $"Hero image upload rejected ({statusCode})." : body);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> GenerateMixWaveformAsync(long releaseId, CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.PostAsync($"api/release/{releaseId}/mix/waveform", null, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for mix waveform generation of release {ReleaseId}", releaseId);
|
||||
return Result.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return Result.CreateFailResult("Mix audio not found.");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("Content API mix waveform generation failed for release {ReleaseId}: {Status} {Body}", releaseId, (int)response.StatusCode, body);
|
||||
return Result.CreateFailResult("Failed to generate mix waveform.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user