Compare commits
918 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca44979b08 | |||
| bf5b314aed | |||
| afa862a67b | |||
| d72263aea1 | |||
| 1e17ffc380 | |||
| a98cef1ba7 | |||
| 4351ae04be | |||
| c1f2cafd8b | |||
| 634eb611eb | |||
| d7a373cdb0 | |||
| 020a945843 | |||
| 1aef30f67d | |||
| 0e8b85bbcb | |||
| 374f09150f | |||
| 9bfa921703 | |||
| 4fe2d564d9 | |||
| 5d9f410cd8 | |||
| 76f7f389a3 | |||
| 61e185a2f7 | |||
| 9347f11ff0 | |||
| f0d1463619 | |||
| aa0b64329f | |||
| 4ab430d232 | |||
| beec36a382 | |||
| 79bbbd4956 | |||
| 48e58c266d | |||
| 67422e922d | |||
| 3aed5c129f | |||
| e98e616997 | |||
| 8fa37f995b | |||
| 0800167511 | |||
| 8a6acd5f5f | |||
| be9de8d77c | |||
| d686fe48ce | |||
| 7adc35dd5d | |||
| 8206c0bdaf | |||
| aeec582957 | |||
| 036ee1f78e | |||
| c1e6930c70 | |||
| cc9d20184d | |||
| e7762e35e8 | |||
| 11faf8888f | |||
| adbd376d42 | |||
| cb899a2913 | |||
| def297e7d9 | |||
| 369cb86437 | |||
| c7629c15a4 | |||
| 9c95a5f23e | |||
| b93881cd66 | |||
| af4cb186f3 | |||
| 121983b19d | |||
| 29e8747c69 | |||
| 518479e7ae | |||
| a2becf45d6 | |||
| 07f29a8216 | |||
| ed606d94c7 | |||
| ccf7d3dbe3 | |||
| bbcf8be677 | |||
| 8902ce4d63 | |||
| eb58ae4a72 | |||
| d80b777e9f | |||
| 5a75da1769 | |||
| 7f3fb74126 | |||
| d0118997b6 | |||
| 5b78efaad4 | |||
| 81d4b42b72 | |||
| 77c6c42c94 | |||
| ab75bbf6c1 | |||
| 59f48bb8cb | |||
| c63c7ca033 | |||
| e5366bc4ec | |||
| 2bde4908d7 | |||
| 1e063d95f4 | |||
| dce5530890 | |||
| 8afcd3784f | |||
| 261289c1b8 | |||
| 740d01a67f | |||
| 1fdbec2533 | |||
| 70842cb576 | |||
| f2a0d39521 | |||
| e807ddb91b | |||
| 19793ba1c3 | |||
| 1bda2b7bea | |||
| 8773803712 | |||
| 3cc11bcbb5 | |||
| 0ba4fc6597 | |||
| 7a0ccdd784 | |||
| e845dc3496 | |||
| ba064cc136 | |||
| ca057dc630 | |||
| 5f4807cc4a | |||
| b3dadbb572 | |||
| 9a4b79d377 | |||
| 33383cd675 | |||
| 56f7013314 | |||
| 6add30a4ff | |||
| 33d6f34d8a | |||
| 2653e62eeb | |||
| 45bd599bdd | |||
| f976af0f7c | |||
| f3b89ca9d7 | |||
| 8752fc0c98 | |||
| 274d0ace62 | |||
| e3a4364b8c | |||
| 564b704803 | |||
| 6af6677a12 | |||
| 1bdaeaa164 | |||
| a84a99c309 | |||
| 2c1571057a | |||
| 0b7d8e41e7 | |||
| 4833935925 | |||
| 7917d56af3 | |||
| 1fd63fe368 | |||
| 4e1f540945 | |||
| 1ed518b018 | |||
| 7c41aa678d | |||
| 475e5e671c | |||
| 9971474403 | |||
| 0d1da9e63c | |||
| d47c186045 | |||
| 670eaab34d | |||
| c58b1c9386 | |||
| 450204cdbf | |||
| 5c22c1626a | |||
| 8628fbf215 | |||
| a23a22a2a3 | |||
| 6e12d0161a | |||
| 9716092805 | |||
| a577df88dd | |||
| 011dbe8d81 | |||
| 2fc2d4eb6d | |||
| 14f3af41e4 | |||
| fa01b9c8e0 | |||
| 835fb71337 | |||
| 021801999c | |||
| 54cba7eea0 | |||
| fbaf545c90 | |||
| d3f89c494a | |||
| c3ec3acafa | |||
| 214f708e65 | |||
| 5058c72375 | |||
| f5edcba7b2 | |||
| 64e1f71e18 | |||
| 7807d4ebe1 | |||
| 4410132409 | |||
| 00ff9e2702 | |||
| bb086e5869 | |||
| 674d772986 | |||
| ee296db7f6 | |||
| 8a4da2f0b9 | |||
| c28a2b1cf5 | |||
| 1427c92092 | |||
| 2fbb1c9b95 | |||
| 4c56eededc | |||
| f9d99b2c98 | |||
| 59608a23c5 | |||
| 2ddc57edb1 | |||
| 0bb656a512 | |||
| bb5a1fcad4 | |||
| 896b37792e | |||
| 2619fc67c8 | |||
| 4c14c67c33 | |||
| 494668bf24 | |||
| c4e22c706c | |||
| c747f3200f | |||
| 1dd1646cce | |||
| 6bbec2fc8e | |||
| 0c22ce8f09 | |||
| 67645cfd05 | |||
| 2591710f09 | |||
| 30999b038c | |||
| b5106d090f | |||
| a2ed334d0d | |||
| 9300c794b4 | |||
| 95dd48018a | |||
| c21b85afdf | |||
| 234a57d6b7 | |||
| 4bec507aab | |||
| a30d15f79d | |||
| b90604d311 | |||
| 77d0562b08 | |||
| aeda7e67a8 | |||
| bd9c67fc65 | |||
| 62fe27224c | |||
| 0708bb7352 | |||
| e6d5b9b77a | |||
| 04847391ad | |||
| 3d71b6836e | |||
| 833b5a921e | |||
| 3bf95538bd | |||
| eb7e977f3c | |||
| 0b8593950b | |||
| 51ac1a76de | |||
| 949bccfb8e | |||
| cfaf63468d | |||
| d6dcd82a53 | |||
| 3485acf3a8 | |||
| c04c2a9e98 | |||
| f1276faabc | |||
| 6029e226d5 | |||
| 135cc48301 | |||
| 54766fd5fc | |||
| fcc95b9195 | |||
| 042641d841 | |||
| 0358df82ac | |||
| 0f7088fe86 | |||
| 5408d0779c | |||
| abe94953b9 | |||
| 03fdcda054 | |||
| 5298cab9b1 | |||
| e05d93a67b | |||
| fd4fdd2624 | |||
| 639f4741e6 | |||
| d7071fdbc2 | |||
| 37cf19c405 | |||
| 37bbfb947f | |||
| 261b11436e | |||
| 280dbbcbc9 | |||
| ce17a685e0 | |||
| 64379c8901 | |||
| 1f8802363c | |||
| 58cdb4d9dc | |||
| 97cce691db | |||
| d0be26bb3e | |||
| 466084b5a3 | |||
| 558ff4b4c6 | |||
| bd85507308 | |||
| fbd298b9c3 | |||
| 3da6591194 | |||
| da60296cf8 | |||
| 4320ea8029 | |||
| 678d3f66ad | |||
| be04e53a97 | |||
| 58b30d3c13 | |||
| be1a55fd37 | |||
| 9d0ce99a5d | |||
| 1d387c2a34 | |||
| fe3819f378 | |||
| cfcc2693f2 | |||
| 621c4f9cb3 | |||
| 67eeb38529 | |||
| 9aa66e8a62 | |||
| 3b9ca700c9 | |||
| 4317a2f9e7 | |||
| 297805b5a8 | |||
| 944f23a88c | |||
| 75e5d99aea | |||
| c084efa78e | |||
| f296bbdf00 | |||
| ebbaa3f84f | |||
| a715f4b28d | |||
| 90555dc4e0 | |||
| 0fbf81b23e | |||
| 4114aa0be4 | |||
| 884ccab826 | |||
| 3c1998de4f | |||
| 622ee940f4 | |||
| 18e171213c | |||
| e9c61bac1a | |||
| dbd90ee52a | |||
| 1b7861e168 | |||
| 098020db32 | |||
| 912256d99a | |||
| 1931574ad4 | |||
| 25aba1cbb7 | |||
| 81d0028f2b | |||
| 62007a6517 | |||
| 13b07beb0b | |||
| 7711c5067c | |||
| eaa71ebea3 | |||
| e8359d5473 | |||
| 7265754c27 | |||
| abc832467d | |||
| 47919a226e | |||
| 933b7145e5 | |||
| f21647423a | |||
| df7acd9e80 | |||
| 3a4db834ac | |||
| d12151278a | |||
| ca90302f21 | |||
| 16784b37f2 | |||
| e9e6b6054f | |||
| 8fa330fbd3 | |||
| 5f0422a263 | |||
| 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 | |||
| ceaa684c74 | |||
| cd226f3ce9 | |||
| f4e39c96fd | |||
| c49f28e619 | |||
| b58bcd8398 | |||
| a54b0a8f8e | |||
| 65426a6c67 | |||
| 6143d9afef | |||
| 690631ef9b | |||
| dfd6d33142 |
@@ -7,3 +7,6 @@
|
||||
*.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
|
||||
DeepDrftShared.Client/wwwroot/js/knob/knob.js text eol=lf
|
||||
|
||||
@@ -9,7 +9,6 @@ on:
|
||||
- 'DeepDrftContent/**'
|
||||
- 'DeepDrftModels/**'
|
||||
- '.gitea/workflows/deploy-api.yml'
|
||||
- 'deploy/systemd/deepdrftapi.service'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -45,14 +44,6 @@ jobs:
|
||||
--no-build \
|
||||
-o DeepDrftAPI/publish
|
||||
|
||||
# DeepDrftContextFactory reads environment/connections.json at design time.
|
||||
# Write a parseable dummy so the factory does not throw during bundle construction.
|
||||
# The bundle only needs the provider type, not a live database connection.
|
||||
- name: Write dummy connections file for EF bundle
|
||||
run: |
|
||||
mkdir -p DeepDrftAPI/environment
|
||||
echo '{"ConnectionStrings":{"DefaultConnection":"Host=localhost;Database=dummy;Username=dummy","Auth":"Host=localhost;Database=dummy;Username=dummy"}}' > DeepDrftAPI/environment/connections.json
|
||||
|
||||
# EF bundle: self-contained binary that applies DeepDrftContext migrations on the host
|
||||
# without the .NET SDK. AuthBlocks' Identity DB is NOT covered here — it self-migrates
|
||||
# via UseAuthBlocksStartupAsync() on first boot.
|
||||
@@ -71,7 +62,7 @@ jobs:
|
||||
run: tar -czf deepdrft-api.tar.gz -C DeepDrftAPI/publish .
|
||||
|
||||
- name: Upload artifacts (archive + bundle)
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: deepdrft-api
|
||||
path: |
|
||||
@@ -90,7 +81,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: deepdrft-api
|
||||
path: staging/
|
||||
@@ -118,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
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: tar -czf deepdrft-manager.tar.gz -C DeepDrftManager/publish .
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: deepdrft-manager
|
||||
path: deepdrft-manager.tar.gz
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
DEPLOY_HOST: ${{ github.ref == 'refs/heads/master' && 'prod.cerebellumsoftworks.com' || 'dch7.cerebellumsoftworks.com' }}
|
||||
steps:
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: deepdrft-manager
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,7 +42,7 @@ jobs:
|
||||
run: tar -czf deepdrft-public.tar.gz -C DeepDrftPublic/publish .
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: deepdrft-public
|
||||
path: deepdrft-public.tar.gz
|
||||
@@ -53,7 +57,7 @@ jobs:
|
||||
DEPLOY_HOST: ${{ github.ref == 'refs/heads/master' && 'prod.cerebellumsoftworks.com' || 'dch7.cerebellumsoftworks.com' }}
|
||||
steps:
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: deepdrft-public
|
||||
|
||||
|
||||
+10
-1
@@ -311,4 +311,13 @@ __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.
|
||||
# Re-include the whole RCL js/ tree so every compiled module (parallax, knob, theme, and
|
||||
# any added later) ships, rather than maintaining a per-module allowlist.
|
||||
!DeepDrftShared.Client/wwwroot/js/
|
||||
!DeepDrftShared.Client/wwwroot/js/**
|
||||
@@ -8,13 +8,13 @@ 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**: 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), crawl-directive endpoints (`GET /robots.txt` and `GET /sitemap.xml`, environment-gated via `IWebHostEnvironment.IsProduction()` directly — server-side only, no PersistentState bridge — served by `CrawlDirectiveController` with pure builders in `Seo/RobotsTxt.cs` and `Seo/SitemapXml.cs`), 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). 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`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. **SEO component** (`Controls/SeoHead.razor` + `Common/SeoModel`, `SeoJsonLd`, `SeoOptions`, `SeoUrls`, `SeoEnvironment`): `SeoHead` is a presentational `<HeadContent>` emitter (one line per page, no fetch); `SeoModel` named factories (`ForRelease`/`ForHome`/`ForAbout`/`ForBrowse`/`ForNotFound`) encode the medium→schema.org mapping in one place; `SeoJsonLd` builds typed JSON-LD (MusicGroup / MusicAlbum+LiveAlbum / MusicRecording / CollectionPage) with inline-safe escaping; `SeoOptions` holds site-wide config (`BaseUrl https://deepdrft.com`, title suffix, default OG image seam, IG `sameAs`) registered via the static `Startup` seam; `SeoEnvironment` is a scoped `[PersistentState]` bridge (mirrors `DarkModeSettings`) seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — robots defaults to `index,follow` only in Production, `noindex,nofollow` everywhere else (fail-safe is noindex); per-page `SeoModel.Robots` overrides the default. Tags are present in prerendered HTML (rides the existing `PersistentComponentState` bridge; no new fetch). Canonical/OG origins come from `SeoOptions.BaseUrl` (config), not `window.location` — no `window` at server prerender and the origin cannot be derived behind the nginx proxy. Consumed by the public site.
|
||||
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. **Always uncrawlable**: a static `wwwroot/robots.txt` (`Disallow: /`, no env gate) plus a blanket `<meta name="robots" content="noindex,nofollow">` in `Components/App.razor` — defense in depth so the CMS is never indexed regardless of how it is discovered. 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`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. 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, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. 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). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
|
||||
- **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, `POST api/track/duration/backfill` (ApiKey-gated one-time backfill of `DurationSeconds` for existing rows from vault audio). Stats endpoints: `GET api/stats/home` (unauthenticated; returns `HomeStatsDto` with cut track count, per-`ReleaseType` cut release counts, mix release count, and total mix runtime seconds). 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.
|
||||
|
||||
@@ -34,7 +34,7 @@ Server-side (SSR): Both clients point directly at DeepDrftAPI (server-to-server,
|
||||
|
||||
1. **SQL Database (PostgreSQL)**: Metadata and track info via Entity Framework
|
||||
- Connection string: Read from `environment/connections.json` via `CredentialTools.ResolvePathOrThrow("connections")` with key `ConnectionStrings:DefaultConnection`.
|
||||
- Entity: `TrackEntity` with `Id`, `EntryKey`, `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?`
|
||||
- Entity: `TrackEntity` with `Id`, `EntryKey`, `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `DurationSeconds?`
|
||||
- Context: `DeepDrftContext` in `DeepDrftData`
|
||||
|
||||
2. **FileDatabase**: Custom file-based binary storage system
|
||||
@@ -42,7 +42,7 @@ Server-side (SSR): Both clients point directly at DeepDrftAPI (server-to-server,
|
||||
- Root contains typed **MediaVaults** (Media, Image, Audio)
|
||||
- Each vault has a JSON `index` file listing entries + per-entry metadata
|
||||
- Entries are user-supplied strings sanitized to `[a-zA-Z0-9-]` + file extension
|
||||
- Binary hierarchy: `FileBinary` → `MediaBinary` (+ Extension/MIME) → `AudioBinary` (+ Duration/Bitrate) | `ImageBinary` (+ AspectRatio)
|
||||
- Binary hierarchy (model shapes): `FileBinary` → `MediaBinary` (+ Extension/MIME) → `AudioBinary` (+ Duration/Bitrate) | `ImageBinary` (+ AspectRatio). **Non-delivery read path** (`LoadResourceAsync<AudioBinary>`) returns a full-buffer `AudioBinary` — still used for non-delivery operations (e.g., duration backfill). **Audio delivery path** streams via `GetEntryStreamAsync` (Opus artifact, `track-opus` vault) or `OpenAudioMediaStreamAsync` (lossless source, `tracks` vault) — a seekable, disk-backed `Stream` per request, never a whole-file `byte[]` (read-side OOM fix, parallel to the store-side). The **write/store path** is streaming: audio processors return a `ProcessedAudio` plan (metadata + streamed `WriteToAsync` callback); `RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` write bytes to a temp file then `File.Move` atomic-rename into place — the full `AudioBinary` buffer is never materialized on this path.
|
||||
- **Error-handling philosophy**: public operations swallow exceptions and return `null`/`false` — callers must check return values, not catch.
|
||||
|
||||
## Key Architectural Decisions
|
||||
@@ -57,34 +57,58 @@ The split between host projects (`DeepDrftPublic`, `DeepDrftManager`, `DeepDrftC
|
||||
|
||||
`TrackEntity` holds *only* metadata. The link to binary content is `EntryKey` (string) — the entry id inside the `tracks` vault in FileDatabase. Dual-database add flow:
|
||||
|
||||
1. `DeepDrftContent.TrackService.AddTrackFromWavAsync` processes WAV, generates entry GUID, stores audio in vault, returns unpersisted `TrackEntity`.
|
||||
1. `TrackContentService.AddTrackAsync` routes the audio file by extension (`AudioProcessorRouter`), produces a `ProcessedAudio` plan (bounded-header metadata + streamed `WriteToAsync` callback — no whole-file `AudioBinary` buffer), and stores it in the vault via `FileDatabase.RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` (atomic temp→rename on the Linux host). Returns an unpersisted `TrackEntity` with `DurationSeconds` populated from the header parse. Wave 1 OOM fix.
|
||||
2. `DeepDrftAPI.Services.UnifiedTrackService.UploadAsync` persists the entity to SQL via `DeepDrftData.TrackManager` and returns the persisted entity with `Id`.
|
||||
|
||||
If step 1 succeeds and step 2 fails, audio is orphaned in the vault (no rollback today).
|
||||
|
||||
The Opus transcode derived-artifact path (`OpusTranscodeService.TranscodeAndStoreAsync`) also streams its entire pipeline: extension and duration are read from the vault index (no body load); the source bytes are opened via `TrackContentService.OpenAudioMediaStreamAsync` and bounded-copied to a staging file; the encoded Ogg output is walked from a `FileStream` via `OggOpusParser.WalkAsync(Stream)` (bounded one-page-at-a-time buffer; byte-identical to the retained whole-buffer oracle `Walk(ReadOnlySpan<byte>)`); and stored via `RegisterResourceStreamingAsync`. The sidecar (a few KB, inherently bounded) retains the whole-buffer write. Completes the store-path OOM-fix arc.
|
||||
|
||||
### Streaming-first audio playback
|
||||
|
||||
The player is not fetch-then-play:
|
||||
|
||||
1. Client calls `GET api/track/{id}` on DeepDrftContent and receives WAV bytes as a stream (`HttpCompletionOption.ResponseHeadersRead`).
|
||||
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.
|
||||
1. Client calls `GET api/track/{id}` on DeepDrftContent and receives the first bounded segment (`Range: bytes=0-{SegmentSizeBytes-1}`, 4 MB) via `HttpCompletionOption.ResponseHeadersRead`.
|
||||
2. `StreamingAudioPlayerService` reads in adaptive 16–64 KB chunks within each segment, pushes each via `AudioInteropService.processStreamingChunk`.
|
||||
3. TypeScript `StreamDecoder` parses the format 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, `StreamingAudioPlayerService` issues a new bounded segment starting at the seek byte offset. Server responds 206; player retains the parsed header and feeds the raw continuation into the existing decode pipeline.
|
||||
|
||||
**Memory bounding (three complementary layers, all required):**
|
||||
- **Raw-queue bound (`StreamDecoder`):** `releaseConsumedChunks()` front-compacts `rawChunks` after each aligned segment is decoded, using a `discardedBytes` absolute cursor so all offsets remain absolute even as the array's front moves. Without this, a long WAV (e.g. a 92-min mix ≈ 970 MB raw) accumulates its entire decoded-from body in `rawChunks` regardless of the decode-side bounds below.
|
||||
- **Decoded-queue bound (`PlaybackScheduler`):** `evictPlayedBuffers()` discards already-played `AudioBuffer`s, capping the scheduler's forward fill to a 96 MB ceiling (Phase 21.2). Decoded PCM is larger than source (Web Audio uses 32-bit float — a 16-bit stereo WAV roughly doubles; Opus decodes to the same float footprint).
|
||||
- **Network bound (segmented fetch, Phase 21 Direction B):** The forward stream is fetched as sequential bounded `bytes=cursor-{cursor+4MB-1}` Range requests via `RunSegmentedStreamAsync`; the next segment is fetched only after `DrainBackpressureAsync` confirms the scheduler is below low-water. Because each segment is fully consumed before the next is issued, the browser holds at most ~one segment of raw bytes. A per-chunk drain inside the segment loop (gated on `_streamingPlaybackStarted` so it cannot deadlock first-audio) additionally prevents high-density codecs (e.g. Opus, where a 4 MB segment is ~100 s of audio) from decoding the whole segment eagerly ahead of the playhead before the inter-segment gate runs.
|
||||
|
||||
**Playback stability invariants (streaming-stabilization arc):**
|
||||
- **Back-pressure water marks:** forward fill 60s high / 30s low (`PlaybackScheduler.DEFAULT_FORWARD_HIGH_WATER_SECONDS` / `...LOW_WATER_SECONDS`). Production pauses on `lookahead ≥ high OR decoded bytes > 96 MB ceiling`, whichever first. The time window is a jitter cushion for Opus's async decode ramp; the byte cap is the hard OOM guarantee and is unchanged.
|
||||
- **Genuine end-of-playback:** `PlaybackScheduler` uses a `streamComplete` flag (set by `setStreamComplete`) combined with an `underrun_` park/resume state to distinguish a drained-but-still-streaming queue (startup/underrun gap → park, resume on refill) from a truly finished track (stream complete AND queue drained → `finishPlayback()`, the single genuine-end path). Prevents the false `onPlaybackEnded` that previously fired when the Opus WebCodecs queue momentarily drained during the decode ramp.
|
||||
- **Rebuffer hysteresis:** Opus playback start and underrun-resume are gated on `hasMinimumPlaybackLead()` (1s decoded lead, `DEFAULT_MIN_PLAYBACK_LEAD_SECONDS`); WAV keeps `hasMinimumBuffers(6)`. `streamComplete` overrides the gate so a short tail still plays out. `StreamingAudioPlayerService.TryStartPlaybackAsync()` force-starts after `MarkStreamCompleteAsync` when the threshold was never crossed (ultra-short track protection).
|
||||
- **Opus `AudioContext` pre-aligned to 48 kHz** in `AudioPlayer.initializeStreaming` before any bytes flow, eliminating the mid-decode context teardown that previously OOM'd the tab under software rendering.
|
||||
|
||||
**Visualizer / decode contention (decodePressure + hwAccel):**
|
||||
- `DeepDrftPublic/Interop/audio/decodePressure.ts` exports a shared `DecodePressureSignal` singleton. The audio pipeline (`OpusStreamDecoder` yield-cap events, `PlaybackScheduler` underrun-parking events) calls `report()` on sustained lag; the visualizer calls `isUnderPressure()` each rAF frame. The signal engages only after ≥ 5 reports within 2500 ms with a 1s minimum hold (hysteresis); a lone startup-ramp blip never engages.
|
||||
- Under pressure, `WaveformVisualizer.ts` throttles its rAF loop to ~15 fps (`PRESSURE_THROTTLE_FRAME_MS = 1000/15`), cutting main-thread WebGL software-render + physics cost so WebCodecs decode recovers. A no-op under HW accel.
|
||||
- `DeepDrftPublic/Interop/visualizer/hwAccel.ts` probes `WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL` for software-renderer signatures (SwiftShader, llvmpipe, softpipe, Microsoft Basic Render, etc.). Absence of the debug extension is treated as accelerated — lava is disabled only on positive evidence. On first interactive render, `WaveformVisualizerControlState.ApplyCapabilityDefault(bool)` applies a one-time scoped default: `LavaEnabled = false` (the expensive subsystem) when no HW accel is detected, `WaveformEnabled` stays on. Guarded by `_capabilityDefaultApplied`; never overrides an explicit user toggle.
|
||||
- **Known limitation / deferred escalation:** HW-accel-off Opus playback is made usable by defaulting lava off and throttling under pressure. If starvation recurs (e.g. waveform-only path also proves too costly, or a software renderer slips the probe), the documented next step is moving Opus WebCodecs decode off the main thread (Web Worker / AudioWorklet) so it stops competing with main-thread rendering. Not a current implementation — a recorded fallback.
|
||||
|
||||
Keep this seam clean — it is the most architecturally load-bearing part of the playback path.
|
||||
|
||||
### Theming and dark mode
|
||||
|
||||
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined inline in `MainLayout.razor`.
|
||||
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined in `DeepDrftShared.Client/Common/DeepDrftPalettes.cs`. `MainLayout.razor` mounts `<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />` — the palettes are not inline in the layout.
|
||||
- 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`.
|
||||
- **Theme-aware token layer:** `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two kinds of CSS custom properties. *Source tokens* (`--deepdrft-navy`, `--deepdrft-white`, `--deepdrft-green-accent`, etc.) are brand constants — identical in `:root` and `.deepdrft-theme-dark`. *Theme-aware aliases* are defined in both blocks and flip when the theme wrapper class changes. Component and page CSS must bind the **alias**, not the source token, so neutral surfaces invert for free. Current alias families: `--deepdrft-page-surface`/`-text`/`-text-muted` (neutral page backgrounds and text), `--deepdrft-play-chip`/`-glyph`/`-chip-soft` (play-state icon chip and glyph), `--deepdrft-popover-surface` (default MudBlazor popover background — light: `color-mix(navy 4%, white)`, a near-page-background surface; dark: references source token `--deepdrft-popover-surface-dark`, a `color-mix(navy-mid 80%, green-accent 20%)` bluer navy defined once in `:root` and referenced by both the `.deepdrft-theme-dark` wrapper block and `body.deepdrft-theme-dark` so portaled popovers are reached). The bespoke glass panels (visualizer/queue/privacy) now bind their own theme-aware `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` family: dark-glass charcoal (sourced from the `--deepdrft-panel-ground` constant) with light text in dark theme, and a light translucent glass with dark text in light theme. These tokens are re-declared in `body.deepdrft-theme-dark` because the panels are MudOverlay panels that portal to `<body>` (same portal scope as popovers); the `--deepdrft-panel-ground` source token is now consumed only via the dark `--deepdrft-panel-surface` value.
|
||||
- **Portaled-popover body-class bridge:** MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark popover token never reached them. Fix: `MainLayout.razor` stamps `deepdrft-theme-dark` on `<body>` via the `setBodyThemeClass(isDark)` helper in `DeepDrftShared.Client/Interop/theme/theme.ts` (lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`). The call fires only on first render or when `_isDarkMode` actually changes (gated by `_lastAppliedDarkMode` comparison) to avoid redundant JS calls on unrelated re-renders. The `body.deepdrft-theme-dark` selector in `deepdrft-tokens.css` resolves `--deepdrft-popover-surface` from `--deepdrft-popover-surface-dark` for these portaled elements.
|
||||
- **Interactive-accent icon treatment (`.dd-accent-icon` / `.dd-accent-fill`):** one reusable rule in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` for green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger), replacing the former pile of per-site dark overrides. Wrap the affordance container in `.dd-accent-icon` to colour its glyphs green-accent in both themes; add `.dd-accent-fill` when the container also holds a `Color.Secondary` filled button that must go green-accent in dark. It is a CSS class (not a palette `Color`) because no MudBlazor `Color` enum is green in both themes, and it targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important` to beat MudBlazor's standalone `.mud-secondary-text` (0,1,0) `!important` on the glyph svg — specificity wins; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too (already green-accent in light via `Color.Secondary`, so folding them in keeps light pixel-identical and fixes dark). The gas-lamp toggle (`GasLampLit`) is self-colored in its SVG (`fill="#2A5C4F"` on the frame) — no dark-only CSS rule is needed; `GasLamp` (unlit, light mode) continues to use `currentColor` and inherits nav text colour. New green-accent icons use this class, not a new override. (Convention detail in `DeepDrftPublic.Client/CLAUDE.md`.)
|
||||
- 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`), `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`), and `Interop/theme/theme.ts` (`setBodyThemeClass(isDark)` — stamps/removes `deepdrft-theme-dark` on `<body>` so portaled MudBlazor elements inherit the dark popover token; consumed by `MainLayout.razor`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -112,10 +136,10 @@ dotnet run --project DeepDrftAPI
|
||||
### Entity Framework (SQL Database)
|
||||
```bash
|
||||
# Add migration (from solution root)
|
||||
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
|
||||
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI
|
||||
|
||||
# Update database
|
||||
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic
|
||||
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
|
||||
```
|
||||
|
||||
## Key Configuration Files
|
||||
@@ -123,8 +147,8 @@ 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`).
|
||||
- `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).
|
||||
- `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 1200 — budget for server-side persist after the body is fully sent).
|
||||
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Non-secret upload tunable: `Upload:StagingPath` (default empty → a `staging` subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (`/tmp` is a small tmpfs on the Linux host); `Startup` also points the framework's multipart buffer here via `ASPNETCORE_TEMP`. 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
|
||||
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ The CMS is now inlined as the primary content of `DeepDrftManager`, a dedicated
|
||||
- A `[HierarchicalRoleAuthorize("Admin")]` attribute (from `AuthBlocksWeb.HierarchicalAuthorize`) on every CMS page component, so `Admin` and any descendant role are admitted by the bundled hierarchical role handler.
|
||||
- Controllers and minimal-API endpoints for CMS operations (`POST api/cms/track`, `DELETE api/cms/track/{id}`, `PUT api/cms/track/{id}`). Controllers are host-owned per the existing convention. Protected by `[Authorize(Roles = "Admin")]` — the JWT bearer middleware AuthBlocks installs validates the access token on each request.
|
||||
- The `AddAuthBlocks(...)` call in `Program.cs` and the matching `await app.Services.UseAuthBlocksStartupAsync()` post-build hook. This installs JWT bearer middleware, the hierarchical role authorization handler, the `AuthDbContext`, the EF migrations, and seeds system roles plus the configured admin user on first boot.
|
||||
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pending-registrations/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
|
||||
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pendingregistration/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
|
||||
|
||||
**Render mode:** `InteractiveServer` for all CMS pages and routes. AuthBlocks's bundled UI (`AuthBlocksWeb` pages) is server-rendered MudBlazor with `JwtAuthenticationStateProvider` reading tokens from browser `localStorage` via JS interop. `InteractiveServer` is the right fit because: (a) it matches what the bundled login UI uses, (b) `InputFile` uploads are natively server-side, (c) CMS endpoints live in the `DeepDrftManager` process with direct access to services.
|
||||
|
||||
@@ -79,7 +79,7 @@ Concretely, from reading the library source:
|
||||
|
||||
- Real per-user accounts (`ApplicationUser` table). No shared password.
|
||||
- One seeded admin on first boot via `AdminUserSettings`. Username, email, password come from `DeepDrftManager/environment/authblocks.json` (gitignored, same pattern as `apikey.json`).
|
||||
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pending-registrations`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
|
||||
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pendingregistration`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
|
||||
- **Mutation attribution.** `TrackEntity` gains a nullable `CreatedByUserId : long?` column in the W1.2 migration. Populated on every CMS-originated mutation; null for historical CLI-added rows and for any pre-CMS data. Captures attribution from day one even though Wave 1 has exactly one user (`feedback_design_for_adaptability`).
|
||||
- **Role gate.** Every CMS page and every `api/cms/*` endpoint requires the `Admin` system role. We use `Admin` rather than introducing a new `CmsAdmin` role because the collective is small and the existing hierarchy already covers the case; if Wave 3 ever needs finer grain (e.g. a `ContentEditor` role that can edit but not delete), that is a `SystemRole.cs` edit upstream, not a redesign here.
|
||||
|
||||
|
||||
+2169
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.
|
||||
|
||||
+276
-28
@@ -6,15 +6,17 @@ 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/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates streaming vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`). The upload/replace hot path streams audio into the vault via the `ProcessedAudio` plan (Wave 1 OOM fix) and then computes both waveform datums in a single bounded streaming pass via `TryStoreWaveformDatumsAsync` (Wave 2 OOM fix) — neither path buffers the whole audio file in a managed `byte[]`.
|
||||
- `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.
|
||||
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`, `UploadSettings`, `UploadStagingDirectory`). No domain code.
|
||||
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
|
||||
- `environment/apikey.json`: API key for track endpoints (loaded via CredentialTools, not in repo, must be created locally or at deployment).
|
||||
- `environment/connections.json`: SQL and Auth connection strings (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }`).
|
||||
@@ -22,21 +24,130 @@ 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.
|
||||
Streams the track's audio bytes from disk with HTTP Range support. An optional `?format=opus` query parameter selects between the derived Opus artifact and the lossless source.
|
||||
|
||||
- **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`).
|
||||
- **Query parameter `format`** (optional): `opus` requests the derived Ogg Opus artifact from the `track-opus` vault when present, falling back to lossless when it is not (C2 — never 404 if any audio exists); omitted or any other value delivers the lossless source in its stored format (WAV/MP3/FLAC). Both arms stream from a seekable disk `FileStream` via `File(stream, contentType, enableRangeProcessing: true)` — no whole-file `byte[]`; `Content-Type` reflects what was actually served (e.g., `audio/ogg` on an Opus hit, the source's real MIME on a lossless request or C2 fallback).
|
||||
- **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. Honoured on both arms (seekable `FileStream` in both cases).
|
||||
- Returns 200 for full-file requests, 206 for Range requests, 404 if no audio artifact exists for the track, 500 if vault operations fail.
|
||||
|
||||
### 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).
|
||||
- Streams the vault audio in bounded ≤80 KB chunks (no whole-file load) via `WaveformProfileService.ComputeAndStoreProfileStreamingAsync`. Tri-state result: `null` = no vault audio → 404; `false` = audio present but not WAV-decodable or vault write failed → 500; `true` = stored → 200.
|
||||
- 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).
|
||||
- Reads the duration from vault index metadata (no audio body load), then streams the vault audio in bounded chunks via `WaveformProfileService.ComputeAndStoreHighResStreamingAsync`. Tri-state result: `null` = no vault audio → 404; `false` = audio present but not WAV-decodable or vault write failed → 500; `true` = stored → 200.
|
||||
- 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.
|
||||
|
||||
### GET api/track/opus-status ([ApiKeyAuthorize])
|
||||
|
||||
Admin Post-Processing view: returns every track with a flag indicating whether it has a complete Opus artifact (both audio AND seek/setup sidecar present in the `track-opus` vault). Used by the CMS to show the Backfill-Opus badge and to poll per-track Post-Processing status after an upload. Mirrors the shape and auth posture of `GET api/track/waveform-status`.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Response**: `List<OpusStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, and `HasOpus` (bool — true only when both the Opus audio entry and the seek/setup sidecar entry are present in `track-opus`; a half-derived track counts as incomplete).
|
||||
- `HasOpus` is resolved via `TrackFormatResolver.HasOpusAsync` — an **index-only** existence check (`MediaVault.HasIndexEntry` for both entries; no file-body load). The endpoint loops over the whole catalogue, so a body load per track would stream the full library sequentially; the index lookup costs zero disk reads per track.
|
||||
- Returns 200 on success. Returns 500 on query error.
|
||||
|
||||
### POST api/track/duration/backfill ([ApiKeyAuthorize])
|
||||
|
||||
Admin backfill: for every track whose `DurationSeconds` SQL column is still null, reads the `AudioBinary.Duration` from the vault and writes it to SQL. Idempotent — a re-run only touches still-null rows; rows that already have a value are skipped.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- No request body.
|
||||
- Calls `UnifiedTrackService.BackfillDurationsAsync`. Lives on `TrackController` in the literal-route block (before `{trackId}` routes, so the segment is never treated as a trackId).
|
||||
- **Response**: `{ updated: int, skipped: int }` — counts of rows written vs. already-populated rows bypassed.
|
||||
- Returns 200 on success. Returns 500 if the backfill operation fails.
|
||||
|
||||
### GET api/track/release/exists ([ApiKeyAuthorize])
|
||||
|
||||
Upload-form pre-flight: checks whether a release with the given (title, artist) already exists in the catalogue. Returns the matching `ReleaseDto` (so the caller can name it in a block message) or 404 when none exists. Uses the same `GetReleaseByTitleAndArtist` read the upload CREATE-path duplicate guard uses, so the pre-flight and the server backstop agree on the match by construction (exact ordinal comparison, soft-deleted rows excluded).
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Query parameters**:
|
||||
- `title` (string, required): the release title to check.
|
||||
- `artist` (string, required): the artist name to check.
|
||||
- Declared as a literal 2-segment route (`"release/exists"`) before the parameterized `{trackId}` route and distinct from `"release/{id:long}"` (different segment shape) — no routing ambiguity.
|
||||
- Returns 200 with `ReleaseDto` JSON if a match exists. Returns 400 if either query parameter is missing or whitespace. Returns 404 if no match. 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 +162,25 @@ 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.
|
||||
- `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools.
|
||||
- The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `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 staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host).
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory.
|
||||
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic streaming vault write via router — audio is streamed to the vault via the `ProcessedAudio` plan, no whole-file buffer — Wave 1 OOM fix) → `TrackManager` (SQL persist with `createdByUserId`) → `TryStoreWaveformDatumsAsync` (best-effort: reads duration from vault index metadata, then computes both waveform datums from a single bounded streaming pass over the stored audio — Wave 2 OOM fix). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching.
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails.
|
||||
|
||||
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
|
||||
|
||||
@@ -76,17 +191,31 @@ 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])
|
||||
### POST api/track/{id:long}/replace-audio ([ApiKeyAuthorize])
|
||||
|
||||
**Authenticated endpoint.** Paged metadata list from SQL. Used by CMS track browser.
|
||||
**Authenticated endpoint.** Accepts a raw audio file upload (.wav, .mp3, .flac) as `multipart/form-data` and replaces the existing track's vault bytes in place, preserving the track id, `EntryKey`, SQL row (metadata/release/position), and release membership. Both waveform datums (512-bucket seeker profile + high-res visualizer datum) are regenerated after the swap; waveform regen failure is logged and swallowed — it does not fail the replace.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID.
|
||||
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block.
|
||||
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (streams new audio into the vault under the existing `EntryKey` via the `ProcessedAudio` plan, no whole-file buffer — Wave 1 OOM fix; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums from a single bounded streaming pass over the freshly stored audio via `TryStoreWaveformDatumsAsync` (best-effort; a datum failure is logged and swallowed — Wave 2 OOM fix) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
|
||||
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
|
||||
|
||||
### GET api/track/page (unauthenticated)
|
||||
|
||||
Paged metadata list from SQL with optional filtering. Public browser data, same auth posture as `GET api/track/{id}`.
|
||||
|
||||
- **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 +233,124 @@ 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.ComputeAndStoreHighResStreamingAsync` (streams the vault audio in bounded chunks, no whole-file buffer) — 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.
|
||||
|
||||
## The stats endpoints
|
||||
|
||||
### GET api/stats/home (unauthenticated)
|
||||
|
||||
Aggregate figures behind the public home hero stat row (`NowPlayingStats`). A single read returns everything the three cards need so the client makes one round-trip. Public, same auth posture as `GET api/track/page`.
|
||||
|
||||
- **Response**: `HomeStatsDto` with:
|
||||
- `CutTrackCount` (int): total non-deleted tracks on Cut-medium releases.
|
||||
- `CutReleaseTypeCounts` (`List<CutReleaseTypeCount>`): per-`ReleaseType` Cut release counts; zero-count types are absent (zero-suppressed server-side).
|
||||
- `MixReleaseCount` (int): total non-deleted Mix-medium releases.
|
||||
- `MixRuntimeSeconds` (double): sum of `DurationSeconds` across all non-deleted tracks on Mix releases (null durations count as 0).
|
||||
- `TotalPlays` (long): site-wide total plays — sum of every `play_counter` row's bucket columns (`PartialCount + SampledCount + CompleteCount`), all-time (Phase 16). Zero until the play-telemetry migration is applied.
|
||||
- `UniqueListeners` (int): site-wide distinct anonymous listeners — distinct non-null `anon_id` across all play events, all-time (Phase 16). Zero until the migration is applied.
|
||||
- `StatsController` injects **both** `ITrackService` (track-domain aggregation — Cuts/Mixes cards) and `IEventService` (event-domain aggregation — Plays card). Neither domain reaches into the other's tables; the controller is the thin composition seam. Track-domain aggregation comes from `TrackRepository.GetHomeStatsAsync` via `ITrackService.GetHomeStats`; play/listener figures come from `IEventService.GetTotalPlayCount` and `IEventService.GetDistinctListenerCount` (Phase 16 wave 16.5). Play/listener reads are **best-effort**: a telemetry failure or not-yet-applied migration leaves those fields at 0 rather than failing the whole endpoint with 500.
|
||||
- Returns 200 on success. Returns 500 if the track-domain aggregation fails.
|
||||
|
||||
## The event endpoints (Phase 16 anonymous telemetry)
|
||||
|
||||
Both endpoints are unauthenticated and rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, keyed on `Connection.RemoteIpAddress` after `UseForwardedHeaders()` resolves XFF). Returns `202 Accepted` — fire-and-forget contract; the `sendBeacon` client ignores the response. Controller: `EventController`.
|
||||
|
||||
### POST api/event/play (unauthenticated, rate-limited)
|
||||
|
||||
Records an anonymous play event. Client sends the track `EntryKey`, a completion bucket, and an optional `anonId` (wave 16.3); server-side release resolution joins track→release at write time (D4). The `anonId` is length-clamped server-side: whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars returns `400` rather than being truncated (truncation would collide distinct listeners).
|
||||
|
||||
- **Body** (`PlayEventDto`): `{ "trackEntryKey": "...", "bucket": "partial"|"sampled"|"complete", "anonId": "..." }` (`anonId` optional — omitted when null).
|
||||
- Validates: non-empty `trackEntryKey`; `bucket` must be a defined `PlayBucket` enum value.
|
||||
- Delegates to `IEventService.RecordPlay`, which appends to `play_event` and bumps `play_counter`.
|
||||
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 when the rate limit is exceeded. Returns 500 on a write failure (logged; beacon ignores it).
|
||||
|
||||
### POST api/event/share (unauthenticated, rate-limited)
|
||||
|
||||
Records an anonymous share event (a clipboard write from `SharePopover`).
|
||||
|
||||
- **Body** (`ShareEventDto`): `{ "targetKey": "...", "targetType": "track"|"release", "channel": "link"|"embed", "anonId": "..." }` (`anonId` optional — omitted when null; same length-clamp as the play endpoint).
|
||||
- Validates: non-empty `targetKey`; defined `ShareTargetType` and `ShareChannel` enum values; `anonId` ≤ 64 chars (reject-not-truncate).
|
||||
- Delegates to `IEventService.RecordShare`, which appends to `share_event`.
|
||||
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 on rate limit. Returns 500 on write failure.
|
||||
|
||||
## ApiKey middleware behaviour
|
||||
|
||||
@@ -141,7 +385,10 @@ 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`.
|
||||
6a. **Upload staging directory** — resolve and create the on-disk staging directory (read `Upload:StagingPath`; if empty, default to a `staging` subdirectory under the FileDatabase vault path via `Startup.ResolveStagingPath`). Set the `ASPNETCORE_TEMP` env var to this directory before any request is served, relocating the framework's multipart file-section buffer (Layer 1) off the system temp mount. Register `UploadStagingDirectory` as a singleton so both `UploadTrack` and `ReplaceAudio` in `TrackController` stage to the same data-disk location (Layer 2) and never write to `/tmp` (a small RAM-backed tmpfs on the Linux host).
|
||||
|
||||
**In `Program.cs`** (SQL + AuthBlocks + wiring):
|
||||
|
||||
@@ -164,8 +411,9 @@ Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints loc
|
||||
|
||||
## Configuration files
|
||||
|
||||
- `appsettings.json`: Logging, hosting, CORS, and AuthBlocks config. **Does not contain secrets.**
|
||||
- `appsettings.json`: Logging, hosting, CORS, AuthBlocks, and non-secret upload config. **Does not contain secrets.**
|
||||
- `Logging`: standard ASP.NET structure.
|
||||
- `Upload:StagingPath`: non-secret string. Empty default → a `staging` subdirectory under the FileDatabase vault path (on the data disk). Override to an absolute path when the vault default is not suitable. Consumed by `Startup.ResolveStagingPath`.
|
||||
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
|
||||
- `AuthBlocks:Jwt:Issuer`, `AuthBlocks:Jwt:Audience`: JWT validation settings (loaded from `environment/authblocks.json`).
|
||||
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
|
||||
@@ -232,7 +480,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,114 @@
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the
|
||||
/// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload-
|
||||
/// validated to make casual inflation annoying (§2.5). Both endpoints return <c>202 Accepted</c>: these
|
||||
/// are fire-and-forget telemetry, not transactions, and the client (a <c>sendBeacon</c>) never reads the
|
||||
/// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4).
|
||||
/// The controller is a thin HTTP boundary; all write logic lives in <see cref="IEventService"/>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/event")]
|
||||
[EnableRateLimiting("events")]
|
||||
public class EventController : ControllerBase
|
||||
{
|
||||
// Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate
|
||||
// payloads are a track key + an enum, well under 1 KB.
|
||||
private const int MaxBodyBytes = 1024;
|
||||
|
||||
// The anonId is a client-minted GUID string (~36 chars); the anon_id column is varchar(64). Reject
|
||||
// anything longer as malformed rather than silently truncating — an over-long token is either a bug
|
||||
// or an inflation attempt, and a truncated id would corrupt the distinct-listener count by colliding
|
||||
// distinct listeners onto one prefix. Whitespace-only is treated as absent.
|
||||
private const int MaxAnonIdLength = 64;
|
||||
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ILogger<EventController> _logger;
|
||||
|
||||
public EventController(IEventService eventService, ILogger<EventController> logger)
|
||||
{
|
||||
_eventService = eventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// POST api/event/play (unauthenticated, rate-limited)
|
||||
[HttpPost("play")]
|
||||
[RequestSizeLimit(MaxBodyBytes)]
|
||||
public async Task<ActionResult> RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default)
|
||||
{
|
||||
// Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding
|
||||
// already 400s a malformed/oversized body and an undefined enum value, but the explicit guards
|
||||
// keep the contract obvious and cover the empty-string key the model binder lets through.
|
||||
if (string.IsNullOrWhiteSpace(payload.TrackEntryKey))
|
||||
return BadRequest("trackEntryKey is required");
|
||||
if (!Enum.IsDefined(payload.Bucket))
|
||||
return BadRequest("bucket is invalid");
|
||||
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
|
||||
return BadRequest("anonId is invalid");
|
||||
|
||||
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
// A telemetry failure must never surface to the listener as an error they can act on, but
|
||||
// we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The
|
||||
// beacon ignores the status either way.
|
||||
_logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message);
|
||||
return StatusCode(500);
|
||||
}
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
// POST api/event/share (unauthenticated, rate-limited)
|
||||
[HttpPost("share")]
|
||||
[RequestSizeLimit(MaxBodyBytes)]
|
||||
public async Task<ActionResult> RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload.TargetKey))
|
||||
return BadRequest("targetKey is required");
|
||||
if (!Enum.IsDefined(payload.TargetType))
|
||||
return BadRequest("targetType is invalid");
|
||||
if (!Enum.IsDefined(payload.Channel))
|
||||
return BadRequest("channel is invalid");
|
||||
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
|
||||
return BadRequest("anonId is invalid");
|
||||
|
||||
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
|
||||
return StatusCode(500);
|
||||
}
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
// Normalize an incoming anonId (wave 16.3): whitespace-only / empty / null collapses to a null token
|
||||
// (the listener didn't send one, or storage was unavailable — a valid, anonId-less event). A token
|
||||
// over the column width is rejected (400) rather than truncated, since truncation would collide
|
||||
// distinct listeners. Returns false only on the over-long case; null and a valid token both pass.
|
||||
private static bool TryNormalizeAnonId(string? raw, out string? anonId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
anonId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length > MaxAnonIdLength)
|
||||
{
|
||||
anonId = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
anonId = trimmed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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: stream the Mix's track audio from the vault, compute a duration-derived
|
||||
// high-res waveform, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using DeepDrftData;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class StatsController : ControllerBase
|
||||
{
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ILogger<StatsController> _logger;
|
||||
|
||||
public StatsController(
|
||||
ITrackService sqlTrackService, IEventService eventService, ILogger<StatsController> logger)
|
||||
{
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_eventService = eventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// GET api/stats/home (unauthenticated)
|
||||
// Aggregate figures behind the public home hero stat row — one read for all three cards. Same auth
|
||||
// posture as the other public browse reads (GET api/track/page). The figures span two domains:
|
||||
// the track-domain aggregation (Cuts/Mixes cards) lives in the SQL track service; the play-domain
|
||||
// figures (Phase 16 Plays card — total plays + unique listeners) live in the event service. This
|
||||
// controller is the thin composition seam that assembles both into one HomeStatsDto — neither
|
||||
// domain reaches into the other's tables. Play/listener figures are best-effort: a telemetry read
|
||||
// failure (or the not-yet-applied migration) leaves them at zero rather than failing the whole card.
|
||||
[HttpGet("home")]
|
||||
public async Task<ActionResult> GetHome(CancellationToken ct = default)
|
||||
{
|
||||
var trackResult = await _sqlTrackService.GetHomeStats(ct);
|
||||
if (!trackResult.Success || trackResult.Value is null)
|
||||
{
|
||||
var error = trackResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetHome stats failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load stats");
|
||||
}
|
||||
|
||||
var stats = trackResult.Value;
|
||||
|
||||
var playsResult = await _eventService.GetTotalPlayCount(ct);
|
||||
if (playsResult is { Success: true })
|
||||
stats.TotalPlays = playsResult.Value;
|
||||
else
|
||||
_logger.LogWarning("GetHome total-plays read failed; Plays card falls back to 0: {Error}",
|
||||
playsResult.Messages.FirstOrDefault()?.Message);
|
||||
|
||||
var listenersResult = await _eventService.GetDistinctListenerCount(ct);
|
||||
if (listenersResult is { Success: true })
|
||||
stats.UniqueListeners = listenersResult.Value;
|
||||
else
|
||||
_logger.LogWarning("GetHome unique-listeners read failed; secondary line falls back to 0: {Error}",
|
||||
listenersResult.Messages.FirstOrDefault()?.Message);
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,13 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
|
||||
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
|
||||
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
|
||||
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.39" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Exposes the internal 409 markers (CardinalityViolationMarker / DuplicateReleaseMarker) to the
|
||||
test suite so UploadDuplicateDetectionTests can assert the orchestrator's rejection contract. -->
|
||||
<InternalsVisibleTo Include="DeepDrftTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -26,3 +32,4 @@
|
||||
|
||||
|
||||
</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);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace DeepDrftAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Non-secret upload tunables. <see cref="StagingPath"/> is the directory used to stage the raw
|
||||
/// audio body during upload/replace-audio. It must live on the data disk, never the system temp
|
||||
/// mount (on the Linux host <c>/tmp</c> is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB
|
||||
/// WAV). When null/empty it defaults to a "staging" subdirectory under the FileDatabase vault path.
|
||||
/// </summary>
|
||||
public class UploadSettings
|
||||
{
|
||||
public string? StagingPath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DeepDrftAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved, on-disk staging directory for upload/replace-audio bodies. Resolved once at
|
||||
/// startup from <see cref="UploadSettings"/> (or the vault path default) and guaranteed to exist.
|
||||
/// Injected into <c>TrackController</c> so the upload path never stages on the system temp mount.
|
||||
/// A typed wrapper rather than a bare string so DI resolves it unambiguously.
|
||||
/// </summary>
|
||||
public sealed record UploadStagingDirectory(string Path);
|
||||
}
|
||||
+55
-2
@@ -4,12 +4,15 @@ using DeepDrftAPI;
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftAPI.Services.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NetBlocks.Utilities.Environment;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
// Required credential files — must exist before the app will start.
|
||||
// Production secrets stay gitignored; the *.example.json templates at the project root show the shape.
|
||||
@@ -64,6 +67,29 @@ builder.Services
|
||||
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
builder.Services.AddScoped<UnifiedTrackService>();
|
||||
|
||||
// Background Opus transcode (Phase 18.1, OQ6). One singleton is both the enqueue seam
|
||||
// (IOpusTranscodeQueue, injected into the scoped UnifiedTrackService) and the hosted drain loop
|
||||
// (IHostedService). It resolves OpusTranscodeService — a singleton — so no scope is captured.
|
||||
builder.Services.AddSingleton<OpusTranscodeBackgroundService>();
|
||||
builder.Services.AddSingleton<IOpusTranscodeQueue>(sp => sp.GetRequiredService<OpusTranscodeBackgroundService>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<OpusTranscodeBackgroundService>());
|
||||
|
||||
// Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL).
|
||||
// EventManager is the IEventService boundary; EventRepository owns the EF writes and the
|
||||
// release-resolution + counter-bump transaction.
|
||||
builder.Services
|
||||
.AddScoped<EventRepository>()
|
||||
.AddScoped<EventManager>()
|
||||
.AddScoped<IEventService>(sp => sp.GetRequiredService<EventManager>());
|
||||
|
||||
// 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.
|
||||
@@ -85,10 +111,13 @@ builder.Services.AddAuthBlocks(options =>
|
||||
options.JwtSettings.Audience = builder.Configuration["AuthBlocks:Jwt:Audience"]
|
||||
?? throw new InvalidOperationException("AuthBlocks:Jwt:Audience is required");
|
||||
|
||||
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
|
||||
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
|
||||
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
|
||||
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
|
||||
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
|
||||
?? throw new InvalidOperationException("AuthBlocks:Email:Token is required");
|
||||
options.EmailConnection.FromAddress = builder.Configuration["AuthBlocks:Email:From"]
|
||||
?? throw new InvalidOperationException("AuthBlocks:Email:From is required");
|
||||
options.EmailConnection.TestInbox = builder.Configuration["AuthBlocks:Email:TestInbox"];
|
||||
|
||||
options.AdminUserSettings = new AdminUserSettings
|
||||
{
|
||||
@@ -110,6 +139,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
// Per-IP rate limiting for the anonymous telemetry intake (Phase 16 §2.5). Coarse and stateless —
|
||||
// a fixed window keyed by the (forwarded) remote IP. The substrate sits behind nginx, so the real
|
||||
// client IP is the X-Forwarded-For value UseForwardedHeaders resolves into Connection.RemoteIpAddress.
|
||||
// On limit, reject with 429 (the beacon ignores it; this only blunts casual inflation). The 30-window
|
||||
// budget is generous for a real listening session and only bites on scripted spam.
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("events", httpContext =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 30,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot.
|
||||
@@ -128,6 +176,11 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseCors("ContentApiPolicy");
|
||||
|
||||
// Rate limiter must sit in the pipeline for the [EnableRateLimiting("events")] attribute on
|
||||
// EventController to take effect. Only the telemetry endpoints carry the policy; everything else is
|
||||
// unaffected (no global limiter is set).
|
||||
app.UseRateLimiter();
|
||||
|
||||
// ApiKey middleware only enforces on endpoints tagged [ApiKeyAuthorize] (the track surface); it
|
||||
// passes all other endpoints through. JWT auth/authorization gate the AuthBlocks endpoints, which
|
||||
// carry no [ApiKeyAuthorize] metadata — the two schemes are orthogonal and do not interfere.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace DeepDrftAPI.Services.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// The enqueue seam for the background Opus transcode (OQ6 / §3.1a). <see cref="UnifiedTrackService"/>
|
||||
/// depends only on this thin interface — not on the worker — so adding the background derive to the
|
||||
/// upload/replace paths costs one small dependency, not the whole transcode graph. Enqueuing is
|
||||
/// non-blocking and best-effort: a freshly uploaded track is already persisted and playable losslessly
|
||||
/// before anything is enqueued, and the transcode runs off the request thread.
|
||||
/// </summary>
|
||||
public interface IOpusTranscodeQueue
|
||||
{
|
||||
/// <summary>
|
||||
/// Schedules a background Opus derive for the track identified by <paramref name="entryKey"/>. Returns
|
||||
/// immediately. A dropped or failed enqueue must not affect the caller — the track remains
|
||||
/// lossless-only and eligible for backfill.
|
||||
/// </summary>
|
||||
void Enqueue(string entryKey);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Threading.Channels;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
|
||||
namespace DeepDrftAPI.Services.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// The background worker behind <see cref="IOpusTranscodeQueue"/> (OQ6 / §3.1a). An unbounded in-process
|
||||
/// channel buffers EntryKeys enqueued by the upload and replace-audio paths; a single hosted loop drains
|
||||
/// them one at a time and runs <see cref="OpusTranscodeService.TranscodeAndStoreAsync"/> for each. Serial
|
||||
/// by design — a transcode is CPU-heavy (§3.1), so running them concurrently would starve request
|
||||
/// handling; one-at-a-time keeps the derive strictly off the hot path without saturating the host.
|
||||
///
|
||||
/// This worker IS the queue (implements <see cref="IOpusTranscodeQueue"/>) so enqueue and drain share one
|
||||
/// channel with no extra indirection. It is registered as a singleton and surfaced under both the
|
||||
/// interface and <see cref="IHostedService"/>.
|
||||
/// </summary>
|
||||
public sealed class OpusTranscodeBackgroundService : BackgroundService, IOpusTranscodeQueue
|
||||
{
|
||||
private readonly Channel<string> _channel =
|
||||
Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
private readonly OpusTranscodeService _transcodeService;
|
||||
private readonly ILogger<OpusTranscodeBackgroundService> _logger;
|
||||
|
||||
public OpusTranscodeBackgroundService(
|
||||
OpusTranscodeService transcodeService,
|
||||
ILogger<OpusTranscodeBackgroundService> logger)
|
||||
{
|
||||
_transcodeService = transcodeService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void Enqueue(string entryKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryKey))
|
||||
return;
|
||||
|
||||
if (!_channel.Writer.TryWrite(entryKey))
|
||||
{
|
||||
// Unbounded writer only rejects after Complete(), i.e. during shutdown. The track stays
|
||||
// lossless-only and is eligible for backfill, so a dropped enqueue is non-fatal — log it.
|
||||
_logger.LogWarning("Opus transcode: could not enqueue {EntryKey} (queue closed).", entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var entryKey in _channel.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _transcodeService.TranscodeAndStoreAsync(entryKey, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break; // host shutting down
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// TranscodeAndStoreAsync already swallows expected failures; this guards the loop against
|
||||
// anything unexpected so one bad track never kills the worker.
|
||||
_logger.LogError(ex, "Opus transcode: unhandled failure draining {EntryKey}; worker continues.", entryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_channel.Writer.TryComplete();
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Duration from the vault index metadata (no body load); its absence means no vault audio.
|
||||
var duration = await _trackContentService.GetAudioDurationAsync(entryKey);
|
||||
if (duration 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). Streamed from the vault in bounded
|
||||
// chunks (Wave 2): a ~GB mix is never buffered whole. Tri-state — null = entry vanished after
|
||||
// the metadata read; false = uncomputable / write failed.
|
||||
var computed = await _waveformProfileService.ComputeAndStoreHighResStreamingAsync(
|
||||
_ => _trackContentService.OpenAudioStreamAsync(entryKey), entryKey, duration.Value, ct);
|
||||
if (computed is null)
|
||||
{
|
||||
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
|
||||
return Result.CreateFailResult(MixTrackNoAudioMessage);
|
||||
}
|
||||
|
||||
if (computed is false)
|
||||
{
|
||||
_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,11 @@
|
||||
using DeepDrftAPI.Services.Opus;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
@@ -15,27 +19,55 @@ 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: ";
|
||||
|
||||
/// <summary>
|
||||
/// Stable marker prefixed onto a duplicate-release rejection so the controller can map it to 409
|
||||
/// Conflict, the same way <see cref="CardinalityViolationMarker"/> is mapped. Fires when an upload
|
||||
/// with no explicit releaseId would create a release whose (title, artist) already exists in the
|
||||
/// catalogue — the upload form is a create-new tool, never an edit/append path. The human-readable
|
||||
/// detail follows the marker and is what the CMS surfaces to the admin.
|
||||
/// </summary>
|
||||
internal const string DuplicateReleaseMarker = "DUPLICATE_RELEASE: ";
|
||||
|
||||
private readonly TrackContentService _contentTrackContentService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly IOpusTranscodeQueue _opusTranscodeQueue;
|
||||
private readonly TrackFormatResolver _formatResolver;
|
||||
private readonly ILogger<UnifiedTrackService> _logger;
|
||||
|
||||
public UnifiedTrackService(
|
||||
TrackContentService contentTrackContentService,
|
||||
ITrackService sqlTrackService,
|
||||
FileDb fileDatabase,
|
||||
WaveformProfileService waveformProfileService,
|
||||
IOpusTranscodeQueue opusTranscodeQueue,
|
||||
TrackFormatResolver formatResolver,
|
||||
ILogger<UnifiedTrackService> logger)
|
||||
{
|
||||
_contentTrackContentService = contentTrackContentService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_opusTranscodeQueue = opusTranscodeQueue;
|
||||
_formatResolver = formatResolver;
|
||||
_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 +75,78 @@ public class UnifiedTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
DateOnly? releaseDate,
|
||||
long createdByUserId,
|
||||
string? originalFileName,
|
||||
ReleaseType releaseType,
|
||||
ReleaseMedium medium,
|
||||
int trackNumber,
|
||||
long? releaseId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate);
|
||||
// Resolve which release this track lands on BEFORE the vault write, so a rejected upload never
|
||||
// orphans audio. Two paths:
|
||||
// - releaseId is null → CREATE path: this is the first row of a submit. (title, artist) must
|
||||
// NOT already exist — the upload form creates new releases only. A pre-existing match is a
|
||||
// duplicate and is blocked (409).
|
||||
// - releaseId is set → ATTACH path: rows 2..N of a within-batch multi-track Cut, attaching
|
||||
// to the release row 1 just created. No (title, artist) lookup — the release id is
|
||||
// authoritative — so the within-batch build is never mistaken for a pre-existing duplicate.
|
||||
// Both paths run the cardinality guard `(liveCount + 1) > Max` (not Session/Mix-hardcoded, so a
|
||||
// future bounded medium is covered by the same line).
|
||||
ResolvedRelease? resolved = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
if (releaseId is { } attachId)
|
||||
{
|
||||
var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
|
||||
if (!attachPeek.Success)
|
||||
{
|
||||
var error = attachPeek.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}");
|
||||
}
|
||||
|
||||
// The attach target must be the same release the natural key resolves to — a guard against
|
||||
// a stale/forged releaseId pointing at a different (title, artist) than this row carries.
|
||||
if (attachPeek.Value is not { } target || target.Id != attachId)
|
||||
{
|
||||
return ResultContainer<TrackDto>.CreateFailResult(
|
||||
$"{DuplicateReleaseMarker}The release this track should attach to could not be found. " +
|
||||
"Start the upload again.");
|
||||
}
|
||||
|
||||
var cardinalityCheck = CheckCardinality(target);
|
||||
if (cardinalityCheck is { } violation)
|
||||
return ResultContainer<TrackDto>.CreateFailResult(violation);
|
||||
|
||||
resolved = new ResolvedRelease(target.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
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}");
|
||||
}
|
||||
|
||||
// CREATE path: a pre-existing (title, artist) is a duplicate. Block it — the form never
|
||||
// edits or appends to an existing release.
|
||||
if (peek.Value is { } existing)
|
||||
{
|
||||
return ResultContainer<TrackDto>.CreateFailResult(
|
||||
$"{DuplicateReleaseMarker}A release titled '{existing.Title}' by {existing.Artist} already " +
|
||||
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
|
||||
}
|
||||
// resolved stays null → FindOrCreateRelease below creates the release.
|
||||
}
|
||||
}
|
||||
|
||||
var unpersisted = await _contentTrackContentService.AddTrackAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName, cancellationToken: ct);
|
||||
|
||||
if (unpersisted is null)
|
||||
{
|
||||
@@ -56,9 +154,63 @@ 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? resolvedReleaseId = resolved?.Id;
|
||||
if (!string.IsNullOrWhiteSpace(album) && resolvedReleaseId is null)
|
||||
{
|
||||
// CREATE path only: the duplicate guard above proved no (title, artist) match exists, so this
|
||||
// mints the release. (The attach path already resolved the id from the pre-check above and
|
||||
// skips FindOrCreateRelease entirely, so a within-batch row never re-runs the natural-key find.)
|
||||
var releaseData = new ReleaseDto
|
||||
{
|
||||
Title = album,
|
||||
Artist = artist,
|
||||
Genre = genre,
|
||||
Description = description,
|
||||
ReleaseDate = releaseDate,
|
||||
ReleaseType = releaseType,
|
||||
Medium = medium,
|
||||
CreatedByUserId = createdByUserId,
|
||||
};
|
||||
|
||||
// FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the
|
||||
// row the concurrent winner just inserted (WasCreated = false). In the CREATE path the
|
||||
// duplicate peek above already verified no pre-existing row exists — so WasCreated = false
|
||||
// means we lost a concurrent-insert race. Treat that as the duplicate condition: reject
|
||||
// rather than silently attaching, keeping the DB unique index as the final safety net.
|
||||
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
|
||||
if (!releaseResult.Success)
|
||||
{
|
||||
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}");
|
||||
}
|
||||
|
||||
var (resolvedRelease, wasCreated) = releaseResult.Value;
|
||||
if (!wasCreated)
|
||||
{
|
||||
// The winning concurrent upload created this release between our peek and our insert.
|
||||
// Reject with the same marker the pre-flight peek uses so the controller maps it to 409.
|
||||
return ResultContainer<TrackDto>.CreateFailResult(
|
||||
$"{DuplicateReleaseMarker}A release titled '{resolvedRelease.Title}' by {resolvedRelease.Artist} already " +
|
||||
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
|
||||
}
|
||||
|
||||
resolvedReleaseId = resolvedRelease.Id;
|
||||
}
|
||||
|
||||
var trackDto = TrackConverter.Convert(unpersisted);
|
||||
trackDto.ReleaseId = resolvedReleaseId;
|
||||
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 +222,244 @@ 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);
|
||||
|
||||
// Schedule the low-data Opus derive (OQ6 / §3.1a): the track is persisted and lossless-playable
|
||||
// NOW; the transcode + seek-index build run on a background worker. Non-blocking and best-effort
|
||||
// — the upload response never waits on it, and a transcode failure leaves the track lossless-only.
|
||||
_opusTranscodeQueue.Enqueue(unpersisted.EntryKey);
|
||||
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
// The release a track resolved onto before the vault write. A null Id is the create path (mint
|
||||
// below); a non-null Id is the attach path (a within-batch multi-track Cut row 2..N).
|
||||
private readonly record struct ResolvedRelease(long Id);
|
||||
|
||||
// The cardinality guard shared by the attach path and (historically) the create path: a release
|
||||
// already at its medium's Max rejects a further track. Returns the marker-prefixed rejection
|
||||
// message, or null when the add is within limits. The create path never trips this (a brand-new
|
||||
// release has zero tracks and admits its first), so only the attach path calls it today.
|
||||
private static string? CheckCardinality(ReleaseDto release)
|
||||
{
|
||||
var cardinality = MediumRules.CardinalityOf(release.Medium);
|
||||
if (release.TrackCount + 1 > cardinality.Max)
|
||||
{
|
||||
return $"{CardinalityViolationMarker}A {release.Medium} release holds a single track; " +
|
||||
$"'{release.Title}' already has one — edit the existing track or choose a different release.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes
|
||||
/// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
|
||||
/// new duration to SQL. Track id, EntryKey, release membership, track number, and all other
|
||||
/// metadata are preserved. The waveform regen is best-effort (a missing datum renders as a flat
|
||||
/// seekbar / blank visualizer downstream), so a datum failure is logged and swallowed rather than
|
||||
/// failing the replace. The duration write is not best-effort — a failure is surfaced so derived
|
||||
/// aggregates (e.g. MixRuntimeSeconds) do not silently go stale. No release-cardinality cascade
|
||||
/// applies: the track count is unchanged, so the single-track-Mix case stays intact.
|
||||
/// </summary>
|
||||
public async Task<Result> ReplaceAudioAsync(long trackId, string tempFilePath, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _sqlTrackService.GetById(trackId);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogError("ReplaceAudioAsync: GetById failed for track {TrackId}: {Error}", trackId, error);
|
||||
return Result.CreateFailResult("Failed to load track.");
|
||||
}
|
||||
|
||||
if (lookup.Value is null)
|
||||
{
|
||||
return Result.CreateFailResult(TrackNotFoundMessage);
|
||||
}
|
||||
|
||||
var entryKey = lookup.Value.EntryKey;
|
||||
|
||||
var newDuration = await _contentTrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath, ct);
|
||||
if (newDuration is null)
|
||||
{
|
||||
_logger.LogWarning("ReplaceAudioAsync: content swap returned null for track {TrackId} ({EntryKey})", trackId, entryKey);
|
||||
return Result.CreateFailResult("Failed to process and store the replacement audio.");
|
||||
}
|
||||
|
||||
// The old waveform no longer matches the new bytes. Regenerate both datums in place, keyed by
|
||||
// the same EntryKey (the re-run overwrites the stale data). The store path no longer hands back
|
||||
// a buffer, so the waveform compute re-reads the freshly stored audio from the vault — the same
|
||||
// path the upload uses. That re-read is now a bounded streaming pass (Wave 2); neither the store
|
||||
// nor the compute holds the whole file. Best-effort throughout: a datum failure never fails the replace.
|
||||
await TryStoreWaveformDatumsAsync(entryKey, ct);
|
||||
|
||||
// Write the new duration to SQL. The vault bytes are already swapped, so this is the
|
||||
// authoritative metadata update for the replace. A failure here is surfaced (unlike the
|
||||
// best-effort waveform regen above) because a stale DurationSeconds silently corrupts
|
||||
// derived aggregates (e.g. MixRuntimeSeconds on the home stats endpoint).
|
||||
var durationWrite = await _sqlTrackService.SetDuration(trackId, newDuration.Value, ct);
|
||||
if (!durationWrite.Success)
|
||||
{
|
||||
var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"ReplaceAudioAsync: vault swap succeeded but SQL duration update failed for track {TrackId} ({EntryKey}): {Error}",
|
||||
trackId, entryKey, error);
|
||||
return Result.CreateFailResult("Audio replaced but duration metadata could not be updated.");
|
||||
}
|
||||
|
||||
// The stale Opus artifact (if any) no longer matches the new source. Schedule a background
|
||||
// regenerate — the transcode service overwrites the prior artifacts in place keyed by the same
|
||||
// EntryKey. Best-effort, off the request thread, mirrors the waveform regen above.
|
||||
_opusTranscodeQueue.Enqueue(entryKey);
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
// 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 are reduced in a
|
||||
// SINGLE streaming pass over the vault audio (Wave 2): the duration comes from the vault index
|
||||
// metadata (no body load) and the PCM is streamed in bounded chunks through two accumulators, so a
|
||||
// ~GB mix never lands its whole body in a managed byte[]. Best-effort throughout — never fails upload.
|
||||
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var duration = await _contentTrackContentService.GetAudioDurationAsync(entryKey);
|
||||
if (duration is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
|
||||
entryKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await _waveformProfileService.ComputeAndStoreAllStreamingAsync(
|
||||
_ => _contentTrackContentService.OpenAudioStreamAsync(entryKey), entryKey, duration.Value, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-time backfill: for every non-deleted track whose SQL duration is still null, read the
|
||||
/// processor-extracted runtime from the vault audio (by EntryKey) and write it to SQL. The migration
|
||||
/// cannot read the vault, so this runs at runtime after deploy. Idempotent — a re-run only touches
|
||||
/// rows still missing a duration. Returns (updated, skipped) counts. A per-track vault miss or SQL
|
||||
/// failure is logged and skipped, never aborting the batch.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<(int Updated, int Skipped)>> BackfillDurationsAsync(CancellationToken ct)
|
||||
{
|
||||
var missing = await _sqlTrackService.GetTracksMissingDuration(ct);
|
||||
if (!missing.Success || missing.Value is null)
|
||||
{
|
||||
var error = missing.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("BackfillDurationsAsync: failed to load tracks missing duration: {Error}", error);
|
||||
return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
|
||||
}
|
||||
|
||||
var updated = 0;
|
||||
var skipped = 0;
|
||||
foreach (var track in missing.Value)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Read the duration from the vault index metadata (no audio body load) — the same value the
|
||||
// processor wrote at upload. Bounds this admin path too (Wave 2): a backfill over a catalogue
|
||||
// of long mixes no longer pulls each whole file into memory just to read its runtime.
|
||||
var duration = await _contentTrackContentService.GetAudioDurationAsync(track.EntryKey);
|
||||
if (duration is null)
|
||||
{
|
||||
_logger.LogWarning("BackfillDurationsAsync: no vault audio for {EntryKey} (track {Id}); skipping.",
|
||||
track.EntryKey, track.Id);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var write = await _sqlTrackService.UpdateDuration(track.Id, duration.Value, ct);
|
||||
if (!write.Success)
|
||||
{
|
||||
var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogWarning("BackfillDurationsAsync: SQL update failed for track {Id}: {Error}", track.Id, error);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
updated++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackfillDurationsAsync complete: {Updated} updated, {Skipped} skipped.", updated, skipped);
|
||||
return ResultContainer<(int, int)>.CreatePassResult((updated, skipped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill-Opus (18.5, OQ4): enqueue a background Opus derive for every non-deleted track that lacks a
|
||||
/// complete Opus artifact (missing audio OR missing sidecar — a half-derived track is treated as missing
|
||||
/// and re-derived). Mirrors the duration-backfill posture: enumerate SQL rows, check each against the
|
||||
/// <c>track-opus</c> vault, schedule the misses. Enqueue-only and non-blocking — the actual transcodes run
|
||||
/// on the shared background worker, serially (the same queue the upload/replace paths feed), so this
|
||||
/// returns as soon as the misses are scheduled rather than waiting on CPU-heavy transcodes. Idempotent:
|
||||
/// a re-run only enqueues tracks still missing Opus, and already-queued/in-flight derives simply overwrite
|
||||
/// in place. Returns (enqueued, skipped) — skipped = tracks that already have a complete Opus artifact.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<(int Enqueued, int Skipped)>> BackfillOpusAsync(CancellationToken ct)
|
||||
{
|
||||
var all = await _sqlTrackService.GetAll();
|
||||
if (!all.Success || all.Value is null)
|
||||
{
|
||||
var error = all.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("BackfillOpusAsync: failed to load tracks: {Error}", error);
|
||||
return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
|
||||
}
|
||||
|
||||
var enqueued = 0;
|
||||
var skipped = 0;
|
||||
foreach (var track in all.Value)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (await _formatResolver.HasOpusAsync(track.EntryKey))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
_opusTranscodeQueue.Enqueue(track.EntryKey);
|
||||
enqueued++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackfillOpusAsync complete: {Enqueued} enqueued, {Skipped} already had Opus.",
|
||||
enqueued, skipped);
|
||||
return ResultContainer<(int, int)>.CreatePassResult((enqueued, skipped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-track Opus (re)derive trigger (18.5): schedule a background transcode for one track. Returns false
|
||||
/// only when the track id is unknown; the enqueue itself is non-blocking and best-effort, like the bulk
|
||||
/// backfill. Re-runnable — overwrites any prior artifact in place.
|
||||
/// </summary>
|
||||
public async Task<Result> EnqueueOpusAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _sqlTrackService.GetByEntryKey(entryKey);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("EnqueueOpusAsync: lookup failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return Result.CreateFailResult("Failed to load track.");
|
||||
}
|
||||
|
||||
if (lookup.Value is null)
|
||||
return Result.CreateFailResult(TrackNotFoundMessage);
|
||||
|
||||
_opusTranscodeQueue.Enqueue(entryKey);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
/// <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 +481,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 +491,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 +511,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-2
@@ -1,10 +1,10 @@
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Audio;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetBlocks.Utilities.Environment;
|
||||
|
||||
@@ -15,10 +15,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,12 +43,62 @@ 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();
|
||||
InitializeTrackOpusVault(db).GetAwaiter().GetResult();
|
||||
return db;
|
||||
});
|
||||
|
||||
// Upload staging directory. Large audio bodies (multi-hundred-MB WAVs) must never stage on
|
||||
// the system temp mount — on the Linux host /tmp is a small RAM-backed tmpfs. We move BOTH
|
||||
// on-disk copies of an upload off /tmp onto the data disk:
|
||||
// Layer 1 — the framework's multipart file-section buffer (FileBufferingReadStream), which
|
||||
// reads its directory from the ASPNETCORE_TEMP env var (falling back to
|
||||
// Path.GetTempPath()). Setting the var here, before the host runs, relocates it.
|
||||
// Layer 2 — the controller's own staging file, via the injected UploadStagingDirectory.
|
||||
// Default location is a "staging" subdirectory beside the vaults; override with
|
||||
// Upload:StagingPath in appsettings.json.
|
||||
var uploadSettings = builder.Configuration.GetSection("Upload").Get<UploadSettings>();
|
||||
var stagingPath = ResolveStagingPath(uploadSettings?.StagingPath, vaultPath);
|
||||
Directory.CreateDirectory(stagingPath);
|
||||
|
||||
// AspNetCoreTempDirectory caches this value on first read and throws if the directory is
|
||||
// absent, so set it (and create the dir) before any request is served.
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
|
||||
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
|
||||
|
||||
// Opus low-data transcode (Phase 18.1). The domain service lives in DeepDrftContent; the host
|
||||
// owns only the engine config and the background worker. Bitrate/ffmpeg-path come from the
|
||||
// OpusTranscode config section; StagingPath is forced to the same data-disk staging directory
|
||||
// the upload path uses so large transcode temp files never land on the /tmp tmpfs.
|
||||
builder.Services.Configure<OpusTranscodeOptions>(
|
||||
builder.Configuration.GetSection(nameof(OpusTranscodeOptions)));
|
||||
builder.Services.PostConfigure<OpusTranscodeOptions>(o => o.StagingPath = stagingPath);
|
||||
builder.Services.AddSingleton<FfmpegOpusEncoder>();
|
||||
builder.Services.AddSingleton<OpusTranscodeService>();
|
||||
|
||||
// Opus delivery format resolution + sidecar lookup (Phase 18.2). The seam 18.3 calls behind
|
||||
// the ?format= stream param and the sidecar path. Stateless over the FileDatabase + content
|
||||
// service singletons; the lossless branch reuses the existing read path unchanged (C2).
|
||||
builder.Services.AddSingleton<TrackFormatResolver>();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the absolute upload-staging directory. An explicit <paramref name="configuredPath"/>
|
||||
/// (from <c>Upload:StagingPath</c>) wins; otherwise it defaults to a <c>staging</c> subdirectory
|
||||
/// under <paramref name="vaultPath"/> — on the data disk, never the system temp mount. Pure so
|
||||
/// the "never <c>/tmp</c>" invariant is unit-testable without standing up the host.
|
||||
/// </summary>
|
||||
public static string ResolveStagingPath(string? configuredPath, string vaultPath)
|
||||
{
|
||||
var path = string.IsNullOrWhiteSpace(configuredPath)
|
||||
? Path.Combine(vaultPath, "staging")
|
||||
: configuredPath;
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.Tracks))
|
||||
@@ -45,5 +106,33 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the track-opus vault exists (Phase 18.1). Holds the derived low-data Ogg Opus artifacts
|
||||
// — the Opus audio bytes and the setup-header + seek-index sidecar — keyed by the track's EntryKey.
|
||||
private static async Task InitializeTrackOpusVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.TrackOpus))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"DeepDrftContent.Controllers.TrackController": "Information"
|
||||
"Default": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Upload": {
|
||||
"StagingPath": ""
|
||||
},
|
||||
"CorsSettings": {
|
||||
"AllowedOrigins": [
|
||||
"https://localhost:12778",
|
||||
"https://localhost:5004",
|
||||
"http://localhost:5003",
|
||||
"https://deepdrft.com",
|
||||
"https://www.deepdrft.com"
|
||||
"https://www.deepdrft.com",
|
||||
"https://app.deepdrft.com"
|
||||
]
|
||||
},
|
||||
"ForwardedHeaders": {
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
},
|
||||
"Email": {
|
||||
"Host": "smtp.your-provider.com",
|
||||
"Token": "your-email-token-here"
|
||||
"Token": "your-email-token-here",
|
||||
"From": "noreply@yourdomain.com",
|
||||
"TestInbox": "<sandbox-id>"
|
||||
},
|
||||
"Admin": {
|
||||
"UserName": "admin",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+68
-32
@@ -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,10 +17,10 @@ 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
|
||||
│ ├── AudioProcessor.cs # WAV file parsing, metadata extraction, streaming PCM header parse
|
||||
│ ├── AudioStoreStream.cs # Bounded streaming copy/prefix helpers used by the processors
|
||||
│ └── ProcessedAudio.cs # Store-path plan: metadata + streamed WriteToAsync callback
|
||||
├── Constants/
|
||||
│ └── VaultConstants.cs # Vault name definitions
|
||||
├── TrackService.cs # Content-side orchestrator
|
||||
@@ -49,6 +49,8 @@ FileBinary (base: byte buffer)
|
||||
|
||||
Each has a matching `*Dto` variant for base64 JSON transport (e.g., `AudioBinaryDto` with buffer encoded as base64).
|
||||
|
||||
**Read/load path**: vault reads (`LoadResourceAsync<AudioBinary>`) still return a full-buffer `AudioBinary`. **Write/store path** is streaming: audio processors return a `ProcessedAudio` plan (metadata + `WriteToAsync` callback); `RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` write bytes to a temp file, then `File.Move` atomic-rename into place. The full `AudioBinary` buffer is never materialized on the store path.
|
||||
|
||||
### Index lifecycle
|
||||
|
||||
- **DirectoryIndex**: Root index file (at `{rootPath}/index`). Tracks which vaults exist.
|
||||
@@ -72,51 +74,86 @@ public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, Fi
|
||||
try { /* store and update index */ }
|
||||
catch { return false; } // Swallow, return false
|
||||
}
|
||||
|
||||
// Streaming counterpart (Wave 1 OOM fix) — same bool contract:
|
||||
public async Task<bool> RegisterResourceStreamingAsync(
|
||||
string vaultId, string entryId, MetaData metaData,
|
||||
Func<Stream, CancellationToken, Task> writeContent, CancellationToken ct)
|
||||
{
|
||||
try { /* stream to temp → atomic rename → update index */ }
|
||||
catch { return false; } // Swallow (logs non-cancel exceptions), return false
|
||||
}
|
||||
```
|
||||
|
||||
`MediaVault.AddEntryStreamingAsync` (called internally) writes bytes to a temp file in the vault directory, then `File.Move(temp, final, overwrite: true)` (atomic POSIX rename on the Linux prod host), then updates the index. A cancel or I/O fault before the rename leaves any prior backing file intact; the temp file is cleaned up best-effort.
|
||||
|
||||
**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 `ProcessedAudio`** (metadata + streamed `WriteToAsync` callback — no whole-file buffer). Header window grows in 64 KB steps capped at 8 MB; the audio body is never read during processing. Standard PCM WAV is stored verbatim (passthrough via `AudioStoreStream.CopyFileAsync`); EXTENSIBLE-PCM / IEEE-float / padded-container WAVs stream their normalization to standard 24-bit PCM. On parse failure, falls back to defaults (180s / 1411 kbps). Also exposes `TryExtractPcm(ReadOnlySpan<byte>)` for the whole-buffer waveform parity oracle and `TryReadPcmStreamInfoAsync(stream, totalLength)` for the streaming waveform compute path (bounded header parse from a stream; returns `WavPcmStreamInfo?` with `DataStart`/`DataLength`/format fields).
|
||||
- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Reads a bounded ≤8 MB prefix for header parsing; 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 `ProcessedAudio`** (passthrough plan — MP3 stored unmodified). On parse failure, falls back to defaults (180s / 320 kbps).
|
||||
- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Reads a bounded ≤64 KB prefix. 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 `ProcessedAudio`** (passthrough plan — FLAC stored unmodified). On parse failure, falls back to defaults (180s / 1411 kbps).
|
||||
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav` → `AudioProcessor`, `.mp3` → `Mp3AudioProcessor`, `.flac` → `FlacAudioProcessor`. **Returns `ProcessedAudio?`**. Throws `ArgumentException` for unsupported extensions.
|
||||
- `ProcessedAudio`: Store-path plan returned by all processors. Carries `Extension`, `Duration`, `Bitrate`, `Size`, and a `WriteToAsync(destination, ct)` callback that streams the canonical vault bytes to the destination without materializing the whole file. `ProcessedAudio.Passthrough(sourcePath, ...)` builds a passthrough plan via `AudioStoreStream.CopyFileAsync`.
|
||||
- `AudioStoreStream`: Internal bounded-buffer streaming helpers. `CopyFileAsync(sourcePath, destination, ct)` does a bounded 80 KB disk-to-disk copy; `ReadPrefixAsync(path, cap, ct)` reads at most `cap` bytes from the start of a file (used by processors for header parsing without loading the body).
|
||||
- `ILoudnessAlgorithm` / `ILoudnessAccumulator`: The loudness strategy interface now exposes both `Compute(ReadOnlySpan<byte>, ...)` (whole-buffer, retained as the parity oracle for tests — no production callers) and `CreateAccumulator(pcmByteLength, ...)` → `ILoudnessAccumulator` (streaming: `Add(ReadOnlySpan<byte>)` / `Finish()` → `double[]`). `RmsLoudnessAlgorithm` implements both; `Compute` is defined in terms of the accumulator, so streaming and whole-buffer outputs are byte-identical.
|
||||
- `WaveformProfileService`: Streaming loudness compute + vault store. The whole-buffer `ComputeAndStoreAsync` / `ComputeAndStoreHighResAsync` methods are **retained but have no production callers** — they exist as the byte-identity parity oracle for tests; do not delete them. The production paths are: `ComputeAndStoreProfileStreamingAsync` (512-bucket seeker profile, tri-state `bool?`), `ComputeAndStoreHighResStreamingAsync` (duration-derived high-res datum, tri-state `bool?`), and `ComputeAndStoreAllStreamingAsync` (both datums in a SINGLE streaming pass, used by the upload/replace hot path, tri-state `bool?`). Tri-state: `null` = no backing audio stream; `false` = audio present but not WAV-decodable or vault write failed; `true` = stored. Streaming reads the WAV in bounded ≤80 KB chunks through one accumulator per datum; peak memory is O(bucket arrays + read buffer), independent of file size.
|
||||
- `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)
|
||||
|
||||
### AddTrackFromWavAsync(filePath)
|
||||
### AddTrackAsync(audioFilePath, ...)
|
||||
|
||||
1. Reads a WAV file from disk.
|
||||
2. Calls `AudioProcessor.ProcessWavFileAsync` → `AudioBinary`.
|
||||
3. Generates a GUID entry key (via `Guid.NewGuid().ToString()`).
|
||||
4. Ensures the `tracks` vault exists (creates if missing).
|
||||
5. Calls `FileDatabase.RegisterResourceAsync("tracks", entryKey, audioBinary)`.
|
||||
6. Returns a populated `TrackEntity` (with `Id = 0` since it's not yet in SQL).
|
||||
The primary upload entry point. Format-agnostic — routes by extension via `AudioProcessorRouter`.
|
||||
|
||||
**Note**: The caller (CLI or web) is responsible for then saving this entity to SQL via `DeepDrftWeb.Services.TrackService.Create`. If the vault write succeeds and SQL write fails, audio is orphaned (no compensating rollback).
|
||||
1. Calls `AudioProcessorRouter.ProcessAudioFileAsync(filePath)` → `ProcessedAudio` plan (no whole-file buffer — Wave 1 OOM fix).
|
||||
2. Generates a GUID entry key (via `Guid.NewGuid().ToString()`).
|
||||
3. Ensures the `tracks` vault exists (creates if missing).
|
||||
4. Builds `MetaData` from the plan's header-extracted fields and calls `FileDatabase.RegisterResourceStreamingAsync("tracks", entryKey, metaData, processed.WriteToAsync)` — bytes are streamed from the staging file to the vault via a bounded copy; `MediaVault.AddEntryStreamingAsync` writes to a temp file then atomic-renames into place.
|
||||
5. Returns a populated `TrackEntity` (with `Id = 0` since it's not yet in SQL, and `DurationSeconds` populated from the header parse).
|
||||
|
||||
If the vault write succeeds and SQL write fails, audio is orphaned (no compensating rollback).
|
||||
|
||||
### AddTrackFromWavAsync(filePath, ...)
|
||||
|
||||
Backward-compatible shim — delegates to `AddTrackAsync`. The router accepts WAV alongside MP3 and FLAC, so this carries no WAV-specific logic of its own.
|
||||
|
||||
### ReplaceTrackAudioAsync(entryKey, audioFilePath)
|
||||
|
||||
Swaps the vault bytes for an existing track in place, under the same `entryKey`. Captures the old extension from the vault index metadata (not by loading the file) so a cross-format swap can clean up the stale backing file post-success. Streams the new audio via `ProcessedAudio` + `RegisterResourceStreamingAsync` (no whole-file buffer — Wave 1 OOM fix). Returns the new audio's duration on success, `null` on failure (original audio left intact).
|
||||
|
||||
### GetAudioBinaryAsync(entryKey)
|
||||
|
||||
Reads audio from the `tracks` vault via `FileDatabase.LoadResourceAsync<AudioBinary>("tracks", entryKey)`. Returns `null` if not found or read fails.
|
||||
Reads audio from the `tracks` vault via `FileDatabase.LoadResourceAsync<AudioBinary>("tracks", entryKey)`. Returns a full-buffer `AudioBinary` or `null` if not found. This is a full-buffer convenience read — **not** on the audio-delivery hot path. The delivery path now uses `TrackFormatResolver` (Opus: `MediaVault.GetEntryStreamAsync`; lossless: `OpenAudioMediaStreamAsync`). `GetAudioBinaryAsync` is retained as a read-back oracle used by tests (`AudioStoreStreamingTests`, `TrackReplaceAudioTests`, `TrackFormatDeliveryTests`).
|
||||
|
||||
### OpenAudioStreamAsync(entryKey)
|
||||
|
||||
Returns a read-only, seekable `Stream` over a track's vault audio without buffering the whole file. Used by the streaming waveform compute path (`WaveformProfileService`). Caller owns and must dispose the stream. Returns `null` if the entry has no backing file.
|
||||
|
||||
### OpenAudioMediaStreamAsync(entryKey)
|
||||
|
||||
Returns a `MediaStream?` — a read-only, non-buffering stream over a track's vault audio together with its stored extension, or `null` if the entry has no backing file. Same non-buffering contract as `OpenAudioStreamAsync`, but exposes `.Extension` alongside `.Stream` so the caller can name a staging file by the original format (the Opus transcode stages the source with the correct extension so ffmpeg can detect the format). The caller owns the returned `MediaStream` and must dispose it. Follows the FileDatabase swallow-and-return-null contract.
|
||||
|
||||
### GetAudioDurationAsync(entryKey)
|
||||
|
||||
Reads the stored audio duration from the vault index metadata only — no audio body load. Used by the streaming waveform compute to derive the duration-based bucket count, and by `UnifiedTrackService.BackfillDurationsAsync`. Returns `null` if the entry is unknown or carries no audio metadata.
|
||||
|
||||
### InitializeTracksVaultAsync()
|
||||
|
||||
@@ -124,14 +161,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"`, `VaultConstants.TrackWaveforms = "track-waveforms"`, and `VaultConstants.TrackOpus = "track-opus"` — 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). `TrackOpus` holds two entries per track: the derived Opus audio (keyed by `entryKey`, extension `.opus`) and the combined setup-header + seek-index sidecar (keyed by `{entryKey}-sidecar`, extension `.opusidx`). 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,32 @@ 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";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for the derived low-data Ogg Opus artifacts, keyed by the track's EntryKey (Phase 18,
|
||||
/// S2). Holds two entries per track: the Opus audio bytes (<c>.opus</c>) and the combined setup-header
|
||||
/// + granule→byte seek-index sidecar (<c>.opusidx</c>). Both are best-effort derived artifacts —
|
||||
/// regenerable, and a track without them still plays losslessly. Distinct from the source <c>tracks</c>
|
||||
/// vault so the source means exactly one thing (mirrors the <c>track-waveforms</c> precedent).
|
||||
/// </summary>
|
||||
public const string TrackOpus = "track-opus";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -206,6 +206,7 @@ public static class MimeTypeExtensions
|
||||
{ ".flac", "audio/flac" },
|
||||
{ ".aac", "audio/aac" },
|
||||
{ ".ogg", "audio/ogg" },
|
||||
{ ".opus", "audio/ogg" },
|
||||
{ ".m4a", "audio/mp4" }
|
||||
};
|
||||
|
||||
|
||||
@@ -178,6 +178,46 @@ public class FileDatabase : DirectoryIndexDirectory, IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a resource by streaming its bytes into the vault, without materializing the whole
|
||||
/// file in a managed <c>byte[]</c> (the store-path OOM fix). The caller supplies the index
|
||||
/// <paramref name="metaData"/> and a <paramref name="writeContent"/> callback that emits bytes to
|
||||
/// the backing stream. Swallows exceptions and returns false, matching
|
||||
/// <see cref="RegisterResourceAsync"/>'s contract — callers check the bool.
|
||||
/// </summary>
|
||||
public async Task<bool> RegisterResourceStreamingAsync(
|
||||
string vaultId,
|
||||
string entryId,
|
||||
MetaData metaData,
|
||||
Func<Stream, CancellationToken, Task> writeContent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directoryVault = _vaults.Get(vaultId);
|
||||
if (directoryVault != null)
|
||||
{
|
||||
var written = await directoryVault.AddEntryStreamingAsync(entryId, metaData, writeContent, cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"Streamed {Bytes} bytes into vault {VaultId} entry {EntryId} (no whole-file buffer).",
|
||||
written, vaultId, entryId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow and return false, matching RegisterResourceAsync. Log at error for real failures
|
||||
// only — a normal client cancel (OperationCanceledException) is not an error condition and
|
||||
// would spam the error log on every client disconnect during a large upload or replace.
|
||||
if (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "RegisterResourceStreamingAsync failed for vault {VaultId} entry {EntryId}", vaultId, entryId);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a resource from a specific vault. Returns null if the vault does not exist,
|
||||
/// false if the entry was not found, true if the entry was removed. Distinguishing
|
||||
|
||||
@@ -56,6 +56,63 @@ public abstract class MediaVault : VaultIndexDirectory
|
||||
await FileUtils.PutFileAsync(mediaPath, buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams an entry's bytes into the vault without ever materializing the whole file in memory.
|
||||
/// The metadata is supplied by the caller (there is no in-memory <see cref="FileBinary"/> to infer
|
||||
/// it from) — the store path (upload / replace-audio) sources its bytes from a staging file, not a
|
||||
/// buffer. Returns the number of bytes written, for the caller to log.
|
||||
///
|
||||
/// Write ordering (atomic-replace guarantee): bytes are streamed to a temp file in the same vault
|
||||
/// directory, the temp file is renamed over the final backing-file path (POSIX <c>rename(2)</c> —
|
||||
/// atomic on the Linux prod host), and the index is updated only after the rename succeeds.
|
||||
/// This ordering ensures: (a) the index never advertises a not-yet-present file; (b) a client
|
||||
/// disconnect or I/O fault during the write leaves any prior backing file intact and the index
|
||||
/// unchanged; (c) the temp file is cleaned up best-effort on any failure before re-throwing so the
|
||||
/// vault directory stays tidy. The caller treats a thrown exception as a failed register.
|
||||
/// </summary>
|
||||
public async Task<long> AddEntryStreamingAsync(
|
||||
string entryId,
|
||||
MetaData metaData,
|
||||
Func<Stream, CancellationToken, Task> writeContent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var finalPath = GetMediaPathFromEntryKey(entryId, metaData.Extension);
|
||||
var tempPath = Path.Combine(RootPath, Path.GetRandomFileName() + ".tmp");
|
||||
|
||||
try
|
||||
{
|
||||
long bytesWritten;
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
{
|
||||
await writeContent(tempStream, cancellationToken);
|
||||
await tempStream.FlushAsync(cancellationToken);
|
||||
bytesWritten = tempStream.Length;
|
||||
}
|
||||
|
||||
// Rename into place — atomic on the Linux prod host (POSIX rename(2)); overwrites any
|
||||
// existing same-extension backing file safely on the replace path.
|
||||
File.Move(tempPath, finalPath, overwrite: true);
|
||||
|
||||
// Update the index only after the file is durably in place. A crash between Move and
|
||||
// AddToIndexAsync leaves an unreferenced file on disk (a harmless orphan recoverable
|
||||
// by a vault scan); a crash or cancel during the temp write leaves the original backing
|
||||
// file and the index both unchanged.
|
||||
await AddToIndexAsync(entryId, metaData);
|
||||
|
||||
return bytesWritten;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort temp-file cleanup. After a successful rename tempPath is gone and the
|
||||
// delete is a no-op. After a write failure or cancel tempPath holds partial bytes that
|
||||
// must be removed so the vault directory stays tidy.
|
||||
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best-effort */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an entry from the vault (MediaVaultType inferred from T)
|
||||
/// </summary>
|
||||
@@ -219,6 +276,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,
|
||||
|
||||
@@ -7,12 +7,22 @@ namespace DeepDrftContent.Processors;
|
||||
/// </summary>
|
||||
public class AudioProcessor
|
||||
{
|
||||
// Header parsing never needs the audio body. Read the file in 64 KB steps until the data-chunk
|
||||
// header is locatable, capping the window so a pathological file with an enormous pre-data header
|
||||
// cannot drive an unbounded allocation — such a file simply falls through to default metadata and
|
||||
// passthrough storage, the same outcome as any unparseable WAV.
|
||||
private const int HeaderWindowStep = 64 * 1024;
|
||||
private const int HeaderWindowCap = 8 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Processes a WAV file and creates an AudioBinary object
|
||||
/// Processes a WAV file into a <see cref="ProcessedAudio"/> store plan: extracts metadata from a
|
||||
/// bounded header window (never the whole file) and returns a streamed writer for the canonical
|
||||
/// vault bytes. Standard PCM is stored verbatim (passthrough copy); EXTENSIBLE-PCM / IEEE-float /
|
||||
/// padded-container WAVs are normalized to a plain 44-byte standard-PCM WAV, written progressively
|
||||
/// so the vault only ever holds a format the streaming pipeline already handles.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the WAV file</param>
|
||||
/// <returns>AudioBinary object with metadata</returns>
|
||||
public async Task<AudioBinary?> ProcessWavFileAsync(string filePath)
|
||||
public async Task<ProcessedAudio?> ProcessWavFileAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
@@ -26,25 +36,357 @@ public class AudioProcessor
|
||||
|
||||
try
|
||||
{
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var wavInfo = ExtractWavMetadata(buffer);
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Extension: ".wav",
|
||||
Duration: wavInfo.Duration,
|
||||
Bitrate: wavInfo.Bitrate
|
||||
);
|
||||
var fileLength = new FileInfo(filePath).Length;
|
||||
var window = await ReadWavHeaderWindowAsync(filePath, cancellationToken);
|
||||
var wavInfo = ExtractWavMetadata(window);
|
||||
|
||||
return new AudioBinary(parameters);
|
||||
if (!wavInfo.IsExtensible)
|
||||
{
|
||||
// Standard PCM (or the default-fallback path, which reports IsExtensible = false):
|
||||
// the source bytes are already a format the pipeline handles, so store them verbatim.
|
||||
return ProcessedAudio.Passthrough(filePath, ".wav", wavInfo.Duration, wavInfo.Bitrate, fileLength);
|
||||
}
|
||||
|
||||
// EXTENSIBLE → streamed normalization. The output data size is derivable from the source
|
||||
// data size alone (no body read needed): verbatim keeps it, float drops 1 byte per sample
|
||||
// (4→3), padded keeps only the valid bytes per container sample.
|
||||
var dataStart = (long)wavInfo.DataChunkPos + 8;
|
||||
var available = fileLength - dataStart;
|
||||
var srcDataSize = Math.Min((long)wavInfo.DataSize, available);
|
||||
|
||||
NormalizeMode mode;
|
||||
int outBitsPerSample;
|
||||
long outDataSize;
|
||||
int containerBytes = 0;
|
||||
int validBytes = 0;
|
||||
if (wavInfo.IsFloat)
|
||||
{
|
||||
mode = NormalizeMode.Float;
|
||||
outBitsPerSample = 24;
|
||||
outDataSize = (srcDataSize / 4) * 3;
|
||||
}
|
||||
else if (wavInfo.IsPaddedContainer)
|
||||
{
|
||||
mode = NormalizeMode.Padded;
|
||||
outBitsPerSample = wavInfo.BitsPerSample;
|
||||
containerBytes = wavInfo.ContainerBitsPerSample / 8;
|
||||
validBytes = wavInfo.BitsPerSample / 8;
|
||||
outDataSize = (srcDataSize / containerBytes) * validBytes;
|
||||
}
|
||||
else
|
||||
{
|
||||
mode = NormalizeMode.Verbatim;
|
||||
outBitsPerSample = wavInfo.BitsPerSample;
|
||||
outDataSize = srcDataSize;
|
||||
}
|
||||
|
||||
var channels = wavInfo.Channels;
|
||||
var sampleRate = wavInfo.SampleRate;
|
||||
|
||||
return new ProcessedAudio(
|
||||
".wav", wavInfo.Duration, wavInfo.Bitrate, 44 + outDataSize,
|
||||
(destination, ct) => WriteNormalizedWavAsync(
|
||||
filePath, dataStart, srcDataSize, channels, sampleRate, outBitsPerSample,
|
||||
outDataSize, mode, containerBytes, validBytes, destination, ct));
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to process WAV file: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads only enough of the file to contain the fmt chunk and the data chunk's 8-byte header, so
|
||||
/// metadata parsing never loads the (potentially ~GB) audio body. Grows the window in 64 KB steps
|
||||
/// until the data chunk is locatable or EOF/<see cref="HeaderWindowCap"/> is hit.
|
||||
/// </summary>
|
||||
private static async Task<byte[]> ReadWavHeaderWindowAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var fs = new FileStream(
|
||||
filePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
bufferSize: HeaderWindowStep, useAsync: true);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
var buffer = new byte[HeaderWindowStep];
|
||||
while (ms.Length < HeaderWindowCap)
|
||||
{
|
||||
var read = await fs.ReadAsync(buffer, ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
ms.Write(buffer, 0, read);
|
||||
|
||||
// FindChunk returns -1 on a partial window (the data chunk isn't reachable yet), so keep
|
||||
// reading until it is found or the cap/EOF is hit. On normal files the data chunk header
|
||||
// sits within the first 64 KB, so this loop runs exactly once.
|
||||
var soFar = ms.ToArray();
|
||||
if (FindChunk(soFar, "data") >= 0)
|
||||
return soFar;
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a normalized standard-PCM WAV to <paramref name="destination"/>: the 44-byte header
|
||||
/// followed by the data region streamed from the source in bounded, sample-aligned chunks. No
|
||||
/// whole-file buffer is ever held — peak memory is O(chunk), independent of duration.
|
||||
/// </summary>
|
||||
private async Task WriteNormalizedWavAsync(
|
||||
string sourcePath, long dataStart, long srcDataSize,
|
||||
int channels, int sampleRate, int outBitsPerSample, long outDataSize,
|
||||
NormalizeMode mode, int containerBytes, int validBytes,
|
||||
Stream destination, CancellationToken ct)
|
||||
{
|
||||
var header = BuildStandardPcmHeader(channels, sampleRate, outBitsPerSample, outDataSize);
|
||||
await destination.WriteAsync(header, ct);
|
||||
|
||||
await using var src = new FileStream(
|
||||
sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
bufferSize: 81920, useAsync: true);
|
||||
src.Seek(dataStart, SeekOrigin.Begin);
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case NormalizeMode.Verbatim:
|
||||
await CopyBoundedAsync(src, destination, srcDataSize, ct);
|
||||
break;
|
||||
case NormalizeMode.Float:
|
||||
// Each 4-byte float sample becomes 3 bytes of 24-bit PCM.
|
||||
await TransformBoundedAsync(src, destination, srcDataSize, unit: 4,
|
||||
transform: (buf, len) => ConvertFloatTo24BitPcm(buf, 0, len), ct);
|
||||
break;
|
||||
case NormalizeMode.Padded:
|
||||
await TransformBoundedAsync(src, destination, srcDataSize, unit: containerBytes,
|
||||
transform: (buf, len) => RepackPaddedContainer(buf, 0, len, containerBytes * 8, validBytes * 8), ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bounded copy of exactly <paramref name="totalBytes"/> from src to dest.</summary>
|
||||
private static async Task CopyBoundedAsync(Stream src, Stream dest, long totalBytes, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
var remaining = totalBytes;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var want = (int)Math.Min(buffer.Length, remaining);
|
||||
var read = await src.ReadAsync(buffer.AsMemory(0, want), ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
await dest.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
remaining -= read;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams <paramref name="totalBytes"/> of source data through <paramref name="transform"/> in
|
||||
/// sample-aligned chunks, writing each transformed chunk to <paramref name="dest"/>. The read
|
||||
/// buffer is a multiple of <paramref name="unit"/>; leftover bytes that do not complete a sample
|
||||
/// are carried into the next read, and a final partial sample is dropped (matching the
|
||||
/// whole-buffer transforms' integer-division behavior).
|
||||
/// </summary>
|
||||
private static async Task TransformBoundedAsync(
|
||||
Stream src, Stream dest, long totalBytes, int unit,
|
||||
Func<byte[], int, byte[]> transform, CancellationToken ct)
|
||||
{
|
||||
var bufLen = Math.Max(unit, (81920 / unit) * unit);
|
||||
var buffer = new byte[bufLen];
|
||||
var remaining = totalBytes;
|
||||
var carried = 0;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var want = (int)Math.Min(bufLen - carried, remaining);
|
||||
if (want == 0)
|
||||
break;
|
||||
var read = await src.ReadAsync(buffer.AsMemory(carried, want), ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
remaining -= read;
|
||||
|
||||
var filled = carried + read;
|
||||
var whole = (filled / unit) * unit;
|
||||
if (whole > 0)
|
||||
{
|
||||
var output = transform(buffer, whole);
|
||||
await dest.WriteAsync(output, ct);
|
||||
}
|
||||
|
||||
carried = filled - whole;
|
||||
if (carried > 0)
|
||||
Array.Copy(buffer, whole, buffer, 0, carried);
|
||||
}
|
||||
}
|
||||
|
||||
private enum NormalizeMode
|
||||
{
|
||||
/// <summary>Sample bytes already standard PCM (EXTENSIBLE-PCM, depth == container width).</summary>
|
||||
Verbatim,
|
||||
/// <summary>IEEE float samples converted to 24-bit PCM.</summary>
|
||||
Float,
|
||||
/// <summary>Padded container (e.g. 24-in-32) re-packed to the valid depth.</summary>
|
||||
Padded
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Reads only the WAV header region from <paramref name="stream"/> (a bounded window, never the
|
||||
/// audio body) and returns where the PCM data region begins, how long it is, and the format
|
||||
/// parameters needed to decode it — the streaming counterpart of <see cref="TryExtractPcm"/>. The
|
||||
/// data length is clamped against <paramref name="totalFileLength"/> (the true backing-file size),
|
||||
/// so the caller streams exactly the present PCM. Returns null for the same inputs
|
||||
/// <see cref="TryExtractPcm"/> rejects — non-WAV bytes (mp3/flac), float, and padded-container
|
||||
/// EXTENSIBLE — so the caller treats null as "no profile computable" and continues gracefully.
|
||||
///
|
||||
/// <paramref name="stream"/> must be positioned at the start; on return its position is past the
|
||||
/// header window (the caller seeks to <c>DataStart</c> before streaming the body). No whole-file
|
||||
/// buffer is allocated — peak memory is the bounded header window.
|
||||
/// </summary>
|
||||
public async Task<WavPcmStreamInfo?> TryReadPcmStreamInfoAsync(
|
||||
Stream stream, long totalFileLength, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var window = await ReadWavHeaderWindowAsync(stream, cancellationToken);
|
||||
if (window is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var validation = ValidateWavStructure(window);
|
||||
if (!validation.IsValid || validation.IsFloat)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
WavMetadata metadata;
|
||||
try
|
||||
{
|
||||
metadata = ParseWavMetadata(window, validation);
|
||||
ValidateAudioParameters(metadata);
|
||||
if (metadata.IsPaddedContainer)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long dataStart = validation.DataChunkPos + 8;
|
||||
if (dataStart > totalFileLength)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var available = totalFileLength - dataStart;
|
||||
var dataLength = Math.Min((long)metadata.DataSize, available);
|
||||
if (dataLength <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WavPcmStreamInfo(
|
||||
dataStart, dataLength, metadata.Channels, metadata.SampleRate, metadata.BitsPerSample);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads enough of <paramref name="stream"/> to contain the fmt chunk and the data chunk's 8-byte
|
||||
/// header, growing in 64 KB steps until the data chunk is locatable or EOF / the
|
||||
/// <see cref="HeaderWindowCap"/> is reached. Bails after the first read when the bytes are not a
|
||||
/// RIFF/WAVE container, so a non-WAV stream (mp3/flac) costs one read, not the full cap. Returns
|
||||
/// null only when nothing could be read.
|
||||
/// </summary>
|
||||
private static async Task<byte[]?> ReadWavHeaderWindowAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
var buffer = new byte[HeaderWindowStep];
|
||||
while (ms.Length < HeaderWindowCap)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
ms.Write(buffer, 0, read);
|
||||
|
||||
var soFar = ms.ToArray();
|
||||
|
||||
// Early-out for non-WAV input: once at least the 12-byte RIFF/WAVE preamble is in hand,
|
||||
// a missing signature means this will never be a WAV — stop rather than read to the cap.
|
||||
if (soFar.Length >= 12 && !HasRiffWaveSignature(soFar))
|
||||
return soFar;
|
||||
|
||||
// FindChunk returns -1 until the data chunk header is fully in the window; on a normal
|
||||
// file it sits within the first 64 KB so this loop runs exactly once.
|
||||
if (FindChunk(soFar, "data") >= 0)
|
||||
return soFar;
|
||||
}
|
||||
|
||||
return ms.Length > 0 ? ms.ToArray() : null;
|
||||
}
|
||||
|
||||
private static bool HasRiffWaveSignature(byte[] buffer) =>
|
||||
buffer.Length >= 12
|
||||
&& System.Text.Encoding.ASCII.GetString(buffer, 0, 4) == "RIFF"
|
||||
&& System.Text.Encoding.ASCII.GetString(buffer, 8, 4) == "WAVE";
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from WAV file buffer with comprehensive validation
|
||||
/// </summary>
|
||||
@@ -107,9 +449,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 +500,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 +522,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 +549,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 +581,105 @@ 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>
|
||||
/// Builds the canonical 44-byte standard-PCM WAV header (audioFormat = 1) for a normalized stream.
|
||||
/// The body is written separately so no whole-file buffer is allocated; this only emits the header
|
||||
/// the streaming pipeline expects, reporting the valid (post-normalization) bit depth.
|
||||
/// </summary>
|
||||
private static byte[] BuildStandardPcmHeader(int channels, int sampleRate, int outBitsPerSample, long dataSize)
|
||||
{
|
||||
const int headerSize = 44;
|
||||
var result = new byte[headerSize];
|
||||
|
||||
var blockAlign = (ushort)(channels * (outBitsPerSample / 8));
|
||||
var byteRate = (uint)(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)channels).CopyTo(result, 22);
|
||||
BitConverter.GetBytes((uint)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);
|
||||
|
||||
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>
|
||||
@@ -206,7 +700,7 @@ public class AudioProcessor
|
||||
/// <summary>
|
||||
/// Finds a chunk in the WAV file buffer with proper alignment handling
|
||||
/// </summary>
|
||||
private int FindChunk(byte[] buffer, string chunkId)
|
||||
private static int FindChunk(byte[] buffer, string chunkId)
|
||||
{
|
||||
var chunkBytes = System.Text.Encoding.ASCII.GetBytes(chunkId);
|
||||
int offset = 12; // Start after RIFF header
|
||||
@@ -253,9 +747,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 +778,40 @@ 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);
|
||||
|
||||
/// <summary>
|
||||
/// Where a WAV's PCM data region lives and how to decode it, without the bytes themselves — the
|
||||
/// streaming counterpart of <see cref="PcmData"/>. The caller seeks to <see cref="DataStart"/> and
|
||||
/// streams exactly <see cref="DataLength"/> bytes through a loudness accumulator.
|
||||
/// </summary>
|
||||
/// <param name="DataStart">Absolute byte offset of the first PCM sample (past the data chunk header).</param>
|
||||
/// <param name="DataLength">PCM region length in bytes, clamped to what the backing file actually holds.</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 WavPcmStreamInfo(
|
||||
long DataStart,
|
||||
long DataLength,
|
||||
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 a
|
||||
/// <see cref="ProcessedAudio"/> store plan (extracted metadata plus a streamed writer for the
|
||||
/// canonical vault bytes). Throws <see cref="ArgumentException"/> for unsupported extensions.
|
||||
/// </summary>
|
||||
public async Task<ProcessedAudio?> ProcessAudioFileAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".wav" => await _wavProcessor.ProcessWavFileAsync(filePath, cancellationToken),
|
||||
".mp3" => await _mp3Processor.ProcessMp3FileAsync(filePath, cancellationToken),
|
||||
".flac" => await _flacProcessor.ProcessFlacFileAsync(filePath, cancellationToken),
|
||||
_ => throw new ArgumentException($"Unsupported audio format: {ext}", nameof(filePath)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded-buffer streaming primitives shared by the audio processors on the store path. None of
|
||||
/// these hold the whole file in memory: copies move a fixed window at a time, and the header read
|
||||
/// caps its allocation regardless of file size.
|
||||
/// </summary>
|
||||
internal static class AudioStoreStream
|
||||
{
|
||||
private const int CopyBufferSize = 81920; // 80 KB — matches the controller staging copy.
|
||||
|
||||
/// <summary>
|
||||
/// Bounded disk-to-disk copy of <paramref name="sourcePath"/> into <paramref name="destination"/>.
|
||||
/// Used for passthrough formats whose stored bytes equal the source bytes. Hand-rolled rather than
|
||||
/// <see cref="Stream.CopyToAsync(Stream)"/> because <c>FileStream</c>'s override writes in 128 KB
|
||||
/// blocks; this keeps every write at or below <see cref="CopyBufferSize"/>, so peak managed memory
|
||||
/// is provably O(buffer), never O(filesize).
|
||||
/// </summary>
|
||||
public static async Task CopyFileAsync(string sourcePath, Stream destination, CancellationToken ct)
|
||||
{
|
||||
await using var src = new FileStream(
|
||||
sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
bufferSize: CopyBufferSize, useAsync: true);
|
||||
|
||||
var buffer = new byte[CopyBufferSize];
|
||||
int read;
|
||||
while ((read = await src.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await destination.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads at most <paramref name="cap"/> bytes from the start of <paramref name="path"/> — enough
|
||||
/// for header/metadata parsing without loading the (potentially ~GB) body. Bounds the allocation
|
||||
/// at <c>min(cap, fileLength)</c>. Size-based metadata (e.g. average bitrate) must use the true
|
||||
/// file length, supplied separately, not the prefix length.
|
||||
/// </summary>
|
||||
public static async Task<byte[]> ReadPrefixAsync(string path, long cap, CancellationToken ct)
|
||||
{
|
||||
await using var fs = new FileStream(
|
||||
path, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
bufferSize: CopyBufferSize, useAsync: true);
|
||||
|
||||
var length = (int)Math.Min(cap, fs.Length);
|
||||
var buffer = new byte[length];
|
||||
var total = 0;
|
||||
while (total < length)
|
||||
{
|
||||
var read = await fs.ReadAsync(buffer.AsMemory(total, length - total), ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
total += read;
|
||||
}
|
||||
|
||||
return total == length ? buffer : buffer[..total];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
// STREAMINFO is mandatory and always the first metadata block, immediately after the 4-byte magic
|
||||
// (data at offset 8, 34 bytes). A small prefix read covers it without loading the body.
|
||||
private const long HeaderCap = 64 * 1024;
|
||||
|
||||
public async Task<ProcessedAudio?> ProcessFlacFileAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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 fileLength = new FileInfo(filePath).Length;
|
||||
var window = await AudioStoreStream.ReadPrefixAsync(filePath, HeaderCap, cancellationToken);
|
||||
var meta = ExtractFlacMetadata(window, fileLength);
|
||||
|
||||
// FLAC is stored unmodified — passthrough the original bytes via a streamed disk-to-disk copy.
|
||||
return ProcessedAudio.Passthrough(filePath, ".flac", meta.Duration, meta.Bitrate, fileLength);
|
||||
}
|
||||
|
||||
/// <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. <paramref name="fileLength"/> is the true
|
||||
/// file size (the header window may be shorter), used for the average-bitrate computation.
|
||||
/// </summary>
|
||||
private static FlacMetadata ExtractFlacMetadata(byte[] buffer, long fileLength)
|
||||
{
|
||||
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)(fileLength * 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,65 @@
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stateful accumulator that reduces the same loudness profile from PCM fed in bounded
|
||||
/// chunks rather than from one contiguous buffer. The streaming waveform path uses this so a long
|
||||
/// track's PCM is never materialized whole in a managed <c>byte[]</c>. The accumulator's output is
|
||||
/// byte-identical to <see cref="Compute"/> for the same total PCM, because <see cref="Compute"/> is
|
||||
/// itself defined in terms of one — the single source of truth for the loudness reduction.
|
||||
/// </summary>
|
||||
/// <param name="pcmByteLength">
|
||||
/// Total length of the PCM data region in bytes. Required up front because the bucket each frame
|
||||
/// lands in is derived from the frame's position relative to the total frame count.
|
||||
/// </param>
|
||||
/// <param name="channels">Number of interleaved channels; averaged to mono per frame.</param>
|
||||
/// <param name="sampleRate">Samples per second (used for the envelope-smoothing time base).</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>
|
||||
ILoudnessAccumulator CreateAccumulator(
|
||||
long pcmByteLength, int channels, int sampleRate, int bitsPerSample, int bucketCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stateful, single-pass reducer for one loudness profile. Frames are fed via <see cref="Add"/> in
|
||||
/// arbitrary (non-frame-aligned) chunks — a partial frame straddling a chunk boundary is carried
|
||||
/// internally — and <see cref="Finish"/> emits the peak-normalized <c>double[bucketCount]</c>. Not
|
||||
/// thread-safe; feed one stream sequentially. Reusable across the same stream's chunks only, not
|
||||
/// across streams.
|
||||
/// </summary>
|
||||
public interface ILoudnessAccumulator
|
||||
{
|
||||
/// <summary>
|
||||
/// Feeds the next run of PCM bytes (interleaved, little-endian). Need not be frame-aligned; bytes
|
||||
/// that do not complete a frame are retained until the next call. Bytes past the total frame count
|
||||
/// declared at construction are ignored, matching the whole-buffer path's trailing-partial-frame drop.
|
||||
/// </summary>
|
||||
void Add(ReadOnlySpan<byte> pcmChunk);
|
||||
|
||||
/// <summary>
|
||||
/// Finalizes and returns the peak-normalized loudness profile (<c>double[bucketCount]</c>, each in
|
||||
/// [0, 1]). All zeros for silence or a degenerate (no-frame) input. Call once, after the last
|
||||
/// <see cref="Add"/>.
|
||||
/// </summary>
|
||||
double[] Finish();
|
||||
}
|
||||
@@ -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,315 @@
|
||||
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;
|
||||
|
||||
// Metadata lives in the leading ID3v2 tag plus the first MPEG frame. Cap the header read so a
|
||||
// large MP3 is not pulled into memory whole just to read it; a tag larger than this (very large
|
||||
// embedded art) simply falls back to the CBR/default estimate, never an OOM. The body is stored
|
||||
// by streaming the original file, not from this window.
|
||||
private const long HeaderCap = 8 * 1024 * 1024;
|
||||
|
||||
public async Task<ProcessedAudio?> ProcessMp3FileAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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 fileLength = new FileInfo(filePath).Length;
|
||||
var window = await AudioStoreStream.ReadPrefixAsync(filePath, HeaderCap, cancellationToken);
|
||||
var meta = ExtractMp3Metadata(window, fileLength);
|
||||
|
||||
// MP3 is stored unmodified — passthrough the original bytes via a streamed disk-to-disk copy.
|
||||
return ProcessedAudio.Passthrough(filePath, ".mp3", meta.Duration, meta.Bitrate, fileLength);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// <paramref name="fileLength"/> is the true file size (the header window may be shorter), used
|
||||
/// for the CBR duration estimate.
|
||||
/// </summary>
|
||||
private static Mp3Metadata ExtractMp3Metadata(byte[] buffer, long fileLength)
|
||||
{
|
||||
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, fileLength);
|
||||
|
||||
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, long fileLength)
|
||||
{
|
||||
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. Uses the true
|
||||
// file length (not the bounded header window), excluding the ID3v2 tag bytes before frameStart.
|
||||
var bytesPerSecond = header.BitrateKbps * 125;
|
||||
return bytesPerSecond > 0 ? (double)(fileLength - 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,140 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a source audio file (any format the source vault holds — WAV/MP3/FLAC) to Ogg Opus fullband
|
||||
/// 320 kbps by shelling out to FFmpeg (libopus). FFmpeg is chosen over a managed encoder because it
|
||||
/// muxes a correct Ogg container with accurate granule positions across every input format — the page
|
||||
/// structure the seek-index walk depends on — which a raw libopus binding does not provide. The external
|
||||
/// <c>ffmpeg</c> binary is therefore a host runtime prerequisite (flagged in the wave handoff).
|
||||
/// </summary>
|
||||
public sealed class FfmpegOpusEncoder
|
||||
{
|
||||
private readonly OpusTranscodeOptions _options;
|
||||
private readonly ILogger<FfmpegOpusEncoder> _logger;
|
||||
|
||||
public FfmpegOpusEncoder(IOptions<OpusTranscodeOptions> options, ILogger<FfmpegOpusEncoder> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transcodes <paramref name="sourcePath"/> to an Ogg Opus file at <paramref name="destinationPath"/>.
|
||||
/// Returns true on a clean exit with a non-empty output. Returns false (logged) on a non-zero exit,
|
||||
/// a timeout, a missing ffmpeg binary, or any process failure — a transcode failure must never throw
|
||||
/// to the caller (C6); the background worker treats false as "leave the track lossless-only".
|
||||
/// </summary>
|
||||
public async Task<bool> EncodeAsync(string sourcePath, string destinationPath, CancellationToken ct)
|
||||
{
|
||||
var ffmpeg = string.IsNullOrWhiteSpace(_options.FfmpegPath) ? "ffmpeg" : _options.FfmpegPath;
|
||||
|
||||
// -vn drops any cover-art video stream; -map a:0 takes the first audio stream; -ar 48000 forces
|
||||
// fullband (Opus internally resamples to 48 kHz anyway, but stating it keeps granulepos math
|
||||
// unambiguous); libopus VBR at the target bitrate; -f ogg for an explicit Ogg container; -y
|
||||
// overwrites the (pre-created, empty) destination temp file.
|
||||
var args = new[]
|
||||
{
|
||||
"-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
"-i", sourcePath,
|
||||
"-vn", "-map", "a:0",
|
||||
"-c:a", "libopus", "-b:a", $"{_options.BitrateKbps}k",
|
||||
"-ar", "48000",
|
||||
"-f", "ogg",
|
||||
"-y", destinationPath,
|
||||
};
|
||||
|
||||
var psi = new ProcessStartInfo(ffmpeg)
|
||||
{
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
{
|
||||
_logger.LogError("Opus transcode: ffmpeg failed to start ({Ffmpeg}).", ffmpeg);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Most commonly a missing binary (Win32Exception "file not found"). This is the ops
|
||||
// prerequisite failing — log loudly so it is unmistakable in the deploy logs.
|
||||
_logger.LogError(ex,
|
||||
"Opus transcode: could not launch ffmpeg ({Ffmpeg}). Is the ffmpeg binary installed on the host?",
|
||||
ffmpeg);
|
||||
return false;
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
|
||||
|
||||
// Drain stderr concurrently — ffmpeg can block writing diagnostics if the pipe is not read.
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(timeout.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
TryKill(process);
|
||||
await SafeStderr(stderrTask); // observe to avoid unobserved-task warnings
|
||||
throw; // genuine shutdown cancellation — let it propagate
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKill(process);
|
||||
await SafeStderr(stderrTask); // observe to avoid unobserved-task warnings
|
||||
_logger.LogError("Opus transcode: ffmpeg exceeded the {Timeout}s timeout for {Source}.",
|
||||
_options.TimeoutSeconds, sourcePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var stderr = await SafeStderr(stderrTask);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
_logger.LogError("Opus transcode: ffmpeg exited {Code} for {Source}. stderr: {Stderr}",
|
||||
process.ExitCode, sourcePath, stderr);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(destinationPath) || new FileInfo(destinationPath).Length == 0)
|
||||
{
|
||||
_logger.LogError("Opus transcode: ffmpeg exited 0 but produced no output for {Source}.", sourcePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Opus transcode: failed to kill timed-out ffmpeg process.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> SafeStderr(Task<string> stderrTask)
|
||||
{
|
||||
try { return await stderrTask; }
|
||||
catch { return "<stderr unavailable>"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-format constants for the Ogg-Opus derived artifacts. Centralised so the seek-index codec,
|
||||
/// the page walker, and the tests agree on one set of magic numbers.
|
||||
/// </summary>
|
||||
public static class OggOpusConstants
|
||||
{
|
||||
/// <summary>Opus granule positions are always sample counts at 48 kHz, regardless of input rate.</summary>
|
||||
public const double OpusSampleRate = 48000.0;
|
||||
|
||||
/// <summary>One seek-index entry per this many seconds of audio (OQ7 — 0.5 s buckets).</summary>
|
||||
public const double SeekBucketSeconds = 0.5;
|
||||
|
||||
/// <summary>The Ogg page capture pattern "OggS" — every page starts with these four bytes.</summary>
|
||||
public static ReadOnlySpan<byte> CapturePattern => "OggS"u8;
|
||||
|
||||
/// <summary>Magic signature opening an OpusHead identification header packet.</summary>
|
||||
public static ReadOnlySpan<byte> OpusHeadSignature => "OpusHead"u8;
|
||||
|
||||
/// <summary>Magic signature opening an OpusTags comment header packet.</summary>
|
||||
public static ReadOnlySpan<byte> OpusTagsSignature => "OpusTags"u8;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed size of an Ogg page header before the segment table: capture(4) + version(1) +
|
||||
/// header-type(1) + granulepos(8) + serial(4) + sequence(4) + checksum(4) + page-segments(1).
|
||||
/// </summary>
|
||||
public const int OggPageHeaderSize = 27;
|
||||
|
||||
/// <summary>Byte offset of the 64-bit granule position within an Ogg page header.</summary>
|
||||
public const int GranulePositionOffset = 6;
|
||||
|
||||
/// <summary>Byte offset of the page-segment count (the segment-table length) within the header.</summary>
|
||||
public const int PageSegmentCountOffset = 26;
|
||||
|
||||
/// <summary>Sentinel granule position for a page that ends mid-packet (no usable timestamp).</summary>
|
||||
public const ulong NoGranulePosition = 0xFFFFFFFFFFFFFFFFUL;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum byte length of an <c>OpusHead</c> packet payload to safely read <c>pre_skip</c>.
|
||||
/// RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) + pre_skip(2) = 12 bytes minimum.
|
||||
/// </summary>
|
||||
public const int OpusHeadMinSize = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Byte offset of <c>pre_skip</c> within the full <c>OpusHead</c> packet payload (including the
|
||||
/// magic). RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) = 10 bytes before pre_skip.
|
||||
/// </summary>
|
||||
public const int OpusHeadPreSkipOffset = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Header size of the serialized seek-index blob:
|
||||
/// totalBytes(8) + duration(8) + count(4) + preSkip(2) + reserved(2) = 24 bytes.
|
||||
/// </summary>
|
||||
public const int SeekIndexHeaderSize = 24;
|
||||
|
||||
/// <summary>Size of one serialized seek point: granulepos(8) + byteOffset(8).</summary>
|
||||
public const int SeekPointSize = 16;
|
||||
|
||||
/// <summary>Vault-resource extension for the Opus audio bytes.</summary>
|
||||
public const string OpusExtension = ".opus";
|
||||
|
||||
/// <summary>Vault-resource extension for the combined setup-header + seek-index sidecar.</summary>
|
||||
public const string SidecarExtension = ".opusidx";
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// The result of walking an encoded Ogg Opus stream once: the captured setup header (the leading
|
||||
/// <c>OpusHead</c> + <c>OpusTags</c> pages, verbatim) and the bucketed granule→byte seek index. This
|
||||
/// is everything the sidecar artifact carries (§3.4a) — built at transcode time so delivery never
|
||||
/// re-walks the stream.
|
||||
/// </summary>
|
||||
/// <param name="SetupHeaderBytes">The leading setup pages (OpusHead + OpusTags), exactly as they
|
||||
/// appear at the start of the stream, ready to prepend to any mid-stream page run before decode.</param>
|
||||
/// <param name="SeekIndex">The accurate, 0.5 s-bucketed granule→byte transfer function.</param>
|
||||
public sealed record OggOpusWalk(byte[] SetupHeaderBytes, OggOpusSeekIndex SeekIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Pure Ogg-Opus stream walker. Reads the page structure directly (the <c>OggS</c> capture pattern and
|
||||
/// the 27-byte page header) to (1) capture the setup-header pages and (2) record, for every audio page,
|
||||
/// its end granule position and exact byte offset — bucketed to 0.5 s with each bucket boundary snapped
|
||||
/// to the nearest enclosing page start. No external dependency: the encoder (FFmpeg) produces the bytes;
|
||||
/// this turns them into the seek artifact deterministically, so it is unit-testable without a codec.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two entry points share one <see cref="WalkState"/> page-processing core, so they produce byte-identical
|
||||
/// output by construction (the project's parity-oracle convention, mirroring
|
||||
/// <c>RmsLoudnessAlgorithm.Compute</c> over its accumulator):
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Walk(ReadOnlySpan{byte})"/> — the whole-buffer overload, retained as the byte-identity
|
||||
/// parity oracle for the streaming variant.</item>
|
||||
/// <item><see cref="WalkAsync(System.IO.Stream,System.Threading.CancellationToken)"/> — the streaming
|
||||
/// variant: walks the page structure from a forward stream in a bounded read buffer (one Ogg page at a
|
||||
/// time), so peak managed memory is O(buffer + seek-index + setup-header), independent of file size.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class OggOpusParser
|
||||
{
|
||||
/// <summary>
|
||||
/// The largest a single Ogg page can be: header(27) + a full 255-entry segment table + the maximum
|
||||
/// payload those segments can describe (255 × 255 bytes). The streaming read buffer is floored at this
|
||||
/// so a complete page always fits, which means a short read on a page can only mean a truncated stream.
|
||||
/// </summary>
|
||||
private const int MaxOggPageSize = OggOpusConstants.OggPageHeaderSize + 255 + 255 * 255; // 65307
|
||||
|
||||
/// <summary>
|
||||
/// Walks <paramref name="oggBytes"/> and produces the setup header + seek index, or null if the
|
||||
/// bytes are not a recognisable Ogg Opus stream (no setup header, no audio pages, or truncated
|
||||
/// structure). A null is the caller's signal to treat the transcode as failed and leave the track
|
||||
/// lossless-only (C6) — it does not throw for malformed input.
|
||||
/// </summary>
|
||||
public static OggOpusWalk? Walk(ReadOnlySpan<byte> oggBytes)
|
||||
{
|
||||
var state = new WalkState();
|
||||
|
||||
var offset = 0;
|
||||
while (offset + OggOpusConstants.OggPageHeaderSize <= oggBytes.Length)
|
||||
{
|
||||
var page = oggBytes.Slice(offset);
|
||||
if (!page[..4].SequenceEqual(OggOpusConstants.CapturePattern))
|
||||
{
|
||||
// Not on a page boundary — the encoder writes contiguous pages, so this means the
|
||||
// stream is malformed or we mis-stepped. Either way it is unrecoverable here.
|
||||
return null;
|
||||
}
|
||||
|
||||
var segmentCount = page[OggOpusConstants.PageSegmentCountOffset];
|
||||
var segmentTableEnd = OggOpusConstants.OggPageHeaderSize + segmentCount;
|
||||
if (segmentTableEnd > page.Length)
|
||||
return null; // truncated header
|
||||
|
||||
var payloadSize = 0;
|
||||
for (var i = 0; i < segmentCount; i++)
|
||||
payloadSize += page[OggOpusConstants.OggPageHeaderSize + i];
|
||||
|
||||
var pageTotalSize = segmentTableEnd + payloadSize;
|
||||
if (pageTotalSize > page.Length)
|
||||
return null; // truncated payload
|
||||
|
||||
var payload = page.Slice(segmentTableEnd, payloadSize);
|
||||
var granule = BinaryPrimitives.ReadUInt64LittleEndian(
|
||||
page.Slice(OggOpusConstants.GranulePositionOffset, 8));
|
||||
|
||||
state.AddPage(granule, payload, page.Slice(0, pageTotalSize), offset);
|
||||
|
||||
offset += pageTotalSize;
|
||||
}
|
||||
|
||||
return state.Finish((ulong)oggBytes.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streaming counterpart of <see cref="Walk(ReadOnlySpan{byte})"/>: walks the Ogg page structure from
|
||||
/// a forward <paramref name="stream"/> in a bounded read buffer (one Ogg page at a time), producing a
|
||||
/// byte-identical <see cref="OggOpusWalk"/> without ever holding the whole encoded file in memory.
|
||||
/// Returns null on the same malformed/truncated conditions as the buffer overload — it does not throw
|
||||
/// for bad input (only <see cref="OperationCanceledException"/> propagates on cancellation).
|
||||
/// </summary>
|
||||
public static Task<OggOpusWalk?> WalkAsync(Stream stream, CancellationToken cancellationToken = default)
|
||||
=> WalkAsync(stream, MaxOggPageSize, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Buffer-size-parameterised core. <paramref name="bufferSize"/> is floored at
|
||||
/// <see cref="MaxOggPageSize"/> so any single page always fits in the buffer; the absolute byte
|
||||
/// cursor (<c>absoluteOffset</c>) advances as the window compacts, so recorded seek offsets stay
|
||||
/// absolute even though the buffer holds only a small window at any instant.
|
||||
/// </summary>
|
||||
internal static async Task<OggOpusWalk?> WalkAsync(Stream stream, int bufferSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = new WalkState();
|
||||
var buffer = new byte[Math.Max(bufferSize, MaxOggPageSize)];
|
||||
var len = 0; // valid bytes held at buffer[0..len]
|
||||
long absoluteOffset = 0; // absolute stream position of buffer[0]
|
||||
|
||||
while (true)
|
||||
{
|
||||
// The buffer overload's loop guard requires a full fixed header before parsing a page; once
|
||||
// fewer than that remain (and the stream is drained) it is the natural end of the stream.
|
||||
if (len < OggOpusConstants.OggPageHeaderSize)
|
||||
{
|
||||
len += await FillAsync(stream, buffer, len, cancellationToken);
|
||||
if (len < OggOpusConstants.OggPageHeaderSize)
|
||||
break;
|
||||
}
|
||||
|
||||
if (!buffer.AsSpan(0, 4).SequenceEqual(OggOpusConstants.CapturePattern))
|
||||
return null;
|
||||
|
||||
var segmentCount = buffer[OggOpusConstants.PageSegmentCountOffset];
|
||||
var segmentTableEnd = OggOpusConstants.OggPageHeaderSize + segmentCount;
|
||||
if (len < segmentTableEnd)
|
||||
{
|
||||
len += await FillAsync(stream, buffer, len, cancellationToken);
|
||||
if (len < segmentTableEnd)
|
||||
return null; // truncated header
|
||||
}
|
||||
|
||||
var payloadSize = 0;
|
||||
for (var i = 0; i < segmentCount; i++)
|
||||
payloadSize += buffer[OggOpusConstants.OggPageHeaderSize + i];
|
||||
|
||||
var pageTotalSize = segmentTableEnd + payloadSize;
|
||||
if (len < pageTotalSize)
|
||||
{
|
||||
len += await FillAsync(stream, buffer, len, cancellationToken);
|
||||
if (len < pageTotalSize)
|
||||
return null; // truncated payload (page never fully arrived before EOF)
|
||||
}
|
||||
|
||||
var page = buffer.AsSpan(0, pageTotalSize);
|
||||
var granule = BinaryPrimitives.ReadUInt64LittleEndian(
|
||||
page.Slice(OggOpusConstants.GranulePositionOffset, 8));
|
||||
var payload = page.Slice(segmentTableEnd, payloadSize);
|
||||
|
||||
state.AddPage(granule, payload, page, absoluteOffset);
|
||||
|
||||
// Compact the consumed page off the front of the window; the absolute cursor advances by the
|
||||
// exact page size so every offset the state records remains an absolute stream position.
|
||||
var remaining = len - pageTotalSize;
|
||||
if (remaining > 0)
|
||||
Buffer.BlockCopy(buffer, pageTotalSize, buffer, 0, remaining);
|
||||
len = remaining;
|
||||
absoluteOffset += pageTotalSize;
|
||||
}
|
||||
|
||||
return state.Finish((ulong)absoluteOffset + (ulong)len);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills <paramref name="buffer"/> from <paramref name="offset"/> to its end, issuing as many reads as
|
||||
/// needed until the buffer is full or the stream is exhausted. Returns the number of bytes added.
|
||||
/// </summary>
|
||||
private static async Task<int> FillAsync(Stream stream, byte[] buffer, int offset, CancellationToken ct)
|
||||
{
|
||||
var added = 0;
|
||||
while (offset + added < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(offset + added, buffer.Length - offset - added), ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
added += read;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
private static bool StartsWith(ReadOnlySpan<byte> payload, ReadOnlySpan<byte> signature) =>
|
||||
payload.Length >= signature.Length && payload[..signature.Length].SequenceEqual(signature);
|
||||
|
||||
/// <summary>
|
||||
/// The single page-processing core both <see cref="Walk(ReadOnlySpan{byte})"/> and
|
||||
/// <see cref="WalkAsync(Stream,int,CancellationToken)"/> drive, page by page, in stream order. Holding
|
||||
/// the setup-header + seek-index accumulation here is what makes the two entry points byte-identical
|
||||
/// by construction: there is exactly one copy of the OpusHead/OpusTags detection, pre-skip correction,
|
||||
/// t=0 anchoring, and 0.5 s bucketing logic.
|
||||
/// </summary>
|
||||
private sealed class WalkState
|
||||
{
|
||||
// The real setup (OpusHead + OpusTags pages) is a few KB; this cap bounds the streaming capture so
|
||||
// a malformed head-without-tags stream cannot grow it unboundedly. A stream that exceeds it has no
|
||||
// OpusTags within the cap, so no audio points are ever recorded and Finish returns null either way
|
||||
// — the cap never changes the output of a stream that produces a non-null result.
|
||||
private const int MaxSetupHeaderBytes = 8 * 1024 * 1024;
|
||||
|
||||
private bool _sawOpusHead;
|
||||
private bool _sawOpusTags;
|
||||
private ushort _preSkip;
|
||||
private int _setupHeaderEnd = -1;
|
||||
|
||||
private bool _capturingSetup = true;
|
||||
private readonly List<byte> _setupHeader = new();
|
||||
|
||||
private readonly List<OpusSeekPoint> _points = new();
|
||||
private ulong _lastGranule;
|
||||
private double _nextBucketTime;
|
||||
private bool _firstAudioPointTaken;
|
||||
|
||||
/// <summary>
|
||||
/// Processes one fully-framed page. <paramref name="pageBytes"/> is the whole page (header +
|
||||
/// segment table + payload) for verbatim setup capture; <paramref name="absoluteOffset"/> is the
|
||||
/// page's absolute start in the stream — the value recorded in the seek index.
|
||||
/// </summary>
|
||||
public void AddPage(ulong granule, ReadOnlySpan<byte> payload, ReadOnlySpan<byte> pageBytes, long absoluteOffset)
|
||||
{
|
||||
if (_capturingSetup)
|
||||
{
|
||||
if (_setupHeader.Count + pageBytes.Length > MaxSetupHeaderBytes)
|
||||
_capturingSetup = false; // malformed: give up capture (result will be null without tags)
|
||||
else
|
||||
_setupHeader.AddRange(pageBytes);
|
||||
}
|
||||
|
||||
// The setup pages carry no audio granule (OpusHead has granulepos 0; OpusTags too). They
|
||||
// are the leading pages whose payload opens with the Opus magic signatures.
|
||||
if (!_sawOpusHead && StartsWith(payload, OggOpusConstants.OpusHeadSignature))
|
||||
{
|
||||
_sawOpusHead = true;
|
||||
_setupHeaderEnd = (int)(absoluteOffset + pageBytes.Length);
|
||||
|
||||
// RFC 7845 §5.1 — OpusHead layout after the 8-byte "OpusHead" magic:
|
||||
// [0] version (1 byte), [1] channel count (1 byte),
|
||||
// [2-3] pre_skip (little-endian uint16) ← at packet bytes 10-11
|
||||
// pre_skip is the number of decoder samples to discard before presenting audio;
|
||||
// all granule→time conversions must subtract it (RFC 7845 §4.3).
|
||||
if (payload.Length >= OggOpusConstants.OpusHeadMinSize)
|
||||
_preSkip = BinaryPrimitives.ReadUInt16LittleEndian(
|
||||
payload.Slice(OggOpusConstants.OpusHeadPreSkipOffset, 2));
|
||||
}
|
||||
else if (_sawOpusHead && !_sawOpusTags && StartsWith(payload, OggOpusConstants.OpusTagsSignature))
|
||||
{
|
||||
_sawOpusTags = true;
|
||||
_setupHeaderEnd = (int)(absoluteOffset + pageBytes.Length);
|
||||
// The setup header ends at the OpusTags page; stop capturing so audio pages never grow it.
|
||||
_capturingSetup = false;
|
||||
}
|
||||
else if (_sawOpusHead && _sawOpusTags)
|
||||
{
|
||||
// Audio page. Record the first audio page unconditionally (the seek anchor at t=0),
|
||||
// then one entry per 0.5 s bucket. A page with no end-granule (mid-packet continuation,
|
||||
// granulepos == -1) is skipped for indexing — its time is unknown — but still advances
|
||||
// the byte cursor.
|
||||
if (granule != OggOpusConstants.NoGranulePosition)
|
||||
{
|
||||
// RFC 7845 §4.3: presentation time = max(0, granule − preSkip) / 48000.
|
||||
// Use this corrected time for bucketing so that a stream with pre-skip 3840 (~80 ms)
|
||||
// does not systematically offset every indexed time by that amount.
|
||||
var correctedTime = Math.Max(0.0,
|
||||
(granule - (double)_preSkip) / OggOpusConstants.OpusSampleRate);
|
||||
|
||||
if (!_firstAudioPointTaken)
|
||||
{
|
||||
// Anchor the first seek point at corrected time = 0 by storing the granule as
|
||||
// preSkip. This guarantees that a binary search for t=0 ("largest entry with
|
||||
// corrected time ≤ 0") always resolves to the first audio page's byte offset —
|
||||
// even when the real granule is slightly above preSkip due to encoder lead-in.
|
||||
_points.Add(new OpusSeekPoint(_preSkip, (ulong)absoluteOffset));
|
||||
_firstAudioPointTaken = true;
|
||||
_nextBucketTime = OggOpusConstants.SeekBucketSeconds;
|
||||
}
|
||||
else if (correctedTime >= _nextBucketTime)
|
||||
{
|
||||
_points.Add(new OpusSeekPoint(granule, (ulong)absoluteOffset));
|
||||
// Advance past every bucket this page crossed so a long page does not emit a
|
||||
// backlog of entries; the next bucket is the first boundary strictly after it.
|
||||
while (_nextBucketTime <= correctedTime)
|
||||
_nextBucketTime += OggOpusConstants.SeekBucketSeconds;
|
||||
}
|
||||
|
||||
_lastGranule = granule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces the final walk, or null on the same conditions the buffer overload rejected:
|
||||
/// no OpusHead, no captured setup header, or no audio seek points. <paramref name="totalByteLength"/>
|
||||
/// is the full stream length, recorded for end-of-stream seek clamping.
|
||||
/// </summary>
|
||||
public OggOpusWalk? Finish(ulong totalByteLength)
|
||||
{
|
||||
if (!_sawOpusHead || _setupHeaderEnd < 0 || _points.Count == 0)
|
||||
return null;
|
||||
|
||||
var setupHeader = _setupHeader.ToArray();
|
||||
// RFC 7845 §4.3: total duration is also pre-skip-corrected, matching the time a listener
|
||||
// experiences (the last audio page's corrected time, clamped to ≥ 0).
|
||||
var totalDuration = Math.Max(0.0,
|
||||
(_lastGranule - (double)_preSkip) / OggOpusConstants.OpusSampleRate);
|
||||
var index = new OggOpusSeekIndex(_points, totalDuration, totalByteLength, _preSkip);
|
||||
return new OggOpusWalk(setupHeader, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// A single seek-index entry: an authoritative 48 kHz <see cref="GranulePosition"/> (Opus granule
|
||||
/// positions are always sample counts at 48 kHz) paired with the exact byte offset of the Ogg page that
|
||||
/// carries it. Every <see cref="ByteOffset"/> is a real page-start boundary, so a
|
||||
/// <c>Range: bytes={ByteOffset}-</c> fetch lands the decoder Ogg-sync-aligned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per RFC 7845 §4.3, the PCM presentation time is <c>(granulepos − preSkip) / 48000</c>. The raw
|
||||
/// <see cref="GranulePosition"/> is stored here as-is; callers should subtract the containing
|
||||
/// <see cref="OggOpusSeekIndex.PreSkip"/> before converting to a presentation time. Use
|
||||
/// <see cref="OggOpusSeekIndex.PresentationTimeSeconds"/> for the corrected value.
|
||||
/// </remarks>
|
||||
/// <param name="GranulePosition">The page's end granule position (48 kHz sample count).</param>
|
||||
/// <param name="ByteOffset">The byte offset of the page start in the Opus file.</param>
|
||||
public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOffset)
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw granule-position-to-time conversion (granulepos / 48 kHz). Does NOT subtract pre-skip — use
|
||||
/// <see cref="OggOpusSeekIndex.PresentationTimeSeconds"/> for the RFC 7845-correct presentation time.
|
||||
/// </summary>
|
||||
public double RawTimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The accurate, precomputed transfer function from seek-time to true file byte offset for one Ogg
|
||||
/// Opus stream (§3.4a A). Built once at transcode time by walking the encoded stream; the client reads
|
||||
/// it back and binary-searches <see cref="Points"/> instead of doing inaccurate VBR byte-rate math.
|
||||
/// One entry per 0.5 s of audio (<see cref="OggOpusConstants.SeekBucketSeconds"/>), each snapped to the
|
||||
/// nearest enclosing page start, plus the totals needed to clamp a seek to range.
|
||||
/// </summary>
|
||||
/// <param name="Points">Ordered (granulepos, byteOffset) entries, ascending. The first entry always
|
||||
/// has <see cref="OpusSeekPoint.GranulePosition"/> == <paramref name="PreSkip"/> (corrected time = 0)
|
||||
/// and points at the first audio page start, ensuring a seek to t=0 always resolves.</param>
|
||||
/// <param name="TotalDurationSeconds">
|
||||
/// Pre-skip-corrected total stream duration: <c>max(0, lastGranule − preSkip) / 48000</c>.
|
||||
/// </param>
|
||||
/// <param name="TotalByteLength">Total Opus file byte length, for clamping a seek past the end.</param>
|
||||
/// <param name="PreSkip">
|
||||
/// The <c>pre_skip</c> value from the <c>OpusHead</c> identification header (RFC 7845 §5.1). Opus
|
||||
/// decoders must discard this many samples from the decoded start before presenting audio. The client
|
||||
/// (wave 18.4) needs this to trim the first decoded buffer; storing it here avoids a re-parse of the
|
||||
/// Ogg stream at delivery time.
|
||||
/// </param>
|
||||
public sealed record OggOpusSeekIndex(
|
||||
IReadOnlyList<OpusSeekPoint> Points,
|
||||
double TotalDurationSeconds,
|
||||
ulong TotalByteLength,
|
||||
ushort PreSkip)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the RFC 7845-correct presentation time for a seek point: <c>max(0, granule − preSkip) / 48000</c>.
|
||||
/// Use this for all time comparisons; raw <see cref="OpusSeekPoint.RawTimeSeconds"/> omits the pre-skip.
|
||||
/// </summary>
|
||||
public double PresentationTimeSeconds(OpusSeekPoint point) =>
|
||||
Math.Max(0.0, (point.GranulePosition - (double)PreSkip) / OggOpusConstants.OpusSampleRate);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the index to the compact little-endian binary blob the sidecar stores. Layout:
|
||||
/// <c>[uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount][uint16 preSkip][uint16 reserved]</c>
|
||||
/// then <c>pointCount × (uint64 granulepos, uint64 byteOffset)</c>. The four-byte preSkip+reserved
|
||||
/// region pads the header to 24 bytes, keeping the point table 8-byte-aligned.
|
||||
/// Fixed-width records keep the client parse to a single typed-array read.
|
||||
/// </summary>
|
||||
public byte[] ToBytes()
|
||||
{
|
||||
var size = OggOpusConstants.SeekIndexHeaderSize + Points.Count * OggOpusConstants.SeekPointSize;
|
||||
var bytes = new byte[size];
|
||||
var span = bytes.AsSpan();
|
||||
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(span[..8], TotalByteLength);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(8, 8), TotalDurationSeconds);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16, 4), (uint)Points.Count);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(20, 2), PreSkip);
|
||||
// bytes 22-23: reserved (zero-initialized by array allocation)
|
||||
|
||||
var cursor = OggOpusConstants.SeekIndexHeaderSize;
|
||||
foreach (var point in Points)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(span.Slice(cursor, 8), point.GranulePosition);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(span.Slice(cursor + 8, 8), point.ByteOffset);
|
||||
cursor += OggOpusConstants.SeekPointSize;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a blob produced by <see cref="ToBytes"/>. Returns null if the blob is too short or its
|
||||
/// declared point count does not fit — the storage contract is exact, so a malformed blob is a
|
||||
/// corruption signal, not a recoverable shape. (Provided so tests and any future server-side reader
|
||||
/// share one codec with the writer.)
|
||||
/// </summary>
|
||||
public static OggOpusSeekIndex? FromBytes(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < OggOpusConstants.SeekIndexHeaderSize)
|
||||
return null;
|
||||
|
||||
var totalByteLength = BinaryPrimitives.ReadUInt64LittleEndian(bytes[..8]);
|
||||
var totalDuration = BinaryPrimitives.ReadDoubleLittleEndian(bytes.Slice(8, 8));
|
||||
var count = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(16, 4));
|
||||
var preSkip = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(20, 2));
|
||||
// bytes 22-23: reserved — ignored on read for forward-compatibility
|
||||
|
||||
var expected = OggOpusConstants.SeekIndexHeaderSize + (long)count * OggOpusConstants.SeekPointSize;
|
||||
if (bytes.Length < expected)
|
||||
return null;
|
||||
|
||||
var points = new OpusSeekPoint[count];
|
||||
var cursor = OggOpusConstants.SeekIndexHeaderSize;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var granule = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(cursor, 8));
|
||||
var offset = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(cursor + 8, 8));
|
||||
points[i] = new OpusSeekPoint(granule, offset);
|
||||
cursor += OggOpusConstants.SeekPointSize;
|
||||
}
|
||||
|
||||
return new OggOpusSeekIndex(points, totalDuration, totalByteLength, preSkip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// The single derived sidecar artifact per track (§3.4a B, recommended design): the Opus setup header
|
||||
/// (<c>OpusHead</c> + <c>OpusTags</c>) followed by the granule→byte seek index. The client fetches this
|
||||
/// once on track load and parses it into its <c>OpusSeekData</c>, so it always has both the setup bytes
|
||||
/// (to prepend to any mid-stream slice) and the accurate seek transfer function before it ever issues a
|
||||
/// Range fetch — including a window that opens away from byte 0 (UC9).
|
||||
/// </summary>
|
||||
/// <param name="SetupHeaderBytes">The verbatim OpusHead + OpusTags pages.</param>
|
||||
/// <param name="SeekIndex">The bucketed granule→byte seek index.</param>
|
||||
public sealed record OpusSidecar(byte[] SetupHeaderBytes, OggOpusSeekIndex SeekIndex)
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes to <c>[uint32 setupHeaderLength][setup-header bytes][seek-index blob]</c>. The
|
||||
/// length prefix lets the client split the two regions with one read; the seek-index blob carries
|
||||
/// its own self-describing header (<see cref="OggOpusSeekIndex.ToBytes"/>), so it needs no trailing
|
||||
/// length.
|
||||
/// </summary>
|
||||
public byte[] ToBytes()
|
||||
{
|
||||
var indexBytes = SeekIndex.ToBytes();
|
||||
var bytes = new byte[4 + SetupHeaderBytes.Length + indexBytes.Length];
|
||||
var span = bytes.AsSpan();
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span[..4], (uint)SetupHeaderBytes.Length);
|
||||
SetupHeaderBytes.CopyTo(span.Slice(4));
|
||||
indexBytes.CopyTo(span.Slice(4 + SetupHeaderBytes.Length));
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a blob produced by <see cref="ToBytes"/>. Returns null on any structural inconsistency
|
||||
/// (short blob, length prefix that overruns, or an unparseable index) — the format is exact, so a
|
||||
/// malformed blob is corruption.
|
||||
/// </summary>
|
||||
public static OpusSidecar? FromBytes(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 4)
|
||||
return null;
|
||||
|
||||
var setupLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes[..4]);
|
||||
var indexStart = 4 + (long)setupLength;
|
||||
if (bytes.Length < indexStart)
|
||||
return null;
|
||||
|
||||
var setupHeader = bytes.Slice(4, (int)setupLength).ToArray();
|
||||
var index = OggOpusSeekIndex.FromBytes(bytes.Slice((int)indexStart));
|
||||
if (index is null)
|
||||
return null;
|
||||
|
||||
return new OpusSidecar(setupHeader, index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// Host-supplied configuration for the Opus transcode. The only operationally significant knob is
|
||||
/// <see cref="FfmpegPath"/> — the transcode shells out to FFmpeg (libopus), which must be present on the
|
||||
/// DeepDrftAPI host (see the wave handoff notes). Defaults target Ogg Opus fullband (48 kHz) at 320 kbps,
|
||||
/// the artifact the spec fixes (§1).
|
||||
/// </summary>
|
||||
public sealed class OpusTranscodeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the ffmpeg executable. Empty/null resolves to <c>"ffmpeg"</c> (found on PATH). Override
|
||||
/// with an absolute path when the binary is not on the host PATH.
|
||||
/// </summary>
|
||||
public string FfmpegPath { get; set; } = "ffmpeg";
|
||||
|
||||
/// <summary>Target Opus bitrate in kbps. 320 kbps fullband is the fixed artifact quality (§1).</summary>
|
||||
public int BitrateKbps { get; set; } = 320;
|
||||
|
||||
/// <summary>
|
||||
/// Directory for the transient source/output files the transcode stages. Defaults to the system
|
||||
/// temp path; the host overrides it to the data-disk upload-staging directory so large files never
|
||||
/// land on the small RAM-backed <c>/tmp</c> tmpfs (same constraint the upload path already honours).
|
||||
/// </summary>
|
||||
public string StagingPath { get; set; } = Path.GetTempPath();
|
||||
|
||||
/// <summary>
|
||||
/// Hard ceiling on a single transcode, in seconds. A run that exceeds it is killed and the track
|
||||
/// stays lossless-only (C6). Generous by default — a 1 GB mix is CPU-expensive (§3.1) — but bounded
|
||||
/// so a hung ffmpeg never wedges the background worker.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 3600;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// Derives and persists a track's low-data Ogg Opus artifacts (Phase 18.1). Mirrors
|
||||
/// <see cref="WaveformProfileService"/>'s derived-artifact lifecycle: compute from the stored source,
|
||||
/// store in a dedicated vault keyed by <c>EntryKey</c>, regenerable, failure-tolerant. For one track it
|
||||
/// produces two entries in the <see cref="VaultConstants.TrackOpus"/> vault — the Opus audio bytes and a
|
||||
/// combined setup-header + seek-index sidecar (§3.4a). Strictly additive: the source <c>tracks</c> vault
|
||||
/// is never touched, and a failure here leaves the track lossless-only and eligible for backfill (C2/C6).
|
||||
/// </summary>
|
||||
public sealed class OpusTranscodeService
|
||||
{
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly TrackContentService _trackContent;
|
||||
private readonly FfmpegOpusEncoder _encoder;
|
||||
private readonly OpusTranscodeOptions _options;
|
||||
private readonly ILogger<OpusTranscodeService> _logger;
|
||||
|
||||
public OpusTranscodeService(
|
||||
FileDb fileDatabase,
|
||||
TrackContentService trackContent,
|
||||
FfmpegOpusEncoder encoder,
|
||||
IOptions<OpusTranscodeOptions> options,
|
||||
ILogger<OpusTranscodeService> logger)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_trackContent = trackContent;
|
||||
_encoder = encoder;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the source audio for <paramref name="entryKey"/> from the <c>tracks</c> vault, transcodes it
|
||||
/// to Ogg Opus 320, walks the encoded stream to build the seek index + capture the setup header, and
|
||||
/// stores the Opus bytes and the sidecar in the <see cref="VaultConstants.TrackOpus"/> vault under the
|
||||
/// same key. Re-runnable — a second call overwrites the prior artifacts (backfill / replace-audio).
|
||||
/// Returns false (logged) on any failure; never throws for expected failure modes (C6). The only
|
||||
/// propagated exception is <see cref="OperationCanceledException"/> on genuine shutdown.
|
||||
/// </summary>
|
||||
public async Task<bool> TranscodeAndStoreAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
// Read the source extension + duration from the vault index (no body load) and open a streamed
|
||||
// read over the source bytes — never the whole-buffer AudioBinary. A 92-min mix source is ~970 MB;
|
||||
// buffering it (and the encoded output below) was the last unconverted store-path OOM violation.
|
||||
var trackDuration = await _trackContent.GetAudioDurationAsync(entryKey) ?? 0.0;
|
||||
var sourceMedia = await _trackContent.OpenAudioMediaStreamAsync(entryKey);
|
||||
if (sourceMedia is null)
|
||||
{
|
||||
_logger.LogWarning("Opus transcode: no source audio in vault for {EntryKey}; skipping.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
string? sourcePath = null;
|
||||
string? opusPath = null;
|
||||
try
|
||||
{
|
||||
// Stage the source to disk in bounded chunks so ffmpeg can read it by file path/extension.
|
||||
// The inner finally disposes the source stream as soon as the copy is done — the read handle
|
||||
// is not held across the (long) encode — and guarantees disposal even if staging setup throws.
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_options.StagingPath);
|
||||
sourcePath = Path.Combine(_options.StagingPath, $"opus-src-{Guid.NewGuid():N}{sourceMedia.Extension}");
|
||||
opusPath = Path.Combine(_options.StagingPath, $"opus-out-{Guid.NewGuid():N}{OggOpusConstants.OpusExtension}");
|
||||
|
||||
await using var staging = new FileStream(
|
||||
sourcePath, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true);
|
||||
await sourceMedia.Stream.CopyToAsync(staging, bufferSize: 81920, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await sourceMedia.DisposeAsync();
|
||||
}
|
||||
|
||||
if (!await _encoder.EncodeAsync(sourcePath, opusPath, ct))
|
||||
return false; // encoder already logged the cause
|
||||
|
||||
// Walk the encoded output from a streamed read in a bounded buffer (no whole-file load). The
|
||||
// seek index and setup header are byte-identical to the buffer walk (parity-tested).
|
||||
OggOpusWalk? walk;
|
||||
await using (var opusIn = new FileStream(
|
||||
opusPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
{
|
||||
walk = await OggOpusParser.WalkAsync(opusIn, ct);
|
||||
}
|
||||
if (walk is null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Opus transcode: ffmpeg produced output but the Ogg stream could not be walked for {EntryKey}; " +
|
||||
"no artifacts stored.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
await EnsureVaultAsync();
|
||||
|
||||
// Bitrate from the output file length + duration — both available without buffering the bytes.
|
||||
var opusLength = new FileInfo(opusPath).Length;
|
||||
var opusBitrate = trackDuration > 0
|
||||
? (int)(opusLength * 8 / trackDuration / 1000)
|
||||
: _options.BitrateKbps;
|
||||
|
||||
// Store the audio first, then the sidecar. If the sidecar write fails the Opus bytes are
|
||||
// present but unseekable — treat that as a failed derive (return false) so a backfill re-runs
|
||||
// it; do not leave a half-derived track that the delivery layer would treat as complete.
|
||||
var audioMeta = MetaDataFactory.CreateAudioMetaData(
|
||||
OpusAudioKey(entryKey), OggOpusConstants.OpusExtension, trackDuration, opusBitrate);
|
||||
var stagedOpusPath = opusPath;
|
||||
var audioStored = await _fileDatabase.RegisterResourceStreamingAsync(
|
||||
VaultConstants.TrackOpus, OpusAudioKey(entryKey), audioMeta,
|
||||
(destination, token) => AudioStoreStream.CopyFileAsync(stagedOpusPath, destination, token), ct);
|
||||
if (!audioStored)
|
||||
{
|
||||
_logger.LogError("Opus transcode: vault write of Opus audio failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The sidecar is the setup header (a few KB) plus the seek index (~16 bytes per 0.5 s bucket);
|
||||
// it is inherently bounded and already in managed memory, so the whole-buffer write is correct.
|
||||
var sidecar = new OpusSidecar(walk.SetupHeaderBytes, walk.SeekIndex).ToBytes();
|
||||
var sidecarBinary = new MediaBinary(new MediaBinaryParams(
|
||||
sidecar, sidecar.Length, OggOpusConstants.SidecarExtension));
|
||||
var sidecarStored = await _fileDatabase.RegisterResourceAsync(
|
||||
VaultConstants.TrackOpus, OpusSidecarKey(entryKey), sidecarBinary);
|
||||
if (!sidecarStored)
|
||||
{
|
||||
_logger.LogError("Opus transcode: vault write of sidecar failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Opus transcode complete for {EntryKey}: {OpusBytes} bytes, {Points} seek points, {Duration:F1}s.",
|
||||
entryKey, opusLength, walk.SeekIndex.Points.Count, walk.SeekIndex.TotalDurationSeconds);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Opus transcode failed for {EntryKey}; track stays lossless-only.", entryKey);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (sourcePath is not null)
|
||||
TryDelete(sourcePath);
|
||||
if (opusPath is not null)
|
||||
TryDelete(opusPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The vault entry key under which a track's Opus audio bytes are stored.</summary>
|
||||
public static string OpusAudioKey(string entryKey) => entryKey;
|
||||
|
||||
/// <summary>The vault entry key under which a track's setup-header + seek-index sidecar is stored.</summary>
|
||||
public static string OpusSidecarKey(string entryKey) => $"{entryKey}-sidecar";
|
||||
|
||||
private async Task EnsureVaultAsync()
|
||||
{
|
||||
// The TrackOpus vault is created at host startup (Startup.cs), so this guard is normally a
|
||||
// no-op for the upload path. It is retained for the backfill path, which may run via a
|
||||
// standalone CLI or a host that skips vault pre-creation, where the vault might not exist.
|
||||
if (!_fileDatabase.HasVault(VaultConstants.TrackOpus))
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
|
||||
}
|
||||
|
||||
private void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Opus transcode: failed to delete staging file {Path}.", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of resolving a track + requested <see cref="AudioFormat"/> to a concrete artifact
|
||||
/// (Phase 18.2; read-path streaming). Carries an <em>open, seekable, disk-backed</em> <see cref="Stream"/>
|
||||
/// over the artifact's bytes — never a buffered <c>byte[]</c>, so a ~220 MB Opus file or ~970 MB lossless
|
||||
/// source is never materialized in a managed array per request. Also carries the content-type that matches
|
||||
/// <em>what was actually returned</em>, and the format actually served — which may differ from the requested
|
||||
/// one when the C2 fallback fires (Opus requested, no Opus artifact → the lossless artifact + its
|
||||
/// content-type). The delivery layer (18.3) sets the response <c>Content-Type</c> from
|
||||
/// <see cref="ContentType"/> so the eventual decoder picks the right decoder for the bytes it receives.
|
||||
/// <para>
|
||||
/// Ownership: the resolver opens the stream; the caller takes ownership. On the success path the caller hands
|
||||
/// <see cref="Stream"/> to <c>File(..., enableRangeProcessing: true)</c>, which disposes it after the
|
||||
/// response. On any pre-handoff throw the caller disposes this instance (which disposes the stream) so the
|
||||
/// underlying <see cref="FileStream"/> never leaks — mirroring the lossless disk-stream path's catch-path
|
||||
/// disposal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Stream">An open, seekable, disk-backed stream over the resolved artifact. The caller owns it.</param>
|
||||
/// <param name="ContentType">The MIME type of the bytes in <paramref name="Stream"/> (e.g. <c>audio/ogg</c>
|
||||
/// for Opus, or the source's real MIME for lossless).</param>
|
||||
/// <param name="ResolvedFormat">The format actually returned. Equal to the requested format on a direct
|
||||
/// hit; <see cref="AudioFormat.Lossless"/> when an Opus request fell back.</param>
|
||||
public sealed record ResolvedAudio(Stream Stream, string ContentType, AudioFormat ResolvedFormat)
|
||||
: IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>True when an Opus request was served the lossless artifact because no Opus existed (C2).</summary>
|
||||
public bool DidFallBack(AudioFormat requested) => requested != ResolvedFormat;
|
||||
|
||||
public void Dispose() => Stream.Dispose();
|
||||
|
||||
public ValueTask DisposeAsync() => Stream.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// The server-side format resolution + sidecar lookup seam (Phase 18.2). Given a track's
|
||||
/// <c>EntryKey</c> and a requested <see cref="AudioFormat"/>, returns the correct audio artifact and the
|
||||
/// content-type that matches it; given an <c>EntryKey</c>, returns the Opus seek/setup sidecar bytes.
|
||||
/// Downstream waves call this — 18.3 wires it behind the <c>?format=</c> stream param and serves the
|
||||
/// sidecar over HTTP; this wave delivers only the seam, not the HTTP surface.
|
||||
/// <para>
|
||||
/// Additive and non-breaking (C2): the lossless branch streams the source exactly as the existing read
|
||||
/// path does (via <see cref="TrackContentService.OpenAudioMediaStreamAsync"/>, a non-buffering disk
|
||||
/// stream), and an Opus request for a track with no Opus artifact falls back to lossless rather than
|
||||
/// failing. Mirrors the <see cref="WaveformProfileService"/> derived-artifact lookup precedent: read from
|
||||
/// the dedicated vault, swallow misses to null (FileDatabase convention), let the caller decide.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read-path streaming: artifacts are resolved as open, seekable, disk-backed <see cref="ResolvedAudio"/>
|
||||
/// handles — never whole-file <c>byte[]</c> loads — so the delivery layer streams them straight to the
|
||||
/// response (Range/206 honoured by the seekable <c>FileStream</c>) without buffering a ~220 MB Opus file
|
||||
/// or a ~970 MB lossless source per request.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TrackFormatResolver
|
||||
{
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly TrackContentService _trackContentService;
|
||||
private readonly ILogger<TrackFormatResolver> _logger;
|
||||
|
||||
public TrackFormatResolver(
|
||||
FileDb fileDatabase,
|
||||
TrackContentService trackContentService,
|
||||
ILogger<TrackFormatResolver> logger)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_trackContentService = trackContentService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="entryKey"/> + <paramref name="requestedFormat"/> to the audio artifact to
|
||||
/// serve plus its content-type. <see cref="AudioFormat.Lossless"/> resolves the source artifact in the
|
||||
/// <c>tracks</c> vault with its real MIME (WAV/MP3/FLAC). <see cref="AudioFormat.Opus"/> resolves the
|
||||
/// derived Opus artifact (<c>audio/ogg</c>) when present, and <strong>falls back to lossless</strong>
|
||||
/// when it is not (C2). Returns null only when even the lossless source is missing — i.e. the track has
|
||||
/// no audio at all (an unknown key or a genuinely empty vault), the one case the caller treats as 404.
|
||||
/// </summary>
|
||||
public async Task<ResolvedAudio?> ResolveAsync(string entryKey, AudioFormat requestedFormat)
|
||||
{
|
||||
if (requestedFormat == AudioFormat.Opus)
|
||||
{
|
||||
var opusVault = _fileDatabase.GetVault(VaultConstants.TrackOpus);
|
||||
if (opusVault is not null)
|
||||
{
|
||||
// Disk-backed, seekable stream over the Opus artifact — no whole-file buffer. The caller
|
||||
// owns the stream (hands it to File(...) on success, disposes on a pre-handoff throw).
|
||||
var opus = await opusVault.GetEntryStreamAsync(OpusTranscodeService.OpusAudioKey(entryKey));
|
||||
if (opus is not null)
|
||||
return new ResolvedAudio(
|
||||
opus.Stream, MimeTypeExtensions.GetMimeType(opus.Extension), AudioFormat.Opus);
|
||||
}
|
||||
|
||||
// C2 fallback: no Opus artifact yet (legacy row, not backfilled, or transcode failed). Degrade
|
||||
// to lossless rather than 404 — Opus is strictly additive; its absence never means "no audio".
|
||||
_logger.LogInformation(
|
||||
"Opus requested for {EntryKey} but no Opus artifact exists; falling back to lossless.", entryKey);
|
||||
}
|
||||
|
||||
return await ResolveLosslessAsync(entryKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the lossless source artifact and its real MIME as a non-buffering disk stream — the existing
|
||||
/// read path. Shared by the explicit-lossless branch and the Opus fallback so both produce identical
|
||||
/// bytes + content-type. The returned stream is seekable, so the delivery layer's Range→206 still works.
|
||||
/// </summary>
|
||||
private async Task<ResolvedAudio?> ResolveLosslessAsync(string entryKey)
|
||||
{
|
||||
var source = await _trackContentService.OpenAudioMediaStreamAsync(entryKey);
|
||||
if (source is null)
|
||||
return null;
|
||||
|
||||
return new ResolvedAudio(
|
||||
source.Stream, MimeTypeExtensions.GetMimeType(source.Extension), AudioFormat.Lossless);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Opus setup-header + seek-index sidecar bytes for <paramref name="entryKey"/>, or null
|
||||
/// when no sidecar is stored (no Opus artifact yet, or an older derive predating the sidecar). 18.3
|
||||
/// serves these on their own path; 18.4 fetches them once on track load and parses them into the
|
||||
/// client's <c>OpusSeekData</c>. The bytes are the raw <see cref="OpusSidecar"/> blob
|
||||
/// (<c>[uint32 setupHeaderLength][setup-header][seek-index]</c>) exactly as 18.1 stored them.
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetOpusSidecarAsync(string entryKey)
|
||||
{
|
||||
var sidecar = await _fileDatabase.LoadResourceAsync<MediaBinary>(
|
||||
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey));
|
||||
return sidecar?.Buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether <paramref name="entryKey"/> already has a complete Opus derive — both the audio bytes
|
||||
/// AND the seek/setup sidecar present in the <c>track-opus</c> vault. The Backfill-Opus pass (18.5) uses
|
||||
/// this to enqueue only tracks that are missing or half-derived (audio without sidecar = unseekable, so
|
||||
/// treated as incomplete and re-derived). Both halves are required because the transcode stores them in
|
||||
/// sequence and a sidecar-write failure leaves a track the delivery layer must not treat as Opus-ready.
|
||||
/// </summary>
|
||||
public async Task<bool> HasOpusAsync(string entryKey)
|
||||
{
|
||||
// Index-only existence — never read a file body. The opus-status admin endpoint calls this in a loop
|
||||
// over the entire catalogue, so a body load here would stream the whole library's audio sequentially.
|
||||
// HasIndexEntry is a pure in-memory index lookup (no disk read, no allocation per track).
|
||||
var opusVault = _fileDatabase.GetVault(VaultConstants.TrackOpus);
|
||||
if (opusVault is null)
|
||||
return false;
|
||||
|
||||
if (!await opusVault.HasIndexEntry(OpusTranscodeService.OpusAudioKey(entryKey)))
|
||||
return false;
|
||||
|
||||
// Both halves required: audio without the seek/setup sidecar is unseekable, so a half-derived track
|
||||
// counts as not-having-Opus (the same completeness rule the Backfill-Opus pass enqueues against).
|
||||
return await opusVault.HasIndexEntry(OpusTranscodeService.OpusSidecarKey(entryKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// The product of processing an uploaded audio file on the store path: the metadata SQL and the
|
||||
/// vault index need, plus a streamed writer that emits the canonical vault bytes to a destination
|
||||
/// stream without ever materializing the whole file in a managed <c>byte[]</c>.
|
||||
///
|
||||
/// This replaces the former whole-file <c>AudioBinary</c> as the processor output for upload /
|
||||
/// replace-audio (Wave 1 OOM fix): passthrough formats (standard-PCM WAV, MP3, FLAC) stream the
|
||||
/// source file straight to the destination, and EXTENSIBLE WAVs stream their normalization to
|
||||
/// standard PCM. The vault <em>load</em> path still uses <c>AudioBinary</c> (a full buffer) — that
|
||||
/// is the Wave 2 read path and is out of scope here.
|
||||
///
|
||||
/// <see cref="WriteToAsync"/> is invoked exactly once by the streaming vault register, against the
|
||||
/// freshly opened backing <see cref="System.IO.FileStream"/>. The writer re-opens the source file
|
||||
/// itself, so the source (a staging file) must still exist when the register runs — it does, because
|
||||
/// processing and registration are sequential within the store call, before the staging-file
|
||||
/// <c>finally</c> cleanup.
|
||||
/// </summary>
|
||||
public sealed class ProcessedAudio
|
||||
{
|
||||
/// <summary>The stored file extension (e.g. <c>.wav</c>, <c>.mp3</c>, <c>.flac</c>).</summary>
|
||||
public string Extension { get; }
|
||||
|
||||
/// <summary>Audio duration in seconds, extracted from the header.</summary>
|
||||
public double Duration { get; }
|
||||
|
||||
/// <summary>Audio bitrate in kbps, extracted from (or estimated for) the header.</summary>
|
||||
public int Bitrate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The canonical stored byte count — computed from the header and file length, never by
|
||||
/// buffering the body. Used only for diagnostics (confirming the streamed path was taken).
|
||||
/// </summary>
|
||||
public long Size { get; }
|
||||
|
||||
private readonly Func<Stream, CancellationToken, Task> _writeTo;
|
||||
|
||||
public ProcessedAudio(
|
||||
string extension,
|
||||
double duration,
|
||||
int bitrate,
|
||||
long size,
|
||||
Func<Stream, CancellationToken, Task> writeTo)
|
||||
{
|
||||
Extension = extension;
|
||||
Duration = duration;
|
||||
Bitrate = bitrate;
|
||||
Size = size;
|
||||
_writeTo = writeTo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams the canonical vault bytes to <paramref name="destination"/>. Bounded-buffer — peak
|
||||
/// managed memory is O(buffer), not O(filesize).
|
||||
/// </summary>
|
||||
public Task WriteToAsync(Stream destination, CancellationToken cancellationToken = default)
|
||||
=> _writeTo(destination, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a passthrough plan: the stored bytes are byte-identical to the source file (standard
|
||||
/// PCM WAV, MP3, FLAC — no transcoding). The writer is a bounded disk-to-disk copy.
|
||||
/// </summary>
|
||||
public static ProcessedAudio Passthrough(
|
||||
string sourcePath, string extension, double duration, int bitrate, long sourceLength)
|
||||
=> new(extension, duration, bitrate, sourceLength,
|
||||
(destination, ct) => AudioStoreStream.CopyFileAsync(sourcePath, destination, ct));
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Whole-buffer reduction. Defined in terms of <see cref="CreateAccumulator"/> so the streaming and
|
||||
/// whole-buffer paths share one decode + finalize implementation — byte-identical output by
|
||||
/// construction, not by parallel maintenance.
|
||||
/// </summary>
|
||||
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
|
||||
{
|
||||
var accumulator = CreateAccumulator(pcmData.Length, channels, sampleRate, bitsPerSample, bucketCount);
|
||||
accumulator.Add(pcmData);
|
||||
return accumulator.Finish();
|
||||
}
|
||||
|
||||
public ILoudnessAccumulator CreateAccumulator(
|
||||
long pcmByteLength, int channels, int sampleRate, int bitsPerSample, int bucketCount)
|
||||
{
|
||||
if (bucketCount <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bucketCount), "Bucket count must be positive.");
|
||||
}
|
||||
|
||||
return new RmsLoudnessAccumulator(pcmByteLength, channels, sampleRate, bitsPerSample, bucketCount);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
internal 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>
|
||||
internal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-pass RMS accumulator backing <see cref="RmsLoudnessAlgorithm"/>. Frames are fed via
|
||||
/// <see cref="Add"/> in arbitrary chunks; a partial frame straddling a chunk boundary is carried in a
|
||||
/// one-frame buffer. The per-frame decode, bucket assignment, and per-bucket accumulation are the exact
|
||||
/// arithmetic the former whole-buffer loop used, in the same frame order, so the floating-point result
|
||||
/// is bit-identical whether the PCM arrives in one span or many. <see cref="Finish"/> applies the same
|
||||
/// envelope smoothing and peak-normalization as before. Memory is O(bucketCount + one frame).
|
||||
/// </summary>
|
||||
public sealed class RmsLoudnessAccumulator : ILoudnessAccumulator
|
||||
{
|
||||
private readonly int _channels;
|
||||
private readonly int _sampleRate;
|
||||
private readonly int _bitsPerSample;
|
||||
private readonly int _bucketCount;
|
||||
private readonly int _bytesPerSample;
|
||||
private readonly int _bytesPerFrame;
|
||||
private readonly long _frameCount;
|
||||
|
||||
private readonly double[] _sumSquares;
|
||||
private readonly long[] _counts;
|
||||
private readonly byte[] _carry;
|
||||
private int _carryLen;
|
||||
private long _frameIndex;
|
||||
|
||||
internal RmsLoudnessAccumulator(long pcmByteLength, int channels, int sampleRate, int bitsPerSample, int bucketCount)
|
||||
{
|
||||
_channels = channels;
|
||||
_sampleRate = sampleRate;
|
||||
_bitsPerSample = bitsPerSample;
|
||||
_bucketCount = bucketCount;
|
||||
_sumSquares = new double[bucketCount];
|
||||
_counts = new long[bucketCount];
|
||||
|
||||
// Guards mirror the former whole-buffer Compute exactly: any degenerate parameter leaves
|
||||
// _frameCount at 0, so Add is a no-op and Finish returns the zero-initialized profile.
|
||||
_bytesPerSample = bitsPerSample / 8;
|
||||
if (channels <= 0 || _bytesPerSample <= 0)
|
||||
{
|
||||
_bytesPerFrame = 0;
|
||||
_frameCount = 0;
|
||||
_carry = [];
|
||||
return;
|
||||
}
|
||||
|
||||
_bytesPerFrame = _bytesPerSample * channels;
|
||||
_frameCount = pcmByteLength / _bytesPerFrame;
|
||||
_carry = new byte[_bytesPerFrame];
|
||||
}
|
||||
|
||||
public void Add(ReadOnlySpan<byte> pcmChunk)
|
||||
{
|
||||
if (_frameIndex >= _frameCount)
|
||||
{
|
||||
return; // degenerate input, or every expected frame already consumed
|
||||
}
|
||||
|
||||
var pos = 0;
|
||||
|
||||
// Complete a frame carried from the previous chunk first.
|
||||
if (_carryLen > 0)
|
||||
{
|
||||
var need = _bytesPerFrame - _carryLen;
|
||||
var take = Math.Min(need, pcmChunk.Length);
|
||||
pcmChunk.Slice(0, take).CopyTo(_carry.AsSpan(_carryLen));
|
||||
_carryLen += take;
|
||||
pos += take;
|
||||
|
||||
if (_carryLen < _bytesPerFrame)
|
||||
{
|
||||
return; // still not a full frame
|
||||
}
|
||||
|
||||
ProcessFrame(_carry);
|
||||
_carryLen = 0;
|
||||
if (_frameIndex >= _frameCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Whole frames directly from the chunk.
|
||||
while (pos + _bytesPerFrame <= pcmChunk.Length && _frameIndex < _frameCount)
|
||||
{
|
||||
ProcessFrame(pcmChunk.Slice(pos, _bytesPerFrame));
|
||||
pos += _bytesPerFrame;
|
||||
}
|
||||
|
||||
// Stash a trailing partial frame for the next chunk — but only while frames are still expected.
|
||||
// A trailing partial frame on the final chunk is dropped, matching the whole-buffer path.
|
||||
if (_frameIndex < _frameCount && pos < pcmChunk.Length)
|
||||
{
|
||||
var remainder = pcmChunk.Slice(pos);
|
||||
remainder.CopyTo(_carry);
|
||||
_carryLen = remainder.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessFrame(ReadOnlySpan<byte> frame)
|
||||
{
|
||||
double channelSum = 0;
|
||||
for (var ch = 0; ch < _channels; ch++)
|
||||
{
|
||||
channelSum += RmsLoudnessAlgorithm.ReadSampleNormalized(frame, ch * _bytesPerSample, _bitsPerSample);
|
||||
}
|
||||
|
||||
var mono = channelSum / _channels;
|
||||
|
||||
// long math avoids overflow on large files before the divide back into bucket index.
|
||||
var bucket = (int)(_frameIndex * _bucketCount / _frameCount);
|
||||
if (bucket >= _bucketCount)
|
||||
{
|
||||
bucket = _bucketCount - 1;
|
||||
}
|
||||
|
||||
_sumSquares[bucket] += mono * mono;
|
||||
_counts[bucket]++;
|
||||
_frameIndex++;
|
||||
}
|
||||
|
||||
public double[] Finish()
|
||||
{
|
||||
var result = new double[_bucketCount];
|
||||
if (_frameCount == 0)
|
||||
{
|
||||
return result; // degenerate input — all zeros, as the whole-buffer guards returned
|
||||
}
|
||||
|
||||
for (var i = 0; i < _bucketCount; i++)
|
||||
{
|
||||
if (_counts[i] > 0)
|
||||
{
|
||||
result[i] = Math.Sqrt(_sumSquares[i] / _counts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope smoothing (~15 ms) then peak-normalization — identical to the whole-buffer finalize.
|
||||
var totalSeconds = (double)_frameCount / _sampleRate;
|
||||
var bucketSeconds = totalSeconds / _bucketCount;
|
||||
RmsLoudnessAlgorithm.SmoothEnvelope(result, bucketSeconds);
|
||||
|
||||
var peak = 0.0;
|
||||
for (var i = 0; i < _bucketCount; i++)
|
||||
{
|
||||
if (result[i] > peak)
|
||||
{
|
||||
peak = result[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (peak <= 0)
|
||||
{
|
||||
Array.Clear(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (var i = 0; i < _bucketCount; i++)
|
||||
{
|
||||
result[i] /= peak;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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,313 @@
|
||||
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";
|
||||
|
||||
/// <summary>Bounded read-buffer size for the streaming PCM pass — the only filesize-independent
|
||||
/// allocation on the streaming path (matches the store path's 80 KB copy buffer).</summary>
|
||||
private const int StreamReadBufferSize = 81920;
|
||||
|
||||
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>
|
||||
/// Streaming counterpart of <see cref="ComputeAndStoreAsync"/>: computes and stores the fixed
|
||||
/// 512-bucket player-bar profile by reading the WAV from <paramref name="openWavStream"/> in bounded
|
||||
/// chunks, never materializing the whole file in a managed <c>byte[]</c>. Tri-state result matches
|
||||
/// the <c>RemoveResourceAsync</c> idiom so callers can map outcomes precisely: <c>null</c> = no audio
|
||||
/// stream available (the entry has no backing audio); <c>false</c> = audio present but no profile
|
||||
/// computable (non-WAV / float / padded) or the vault write failed; <c>true</c> = stored. Output is
|
||||
/// byte-identical to the whole-buffer path for the same WAV.
|
||||
/// </summary>
|
||||
public Task<bool?> ComputeAndStoreProfileStreamingAsync(
|
||||
Func<CancellationToken, Task<Stream?>> openWavStream,
|
||||
string entryKey,
|
||||
CancellationToken ct = default) =>
|
||||
RunStreamingAsync(
|
||||
openWavStream, entryKey,
|
||||
[(_options.BucketCount, VaultConstants.WaveformProfiles)], ct);
|
||||
|
||||
/// <summary>
|
||||
/// Streaming counterpart of <see cref="ComputeAndStoreHighResAsync"/>: computes and stores the
|
||||
/// duration-derived high-res datum (<see cref="VaultConstants.TrackWaveforms"/>) by streaming the WAV
|
||||
/// from <paramref name="openWavStream"/>. <paramref name="durationSeconds"/> drives the bucket count
|
||||
/// exactly as the whole-buffer path's <c>audio.Duration</c> did — pass the same vault-metadata
|
||||
/// duration to keep the stored bytes identical. Tri-state result as in
|
||||
/// <see cref="ComputeAndStoreProfileStreamingAsync"/>.
|
||||
/// </summary>
|
||||
public Task<bool?> ComputeAndStoreHighResStreamingAsync(
|
||||
Func<CancellationToken, Task<Stream?>> openWavStream,
|
||||
string entryKey,
|
||||
double durationSeconds,
|
||||
CancellationToken ct = default) =>
|
||||
RunStreamingAsync(
|
||||
openWavStream, entryKey,
|
||||
[(WaveformResolution.BucketCountForDuration(durationSeconds), VaultConstants.TrackWaveforms)], ct);
|
||||
|
||||
/// <summary>
|
||||
/// Computes and stores BOTH datums a track carries — the 512-bucket profile and the duration-derived
|
||||
/// high-res datum — from a SINGLE streaming pass over the WAV. One sequential read of the (possibly
|
||||
/// ~GB) audio feeds two independent accumulators, so memory stays O(bucket arrays + read buffer) and
|
||||
/// disk I/O is halved versus two separate passes. This is the upload / replace-audio hot path. Each
|
||||
/// datum's stored bytes are byte-identical to its whole-buffer counterpart. Tri-state: <c>null</c> =
|
||||
/// no audio stream; <c>false</c> = not WAV-decodable or a vault write failed; <c>true</c> = both
|
||||
/// datums stored. Best-effort callers ignore the result.
|
||||
/// </summary>
|
||||
public Task<bool?> ComputeAndStoreAllStreamingAsync(
|
||||
Func<CancellationToken, Task<Stream?>> openWavStream,
|
||||
string entryKey,
|
||||
double durationSeconds,
|
||||
CancellationToken ct = default) =>
|
||||
RunStreamingAsync(
|
||||
openWavStream, entryKey,
|
||||
[
|
||||
(_options.BucketCount, VaultConstants.WaveformProfiles),
|
||||
(WaveformResolution.BucketCountForDuration(durationSeconds), VaultConstants.TrackWaveforms),
|
||||
],
|
||||
ct);
|
||||
|
||||
/// <summary>
|
||||
/// Core streaming reduction: opens the WAV once, parses its header (bounded), then streams the PCM
|
||||
/// data region through one loudness accumulator per requested target, storing each datum. All
|
||||
/// targets are computed in the single pass. See the tri-state contract on the public wrappers.
|
||||
/// </summary>
|
||||
private async Task<bool?> RunStreamingAsync(
|
||||
Func<CancellationToken, Task<Stream?>> openWavStream,
|
||||
string entryKey,
|
||||
IReadOnlyList<(int BucketCount, string VaultName)> targets,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = await openWavStream(ct);
|
||||
if (stream is null)
|
||||
{
|
||||
// No backing audio for this entry — distinct from "present but undecodable".
|
||||
return null;
|
||||
}
|
||||
|
||||
var info = await _audioProcessor.TryReadPcmStreamInfoAsync(stream, stream.Length, ct);
|
||||
if (info is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform profile not computed for {EntryKey}: WAV PCM could not be extracted (streaming).",
|
||||
entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
var v = info.Value;
|
||||
var accumulators = new ILoudnessAccumulator[targets.Count];
|
||||
for (var i = 0; i < targets.Count; i++)
|
||||
{
|
||||
accumulators[i] = _loudnessAlgorithm.CreateAccumulator(
|
||||
v.DataLength, v.Channels, v.SampleRate, v.BitsPerSample, targets[i].BucketCount);
|
||||
}
|
||||
|
||||
await StreamPcmThroughAsync(stream, v.DataStart, v.DataLength, accumulators, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Streaming waveform compute for {EntryKey}: {DataLength} PCM bytes, {TargetCount} datum(s), " +
|
||||
"{BufferSize}B read buffer — no whole-file load.",
|
||||
entryKey, v.DataLength, targets.Count, StreamReadBufferSize);
|
||||
|
||||
var allStored = true;
|
||||
for (var i = 0; i < targets.Count; i++)
|
||||
{
|
||||
var profile = accumulators[i].Finish();
|
||||
var quantized = Quantize(profile);
|
||||
|
||||
await EnsureVaultAsync(targets[i].VaultName);
|
||||
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(targets[i].VaultName, entryKey, binary);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform vault write failed for {EntryKey} in {VaultName}.", entryKey, targets[i].VaultName);
|
||||
allStored = false;
|
||||
}
|
||||
}
|
||||
|
||||
return allStored;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Streaming waveform computation failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeks to the PCM data region and streams exactly <paramref name="dataLength"/> bytes through each
|
||||
/// accumulator in bounded reads. The accumulators carry partial frames internally, so the read
|
||||
/// boundaries need not align to frames. Peak memory is one read buffer — independent of file size.
|
||||
/// </summary>
|
||||
private static async Task StreamPcmThroughAsync(
|
||||
Stream stream, long dataStart, long dataLength, ILoudnessAccumulator[] accumulators, CancellationToken ct)
|
||||
{
|
||||
stream.Seek(dataStart, SeekOrigin.Begin);
|
||||
|
||||
var buffer = new byte[StreamReadBufferSize];
|
||||
var remaining = dataLength;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var want = (int)Math.Min(buffer.Length, remaining);
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(0, want), ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
|
||||
var span = buffer.AsSpan(0, read);
|
||||
foreach (var accumulator in accumulators)
|
||||
{
|
||||
accumulator.Add(span);
|
||||
}
|
||||
|
||||
remaining -= read;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,45 @@ 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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Process the WAV file
|
||||
var audioBinary = await _audioProcessor.ProcessWavFileAsync(wavFilePath);
|
||||
if (audioBinary == null)
|
||||
// Process the audio file (routed by extension). The returned plan carries metadata plus a
|
||||
// streamed writer — no whole-file buffer (the store-path OOM fix).
|
||||
var processed = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath, cancellationToken);
|
||||
if (processed == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to process WAV file");
|
||||
throw new InvalidOperationException("Failed to process audio file");
|
||||
}
|
||||
|
||||
// Generate a unique track ID
|
||||
@@ -56,29 +62,131 @@ public class TrackContentService
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
||||
}
|
||||
|
||||
// Store the audio in FileDatabase
|
||||
var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, trackId, audioBinary);
|
||||
// Stream the audio into the vault. The metadata is supplied directly (there is no in-memory
|
||||
// AudioBinary on this path), and the bytes are written progressively from the staging file.
|
||||
var metaData = MetaDataFactory.CreateAudioMetaData(trackId, processed.Extension, processed.Duration, processed.Bitrate);
|
||||
var success = await _fileDatabase.RegisterResourceStreamingAsync(
|
||||
VaultConstants.Tracks, trackId, metaData, processed.WriteToAsync, cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
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,
|
||||
// Persist the processor-extracted runtime to SQL so aggregate queries (total mix runtime)
|
||||
// need not touch the vault. Same value the high-res waveform compute reads downstream.
|
||||
DurationSeconds = processed.Duration
|
||||
};
|
||||
|
||||
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,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Swaps the audio bytes for an existing track in place: processes a new audio file and
|
||||
/// re-registers it under the SAME <paramref name="entryKey"/> in the tracks vault. The track's
|
||||
/// vault key — and therefore its SQL link, release membership, position, and metadata — is
|
||||
/// untouched; only the binary changes. The new audio is streamed to the vault first; only on
|
||||
/// confirmed success is a stale old backing file cleaned up. A cross-format replacement (e.g.
|
||||
/// .wav → .flac) leaves the old file on disk under its former filename once the index is updated;
|
||||
/// the post-success cleanup removes it. For a same-extension overwrite the register alone suffices.
|
||||
/// If the register fails the original audio is left intact and null is returned, so the track
|
||||
/// remains playable. Returns the freshly stored audio's <b>duration</b> on success (the caller
|
||||
/// re-reads the vault for waveform regen and uses this for the SQL duration write) — matching the
|
||||
/// FileDatabase swallow-and-return-null contract. The new bytes are never materialized in memory.
|
||||
/// </summary>
|
||||
public async Task<double?> ReplaceTrackAudioAsync(string entryKey, string audioFilePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Capture the old extension from the index metadata (not by loading the file — that would
|
||||
// pull the whole old audio into memory). After register the index points to the new
|
||||
// extension, so we need the old value now to detect a cross-format swap and clean up the
|
||||
// stale file post-success.
|
||||
var trackVault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
var existingMeta = trackVault is null ? null : await trackVault.GetEntryMetadata(entryKey);
|
||||
var oldExtension = existingMeta?.Extension;
|
||||
|
||||
var processed = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath, cancellationToken);
|
||||
if (processed == null)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: processing returned null for {entryKey}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
|
||||
{
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
||||
}
|
||||
|
||||
// Stream the new audio in. This upserts the index entry (new extension recorded) and writes
|
||||
// the new file to disk. If this fails the original entry and file are untouched.
|
||||
var metaData = MetaDataFactory.CreateAudioMetaData(entryKey, processed.Extension, processed.Duration, processed.Bitrate);
|
||||
var success = await _fileDatabase.RegisterResourceStreamingAsync(
|
||||
VaultConstants.Tracks, entryKey, metaData, processed.WriteToAsync, cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: vault write failed for {entryKey}; original audio preserved");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Post-success stale-file cleanup for cross-format swaps. The register wrote the new
|
||||
// file (e.g. .flac) and updated the index to the new extension, but the old backing
|
||||
// file (e.g. .wav) is now unreferenced on disk. Delete it directly by constructing the
|
||||
// old path — RemoveResourceAsync would now resolve to the new extension and delete the
|
||||
// wrong file. Non-fatal: an orphaned old file is a disk-hygiene concern, not a
|
||||
// playback issue (the index no longer references it).
|
||||
if (oldExtension != null && oldExtension != processed.Extension)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault != null)
|
||||
{
|
||||
var sanitizedKey = System.Text.RegularExpressions.Regex.Replace(entryKey, @"[^a-zA-Z0-9]", "-");
|
||||
var staleFilePath = Path.Combine(vault.RootPath, $"{sanitizedKey}{oldExtension}");
|
||||
try
|
||||
{
|
||||
if (File.Exists(staleFilePath))
|
||||
File.Delete(staleFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: stale backing-file removal failed for {entryKey} ({staleFilePath}): {ex.Message} — new audio is live; orphaned file may remain on disk");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processed.Duration;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -93,6 +201,65 @@ public class TrackContentService
|
||||
return await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, trackId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a read-only, seekable stream over a track's vault audio, or null if the entry has no
|
||||
/// backing file. The caller owns the stream and must dispose it. Unlike <see cref="GetAudioBinaryAsync"/>
|
||||
/// this never buffers the whole file — it is the source for the streaming waveform compute. Follows
|
||||
/// the FileDatabase swallow-and-return-null contract.
|
||||
/// </summary>
|
||||
/// <param name="trackId">Track ID (EntryKey)</param>
|
||||
public async Task<Stream?> OpenAudioStreamAsync(string trackId)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var media = await vault.GetEntryStreamAsync(trackId);
|
||||
return media?.Stream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a read-only stream over a track's vault audio together with its stored extension, or null if
|
||||
/// the entry has no backing file. Same non-buffering contract as <see cref="OpenAudioStreamAsync"/>,
|
||||
/// but keeps the <see cref="MediaStream.Extension"/> the caller needs to name a staging file for a
|
||||
/// format-detecting consumer (the Opus transcode reopens the source by extension for ffmpeg). The
|
||||
/// caller owns the returned <see cref="MediaStream"/> and must dispose it. Follows the FileDatabase
|
||||
/// swallow-and-return-null contract.
|
||||
/// </summary>
|
||||
/// <param name="trackId">Track ID (EntryKey)</param>
|
||||
public async Task<MediaStream?> OpenAudioMediaStreamAsync(string trackId)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await vault.GetEntryStreamAsync(trackId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a track's stored audio duration from the vault index metadata WITHOUT loading the audio
|
||||
/// body — the cheap counterpart of <c>GetAudioBinaryAsync(...).Duration</c>. Returns null if the
|
||||
/// entry is unknown or carries no audio metadata. The streaming high-res waveform path uses this to
|
||||
/// derive the duration-based bucket count, matching the value the whole-buffer path read off
|
||||
/// <see cref="AudioBinary.Duration"/> so the stored datum is byte-identical.
|
||||
/// </summary>
|
||||
/// <param name="trackId">Track ID (EntryKey)</param>
|
||||
public async Task<double?> GetAudioDurationAsync(string trackId)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var metaData = await vault.GetEntryMetadata(trackId);
|
||||
return metaData is AudioMetaData audio ? audio.Duration : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if FileDatabase is available and tracks vault exists
|
||||
/// </summary>
|
||||
|
||||
+22
-7
@@ -20,7 +20,7 @@ Separating domain logic from hosts so DeepDrftAPI can reuse `TrackManager` / `Tr
|
||||
DeepDrftData/
|
||||
├── Data/
|
||||
│ ├── DeepDrftContext.cs # EF DbContext
|
||||
│ ├── DeepDrftContextFactory.cs # Design-time factory (hard-codes ../Database/deepdrft.db)
|
||||
│ ├── DeepDrftContextFactory.cs # Design-time factory (reads environment/connections.json; Npgsql dummy fallback)
|
||||
│ └── Configurations/
|
||||
│ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity
|
||||
├── Migrations/ # EF-generated migrations (namespace DeepDrftData.Migrations)
|
||||
@@ -32,7 +32,7 @@ DeepDrftData/
|
||||
|
||||
## EF DbContext and configuration
|
||||
|
||||
`DeepDrftContext` targets SQLite, connection string from `appsettings.json` (`ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) hard-codes `../Database/deepdrft.db` for `dotnet ef` commands, so you can run migrations locally without a full app context.
|
||||
`DeepDrftContext` targets **PostgreSQL** (Npgsql), connection string from `environment/connections.json` (loaded at runtime via `CredentialTools.ResolvePathOrThrow("connections", ...)` in `DeepDrftAPI/Program.cs`, key `ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) reads the same `environment/connections.json` when present and falls back to a Npgsql dummy connection string (`Host=localhost;Database=deepdrft-design-time;Username=dummy`) for CI or environments without the file, so `dotnet ef` commands work without a live database.
|
||||
|
||||
`TrackConfiguration` uses EF fluent API:
|
||||
- Table name: `track` (singular)
|
||||
@@ -42,6 +42,7 @@ DeepDrftData/
|
||||
- `Album`, `Genre`: optional, max 200 / 100
|
||||
- `ReleaseDate`: optional `DateOnly`
|
||||
- `ImagePath`: optional, max 500 (currently a free-form URL string; points to images vault in future)
|
||||
- `DurationSeconds`: optional `double?` (nullable; populated at upload from vault audio; backfillable via `POST api/track/duration/backfill`; used for aggregate mix-runtime queries). Column: `duration_seconds`. Migration: `20260618155002_AddTrackDuration`.
|
||||
|
||||
## Service → Repository → DbContext shape
|
||||
|
||||
@@ -49,6 +50,20 @@ DeepDrftData/
|
||||
- **Repository** (`TrackRepository`): Internal data access. Queries the DbContext. Throws on error (service catches).
|
||||
- **DbContext** (`DeepDrftContext`): EF Core. Directly accessed by repository, never by service (pattern isolation).
|
||||
|
||||
Notable repository / service methods beyond the standard CRUD:
|
||||
|
||||
- `TrackRepository.GetHomeStatsAsync` / `ITrackService.GetHomeStats`: Returns `HomeStatsDto` — cut track count, per-`ReleaseType` cut release counts (zero-suppressed), mix release count, total mix runtime seconds (null durations counted as 0; tracks under a soft-deleted release excluded). Used by `StatsController`.
|
||||
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
|
||||
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
|
||||
- `ITrackService.FindOrCreateRelease` / `TrackManager.FindOrCreateRelease`: Finds the release row matching (title, artist) or creates one if none exists. Returns `ResultContainer<(ReleaseDto Release, bool WasCreated)>` — the `WasCreated` flag lets the upload CREATE path distinguish a freshly minted release from one returned because a concurrent upload won the insert race (the latter is treated as a duplicate and rejected with 409, not silently attached). `ITrackService.GetReleaseByTitleAndArtist` is the read-only counterpart used for the upload pre-flight check and the ATTACH-path validation.
|
||||
|
||||
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
|
||||
|
||||
`EventRepository` and `EventManager` (with `IEventService` boundary) are the SQL-side domain for anonymous play/share telemetry (Phase 16 waves 16.1 + 16.3). Unlike `TrackRepository`, these entities have no soft-delete lifecycle and are not `BaseEntity`/`IEntity` — `EventRepository` is a plain context-backed repository against the same scoped `DeepDrftContext`.
|
||||
|
||||
- **`EventRepository`** (`Repositories/EventRepository.cs`): append-only writes to the `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository joins track→release and stamps the `release_id` on the row. Also owns the three distinct-listener aggregation queries added in wave 16.3: `CountDistinctListenersAsync()` (site-wide), `CountDistinctListenersForTrackAsync(trackEntryKey)`, `CountDistinctListenersForReleaseAsync(releaseId)` — each excludes null `anon_id` rows.
|
||||
- **`EventManager` / `IEventService`** (`EventManager.cs`): `RecordPlay(trackEntryKey, bucket, anonId, ct)` and `RecordShare(targetType, targetKey, channel, anonId, ct)` return NetBlocks `Result`. Wave 16.3 added three distinct-count members returning `ResultContainer<int>`: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)`. Registered scoped in `DeepDrftAPI/Program.cs`. Migration: `20260619155610_AddPlayShareTelemetry` (authored; not yet applied — Daniel-gated). The `anon_id` columns and covering indexes on `play_event`/`share_event` are part of this migration — no additional migration was needed for 16.3.
|
||||
|
||||
Example:
|
||||
|
||||
```csharp
|
||||
@@ -117,10 +132,10 @@ Run from the solution root:
|
||||
|
||||
```bash
|
||||
# Add a migration
|
||||
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
|
||||
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI
|
||||
|
||||
# Apply to database
|
||||
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic
|
||||
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
|
||||
```
|
||||
|
||||
The design-time factory means you can also run `dotnet ef ... --project DeepDrftData` standalone for local development (it doesn't need the startup project).
|
||||
@@ -132,9 +147,9 @@ Migrations live in the `DeepDrftData.Migrations` namespace. Migration files are
|
||||
## Connection string
|
||||
|
||||
- **DeepDrftAPI**: `environment/connections.json` → `ConnectionStrings:DefaultConnection`
|
||||
- Points at the same database (PostgreSQL in production, SQLite for local development).
|
||||
- Always PostgreSQL (Npgsql) — both production and local development.
|
||||
|
||||
The design-time factory hard-codes the local path for `dotnet ef` commands.
|
||||
The design-time factory reads `environment/connections.json` when present; falls back to a Npgsql dummy for CI.
|
||||
|
||||
## Service registration
|
||||
|
||||
@@ -142,7 +157,7 @@ In `DeepDrftAPI/Program.cs`:
|
||||
|
||||
```csharp
|
||||
services.AddDbContext<DeepDrftContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); // or UseSqlite for dev
|
||||
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")));
|
||||
services.AddScoped<TrackRepository>();
|
||||
services.AddScoped<TrackManager>();
|
||||
services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
|
||||
@@ -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,47 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the <c>play_counter</c> rollup (Phase 16 §4.1 / D6). One row per track, unique
|
||||
/// on track_id so the incremental-on-write bump is an upsert against a single row. <c>TotalPlays</c> is
|
||||
/// a computed C# property (sum of the three bucket columns) and is not mapped — it is derived on read.
|
||||
/// </summary>
|
||||
public class PlayCounterConfiguration : IEntityTypeConfiguration<PlayCounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayCounter> builder)
|
||||
{
|
||||
builder.ToTable("play_counter");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TrackId)
|
||||
.IsRequired()
|
||||
.HasColumnName("track_id");
|
||||
|
||||
builder.Property(e => e.PartialCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
builder.Property(e => e.SampledCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
builder.Property(e => e.CompleteCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
// Derived headline figure — never a column.
|
||||
builder.Ignore(e => e.TotalPlays);
|
||||
|
||||
builder.HasIndex(e => e.TrackId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_play_counter_track_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the append-only <c>play_event</c> log (Phase 16 §4.2). Plain entity, not a
|
||||
/// <c>BaseEntity</c> — no soft-delete or updated_at, just an immutable fact with a created_at stamp.
|
||||
/// Indexed on track key, release id, and anon id (the last reserved for the wave-16.3 distinct-listener
|
||||
/// query) so the aggregation paths stay cheap as the log grows.
|
||||
/// </summary>
|
||||
public class PlayEventConfiguration : IEntityTypeConfiguration<PlayEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayEvent> builder)
|
||||
{
|
||||
builder.ToTable("play_event");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TrackEntryKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("track_entry_key");
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
builder.Property(e => e.Bucket)
|
||||
.IsRequired()
|
||||
.HasConversion<string>() // Store the readable bucket name, mirroring ReleaseMedium.
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("bucket");
|
||||
|
||||
// Reserved nullable token (wave 16.3). Same width as a stringified GUID.
|
||||
builder.Property(e => e.AnonId)
|
||||
.HasMaxLength(64)
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
builder.Property(e => e.CreatedAt)
|
||||
.IsRequired()
|
||||
.HasColumnName("created_at");
|
||||
|
||||
builder.HasIndex(e => e.TrackEntryKey).HasDatabaseName("IX_play_event_track_entry_key");
|
||||
builder.HasIndex(e => e.ReleaseId).HasDatabaseName("IX_play_event_release_id");
|
||||
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_play_event_anon_id");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the append-only <c>share_event</c> log (Phase 16 §4.2). Plain immutable-fact
|
||||
/// entity. Indexed on the target key so per-target share tallies stay cheap.
|
||||
/// </summary>
|
||||
public class ShareEventConfiguration : IEntityTypeConfiguration<ShareEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ShareEvent> builder)
|
||||
{
|
||||
builder.ToTable("share_event");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TargetType)
|
||||
.IsRequired()
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("target_type");
|
||||
|
||||
builder.Property(e => e.TargetKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("target_key");
|
||||
|
||||
builder.Property(e => e.Channel)
|
||||
.IsRequired()
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("channel");
|
||||
|
||||
builder.Property(e => e.AnonId)
|
||||
.HasMaxLength(64)
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
builder.Property(e => e.CreatedAt)
|
||||
.IsRequired()
|
||||
.HasColumnName("created_at");
|
||||
|
||||
builder.HasIndex(e => e.TargetKey).HasDatabaseName("IX_share_event_target_key");
|
||||
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_share_event_anon_id");
|
||||
}
|
||||
}
|
||||
@@ -30,28 +30,28 @@ 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);
|
||||
|
||||
// Nullable: existing rows carry NULL until the one-time duration backfill populates them.
|
||||
builder.Property(e => e.DurationSeconds)
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
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,26 @@ 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; }
|
||||
|
||||
// Phase 16 anonymous telemetry: append-only event logs + incremental play rollup. All SQL — the
|
||||
// FileDatabase vault is not involved.
|
||||
public DbSet<PlayEvent> PlayEvents { get; set; }
|
||||
public DbSet<ShareEvent> ShareEvents { get; set; }
|
||||
public DbSet<PlayCounter> PlayCounters { 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());
|
||||
modelBuilder.ApplyConfiguration(new PlayEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ShareEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new PlayCounterConfiguration());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using NetBlocks.Utilities.Environment;
|
||||
|
||||
namespace DeepDrftData.Data;
|
||||
|
||||
@@ -7,23 +8,21 @@ public class DeepDrftContextFactory : IDesignTimeDbContextFactory<DeepDrftContex
|
||||
{
|
||||
public DeepDrftContext CreateDbContext(string[] args)
|
||||
{
|
||||
// Load the real connection string from environment/connections.json — the same
|
||||
// file DeepDrftPublic's Program.cs loads via CredentialTools. When EF tools run with
|
||||
// --startup-project DeepDrftPublic, the working directory resolves there, so this
|
||||
// relative path works without any env var configuration.
|
||||
const string relPath = "environment/connections.json";
|
||||
if (!File.Exists(relPath))
|
||||
throw new FileNotFoundException(
|
||||
$"'{relPath}' not found. Run EF commands with --startup-project DeepDrftPublic " +
|
||||
$"from the solution root (current dir: {Directory.GetCurrentDirectory()}).", relPath);
|
||||
var path = CredentialTools.ResolvePath("connections", "environment/connections.json");
|
||||
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(relPath));
|
||||
var connectionString = doc.RootElement
|
||||
.GetProperty("ConnectionStrings")
|
||||
.GetProperty("DefaultConnection")
|
||||
.GetString()
|
||||
?? throw new InvalidOperationException(
|
||||
"ConnectionStrings:DefaultConnection not found in environment/connections.json");
|
||||
string? connectionString = null;
|
||||
if (File.Exists(path))
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path));
|
||||
connectionString = doc.RootElement
|
||||
.GetProperty("ConnectionStrings")
|
||||
.GetProperty("DefaultConnection")
|
||||
.GetString();
|
||||
}
|
||||
|
||||
// Fall back to a design-time dummy — the bundle only needs the provider/schema,
|
||||
// not a live connection. This removes the requirement to write a dummy file in CI.
|
||||
connectionString ??= "Host=localhost;Database=deepdrft-design-time;Username=dummy";
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<DeepDrftContext>();
|
||||
optionsBuilder.UseNpgsql(connectionString);
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
</PackageReference>
|
||||
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.30" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.30" />
|
||||
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.35" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.35" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IEventService"/> implementation over <see cref="EventRepository"/>. The layer boundary
|
||||
/// matches the rest of DeepDrftData: the repository owns the EF constructs and the write transaction;
|
||||
/// this service catches at the boundary and returns a NetBlocks <see cref="Result"/>. Telemetry is
|
||||
/// best-effort by design (§2.2) — a failed write is logged and surfaced as a fail result, never thrown
|
||||
/// at the caller, so a telemetry hiccup can never reach a listener.
|
||||
/// </summary>
|
||||
public class EventManager : IEventService
|
||||
{
|
||||
private readonly EventRepository _repository;
|
||||
private readonly ILogger<EventManager> _logger;
|
||||
|
||||
public EventManager(EventRepository repository, ILogger<EventManager> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result> RecordPlay(
|
||||
string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _repository.RecordPlayAsync(trackEntryKey, bucket, anonId, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to record play event for track {TrackEntryKey}", trackEntryKey);
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> RecordShare(
|
||||
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _repository.RecordShareAsync(targetType, targetKey, channel, anonId, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to record share event for {TargetType} {TargetKey}", targetType, targetKey);
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _repository.CountTotalPlaysAsync(cancellationToken);
|
||||
return ResultContainer<long>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to count total plays");
|
||||
return ResultContainer<long>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _repository.CountDistinctListenersAsync(cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to count distinct listeners");
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetDistinctListenerCountForTrack(
|
||||
string trackEntryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _repository.CountDistinctListenersForTrackAsync(trackEntryKey, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to count distinct listeners for track {TrackEntryKey}", trackEntryKey);
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetDistinctListenerCountForRelease(
|
||||
long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _repository.CountDistinctListenersForReleaseAsync(releaseId, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to count distinct listeners for release {ReleaseId}", releaseId);
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// SQL-side anonymous telemetry service (Phase 16). Records play and share events to the append-only
|
||||
/// logs and maintains the incremental play-counter rollup. The release dimension on a play is resolved
|
||||
/// server-side from the track key (§2.3 / D4) — callers pass only what the client cheaply knows.
|
||||
/// Returns NetBlocks <see cref="Result"/> at the boundary; the controller maps that to 202/4xx/5xx.
|
||||
/// </summary>
|
||||
public interface IEventService
|
||||
{
|
||||
/// <summary>
|
||||
/// Record one play: append a <c>play_event</c> row (release resolved from the track key) and bump
|
||||
/// the track's <c>play_counter</c> in the same transaction. A play of an unknown/removed track key
|
||||
/// still logs (with a null release and no counter bump) rather than failing.
|
||||
/// </summary>
|
||||
Task<Result> RecordPlay(string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Record one share: append a <c>share_event</c> row. Target and channel come straight from the client.</summary>
|
||||
Task<Result> RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide total play count (Phase 16 §5 — all-time): the sum of every <c>play_counter</c> row's
|
||||
/// three bucket columns. Zero until the telemetry migration is applied. The home Plays card's primary
|
||||
/// figure; the controller composes it onto <c>HomeStatsDto</c> alongside the track-domain figures.
|
||||
/// </summary>
|
||||
Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide distinct-listener count (Phase 16 §3, D3 — all-time): distinct non-null <c>anon_id</c>
|
||||
/// values across all play events. Null tokens are excluded (not a known listener). The capability for
|
||||
/// wave 16.5's "N listeners" card; nothing surfaces it via API or UI in wave 16.3.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Distinct listeners who played the given track (by vault entry key). Null tokens excluded.</summary>
|
||||
Task<ResultContainer<int>> GetDistinctListenerCountForTrack(string trackEntryKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct listeners across the release's tracks (derived, D4) — a listener who played any track in
|
||||
/// the release counts once. Null tokens excluded.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> GetDistinctListenerCountForRelease(long releaseId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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,80 @@ 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>
|
||||
/// Aggregate figures behind the public home hero stat row: Cut track count + per-ReleaseType Cut
|
||||
/// release breakdown, Mix release count, and total Mix runtime in seconds. One read for all three cards.
|
||||
/// </summary>
|
||||
Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Non-deleted tracks whose SQL duration is still null — the work list for the one-time duration
|
||||
/// backfill. The backfill reads each track's stored duration from the vault and writes it via
|
||||
/// <see cref="UpdateDuration"/>.
|
||||
/// </summary>
|
||||
Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Set the SQL duration for one track. Idempotent: a track whose duration is already set is left
|
||||
/// untouched. Backs the duration backfill. Returns the number of rows updated (0 or 1).
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unconditionally overwrite the SQL duration for one track. Unlike <see cref="UpdateDuration"/>,
|
||||
/// this carries no null guard — it is for the replace-audio path where the track already has a
|
||||
/// non-null duration that must be overwritten with the new audio's value. Returns a fail Result
|
||||
/// when zero rows are affected (track removed between lookup and write).
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, 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.
|
||||
/// The <c>WasCreated</c> flag in the result is <see langword="true"/> when a new row was inserted
|
||||
/// and <see langword="false"/> when an existing row was found (including after a lost concurrent-insert
|
||||
/// race). The CREATE path in <c>UnifiedTrackService.UploadAsync</c> uses this to turn a
|
||||
/// "found existing" outcome into a duplicate rejection rather than a silent attach.
|
||||
/// </summary>
|
||||
Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// <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("20260618155002_AddTrackDuration")]
|
||||
partial class AddTrackDuration
|
||||
{
|
||||
/// <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<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
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,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrackDuration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "duration_seconds",
|
||||
table: "track",
|
||||
type: "double precision",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "duration_seconds",
|
||||
table: "track");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
// <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("20260619155610_AddPlayShareTelemetry")]
|
||||
partial class AddPlayShareTelemetry
|
||||
{
|
||||
/// <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.PlayCounter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CompleteCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
b.Property<long>("PartialCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
b.Property<long>("SampledCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
b.Property<long>("TrackId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("track_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrackId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_play_counter_track_id");
|
||||
|
||||
b.ToTable("play_counter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Bucket")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("bucket");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("track_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_play_event_anon_id");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.HasDatabaseName("IX_play_event_release_id");
|
||||
|
||||
b.HasIndex("TrackEntryKey")
|
||||
.HasDatabaseName("IX_play_event_track_entry_key");
|
||||
|
||||
b.ToTable("play_event", (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.ShareEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("TargetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("target_key");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("target_type");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_share_event_anon_id");
|
||||
|
||||
b.HasIndex("TargetKey")
|
||||
.HasDatabaseName("IX_share_event_target_key");
|
||||
|
||||
b.ToTable("share_event", (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<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
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,110 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlayShareTelemetry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_counter",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
track_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
partial_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
|
||||
sampled_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
|
||||
complete_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_play_counter", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_event",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
track_entry_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
release_id = table.Column<long>(type: "bigint", nullable: true),
|
||||
bucket = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_play_event", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "share_event",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
target_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
target_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_share_event", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_counter_track_id",
|
||||
table: "play_counter",
|
||||
column: "track_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_anon_id",
|
||||
table: "play_event",
|
||||
column: "anon_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_release_id",
|
||||
table: "play_event",
|
||||
column: "release_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_track_entry_key",
|
||||
table: "play_event",
|
||||
column: "track_entry_key");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_share_event_anon_id",
|
||||
table: "share_event",
|
||||
column: "anon_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_share_event_target_key",
|
||||
table: "share_event",
|
||||
column: "target_key");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_counter");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_event");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "share_event");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,138 @@ 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.PlayCounter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CompleteCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
b.Property<long>("PartialCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
b.Property<long>("SampledCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
b.Property<long>("TrackId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("track_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrackId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_play_counter_track_id");
|
||||
|
||||
b.ToTable("play_counter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Bucket")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("bucket");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("track_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_play_event_anon_id");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.HasDatabaseName("IX_play_event_release_id");
|
||||
|
||||
b.HasIndex("TrackEntryKey")
|
||||
.HasDatabaseName("IX_play_event_track_entry_key");
|
||||
|
||||
b.ToTable("play_event", (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 +178,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 +205,195 @@ 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.ShareEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("TargetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("target_key");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("target_type");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_share_event_anon_id");
|
||||
|
||||
b.HasIndex("TargetKey")
|
||||
.HasDatabaseName("IX_share_event_target_key");
|
||||
|
||||
b.ToTable("share_event", (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<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
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 +403,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,175 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DeepDrftData.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not
|
||||
/// involved). Owns the append-only writes to <c>play_event</c> / <c>share_event</c> and the
|
||||
/// incremental-on-write bump of the <c>play_counter</c> rollup (D6). Server-side release resolution
|
||||
/// (§2.3 / D4) lives here: a play event carries only the track key, and this repository joins
|
||||
/// track→release at write time and stamps the release id on the row.
|
||||
///
|
||||
/// <para>
|
||||
/// Unlike <see cref="TrackRepository"/> these entities are not <c>BaseEntity</c>/<c>IEntity</c> (no
|
||||
/// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the
|
||||
/// BlazorBlocks <c>Repository<></c> base. It holds the same scoped <see cref="DeepDrftContext"/>
|
||||
/// the rest of the SQL layer uses, never a service locator.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EventRepository
|
||||
{
|
||||
private readonly DeepDrftContext _context;
|
||||
|
||||
public EventRepository(DeepDrftContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append one play event and bump the track's counter in a single transaction (D6). The release id
|
||||
/// is resolved here from the track key (§2.3 / D4): a live track contributes its release id (null
|
||||
/// for a loose track); an unknown key records the event with a null release and no counter bump
|
||||
/// (there is no track to roll up against). Returns true when the event was written.
|
||||
/// </summary>
|
||||
public async Task<bool> RecordPlayAsync(
|
||||
string trackEntryKey, PlayBucket bucket, string? anonId, CancellationToken ct = default)
|
||||
{
|
||||
// Resolve the track→release link server-side. Soft-deleted tracks resolve to null so a play of
|
||||
// a since-removed track still logs (with no counter bump) rather than throwing.
|
||||
var track = await _context.Tracks
|
||||
.Where(t => t.EntryKey == trackEntryKey && !t.IsDeleted)
|
||||
.Select(t => new { t.Id, t.ReleaseId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// The append and the counter bump must commit together — wrap them in one transaction so a
|
||||
// counter that drifts from the log is impossible. Reuse an ambient transaction if the caller
|
||||
// already opened one.
|
||||
var ownsTransaction = _context.Database.CurrentTransaction is null;
|
||||
var transaction = ownsTransaction
|
||||
? await _context.Database.BeginTransactionAsync(ct)
|
||||
: null;
|
||||
try
|
||||
{
|
||||
_context.PlayEvents.Add(new PlayEvent
|
||||
{
|
||||
TrackEntryKey = trackEntryKey,
|
||||
ReleaseId = track?.ReleaseId,
|
||||
Bucket = bucket,
|
||||
AnonId = anonId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
if (track is not null)
|
||||
await BumpCounterAsync(track.Id, bucket, ct);
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide total plays: the sum of every counter's three bucket columns across all rows (Phase 16
|
||||
/// §5). Sums the mapped columns directly rather than <see cref="PlayCounter.TotalPlays"/>, which is an
|
||||
/// EF-ignored computed property and so not translatable. An empty counter table sums to 0 (the home
|
||||
/// card's expected reading until the telemetry migration is applied).
|
||||
/// </summary>
|
||||
public Task<long> CountTotalPlaysAsync(CancellationToken ct = default)
|
||||
=> _context.PlayCounters
|
||||
.SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Count distinct non-null anon ids across every play event (Phase 16 §3 / §4.2 — the all-time
|
||||
/// unique-listener metric, D3). Null anon ids (events where the listener sent no token, or storage
|
||||
/// was unavailable) are excluded — they are not a known listener and must not inflate the count. This
|
||||
/// is the site-wide listener reach figure; the per-track / per-release overloads scope it.
|
||||
/// </summary>
|
||||
public Task<int> CountDistinctListenersAsync(CancellationToken ct = default)
|
||||
=> _context.PlayEvents
|
||||
.Where(e => e.AnonId != null)
|
||||
.Select(e => e.AnonId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct listeners for one track, keyed by its vault entry key (the same key the play event
|
||||
/// stamps). Null anon ids excluded. Per-track scope of <see cref="CountDistinctListenersAsync()"/>.
|
||||
/// </summary>
|
||||
public Task<int> CountDistinctListenersForTrackAsync(string trackEntryKey, CancellationToken ct = default)
|
||||
=> _context.PlayEvents
|
||||
.Where(e => e.TrackEntryKey == trackEntryKey && e.AnonId != null)
|
||||
.Select(e => e.AnonId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct listeners for one release, derived across the release's tracks (D4): the play event
|
||||
/// stamps the resolved release id at write time, so a distinct count over <c>anon_id</c> filtered by
|
||||
/// <c>release_id</c> is exactly "distinct listeners who played any track in this release." Null anon
|
||||
/// ids excluded. A listener who heard two tracks of the release counts once (it is a distinct count
|
||||
/// over the union, not a sum of per-track counts).
|
||||
/// </summary>
|
||||
public Task<int> CountDistinctListenersForReleaseAsync(long releaseId, CancellationToken ct = default)
|
||||
=> _context.PlayEvents
|
||||
.Where(e => e.ReleaseId == releaseId && e.AnonId != null)
|
||||
.Select(e => e.AnonId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
|
||||
public async Task RecordShareAsync(
|
||||
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_context.ShareEvents.Add(new ShareEvent
|
||||
{
|
||||
TargetType = targetType,
|
||||
TargetKey = targetKey,
|
||||
Channel = channel,
|
||||
AnonId = anonId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Bump the matching bucket column on the track's counter row, creating the row on first play. The
|
||||
// row is added to the change tracker but not saved here — the caller's SaveChanges/commit persists
|
||||
// it inside the same transaction as the event append.
|
||||
//
|
||||
// Race note: two concurrent first-plays of the same track can both reach this method, find no
|
||||
// counter row, and both Add a new PlayCounter. The second SaveChanges will hit the unique index on
|
||||
// (track_id) and throw, causing the outer transaction to roll back and the event to be dropped —
|
||||
// no crash, no counter corruption. At the expected play volume this is an acceptable loss; the
|
||||
// unique index is the integrity backstop.
|
||||
private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct)
|
||||
{
|
||||
var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, ct);
|
||||
if (counter is null)
|
||||
{
|
||||
counter = new PlayCounter { TrackId = trackId };
|
||||
_context.PlayCounters.Add(counter);
|
||||
}
|
||||
|
||||
switch (bucket)
|
||||
{
|
||||
case PlayBucket.Partial: counter.PartialCount++; break;
|
||||
case PlayBucket.Sampled: counter.SampledCount++; break;
|
||||
case PlayBucket.Complete: counter.CompleteCount++; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user