songhive 0.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. songhive/__init__.py +11 -0
  2. songhive/__main__.py +3 -0
  3. songhive/api/__init__.py +5 -0
  4. songhive/api/_common.py +100 -0
  5. songhive/api/_include.py +39 -0
  6. songhive/api/_sorting.py +35 -0
  7. songhive/api/app.py +264 -0
  8. songhive/api/deps.py +209 -0
  9. songhive/api/errors.py +141 -0
  10. songhive/api/middleware/__init__.py +0 -0
  11. songhive/api/middleware/auth.py +84 -0
  12. songhive/api/middleware/rate_limit.py +141 -0
  13. songhive/api/responses.py +203 -0
  14. songhive/api/routes/__init__.py +47 -0
  15. songhive/api/routes/_common.py +75 -0
  16. songhive/api/routes/_images.py +77 -0
  17. songhive/api/routes/admin.py +1173 -0
  18. songhive/api/routes/albums.py +707 -0
  19. songhive/api/routes/api_tokens.py +257 -0
  20. songhive/api/routes/artists.py +546 -0
  21. songhive/api/routes/auth.py +642 -0
  22. songhive/api/routes/favorites.py +102 -0
  23. songhive/api/routes/federation.py +305 -0
  24. songhive/api/routes/files.py +387 -0
  25. songhive/api/routes/genres.py +152 -0
  26. songhive/api/routes/hashtags.py +152 -0
  27. songhive/api/routes/history.py +91 -0
  28. songhive/api/routes/instance.py +271 -0
  29. songhive/api/routes/libraries.py +1111 -0
  30. songhive/api/routes/playlists.py +774 -0
  31. songhive/api/routes/radios.py +127 -0
  32. songhive/api/routes/reports.py +146 -0
  33. songhive/api/routes/share.py +155 -0
  34. songhive/api/routes/share_urls.py +139 -0
  35. songhive/api/routes/shares.py +112 -0
  36. songhive/api/routes/tracks.py +934 -0
  37. songhive/api/routes/users.py +344 -0
  38. songhive/api/share_page.py +577 -0
  39. songhive/app.py +204 -0
  40. songhive/cli/__init__.py +3 -0
  41. songhive/cli/admin.py +577 -0
  42. songhive/config/__init__.py +4 -0
  43. songhive/config/loader.py +184 -0
  44. songhive/config/schema.py +415 -0
  45. songhive/federation/__init__.py +3 -0
  46. songhive/federation/_common.py +25 -0
  47. songhive/federation/activities.py +94 -0
  48. songhive/federation/actors.py +133 -0
  49. songhive/federation/serializers.py +93 -0
  50. songhive/federation/storage.py +99 -0
  51. songhive/migrations/__init__.py +15 -0
  52. songhive/migrations/env.py +109 -0
  53. songhive/migrations/script.py.mako +28 -0
  54. songhive/migrations/utils.py +158 -0
  55. songhive/migrations/versions/10f8d202505a_add_track_musicbrainz_enriched_at.py +35 -0
  56. songhive/migrations/versions/194ec56009fe_add_api_tokens_table.py +64 -0
  57. songhive/migrations/versions/3a8c9d2e1f45_add_image_enriched_at.py +42 -0
  58. songhive/migrations/versions/55e5cbed94e8_add_hashtags.py +91 -0
  59. songhive/migrations/versions/69bc219bdb87_add_metadata_edit_columns.py +100 -0
  60. songhive/migrations/versions/8ad99ccdcef2_make_library_playlist_radio_owner_.py +134 -0
  61. songhive/migrations/versions/d49ed7492551_base.py +25 -0
  62. songhive/migrations/versions/e4307588dfd3_add_genres.py +82 -0
  63. songhive/migrations/versions/f854f113ac09_make_track_musicbrainz_id_non_unique.py +43 -0
  64. songhive/migrations/versions/fa56625fc487_add_genre_track_inheritance.py +40 -0
  65. songhive/models/__init__.py +66 -0
  66. songhive/models/_enums.py +13 -0
  67. songhive/models/album.py +71 -0
  68. songhive/models/api_token.py +63 -0
  69. songhive/models/artist.py +53 -0
  70. songhive/models/audit_log.py +30 -0
  71. songhive/models/base.py +153 -0
  72. songhive/models/favorite.py +22 -0
  73. songhive/models/genre.py +79 -0
  74. songhive/models/hashtag.py +177 -0
  75. songhive/models/history.py +21 -0
  76. songhive/models/invite.py +29 -0
  77. songhive/models/library.py +67 -0
  78. songhive/models/library_track.py +33 -0
  79. songhive/models/oauth_client.py +61 -0
  80. songhive/models/playlist.py +70 -0
  81. songhive/models/radio.py +32 -0
  82. songhive/models/report.py +46 -0
  83. songhive/models/setting.py +28 -0
  84. songhive/models/share_grant.py +42 -0
  85. songhive/models/share_token.py +42 -0
  86. songhive/models/stored_file.py +32 -0
  87. songhive/models/track.py +93 -0
  88. songhive/models/transcoded_file.py +54 -0
  89. songhive/models/upload.py +31 -0
  90. songhive/models/user.py +91 -0
  91. songhive/models/user_link.py +45 -0
  92. songhive/music/__init__.py +3 -0
  93. songhive/music/importer.py +57 -0
  94. songhive/music/metadata.py +12 -0
  95. songhive/services/__init__.py +0 -0
  96. songhive/services/acl.py +282 -0
  97. songhive/services/admin_tasks.py +322 -0
  98. songhive/services/api_token_tracker.py +107 -0
  99. songhive/services/audit.py +304 -0
  100. songhive/services/auth.py +114 -0
  101. songhive/services/celery_admin.py +107 -0
  102. songhive/services/deletion.py +599 -0
  103. songhive/services/email.py +97 -0
  104. songhive/services/federation.py +226 -0
  105. songhive/services/genres.py +681 -0
  106. songhive/services/hashtags.py +511 -0
  107. songhive/services/import_.py +544 -0
  108. songhive/services/metadata.py +492 -0
  109. songhive/services/music.py +1110 -0
  110. songhive/services/musicbrainz.py +1265 -0
  111. songhive/services/redis.py +51 -0
  112. songhive/services/reports.py +132 -0
  113. songhive/services/settings.py +199 -0
  114. songhive/services/sharing.py +274 -0
  115. songhive/services/stats.py +197 -0
  116. songhive/services/storage.py +263 -0
  117. songhive/services/streaming.py +133 -0
  118. songhive/static/assets/AboutView-DKieBlea.css +1 -0
  119. songhive/static/assets/AboutView-bt-_sOm8.js +1 -0
  120. songhive/static/assets/AddToCollectionDialog-Bm6ATeuE.css +1 -0
  121. songhive/static/assets/AddToCollectionDialog-DqXKPg1f.js +1 -0
  122. songhive/static/assets/AlbumCard-B2O7AoLL.js +1 -0
  123. songhive/static/assets/AlbumCard-BNKCsgwg.css +1 -0
  124. songhive/static/assets/AlbumEditView-BoPYJPRQ.js +1 -0
  125. songhive/static/assets/AlbumEditView-DyfNh1Ex.css +1 -0
  126. songhive/static/assets/AlbumView-BGXsjaY0.css +1 -0
  127. songhive/static/assets/AlbumView-xJLRHn6o.js +1 -0
  128. songhive/static/assets/AlbumsView-BzR-1mAx.js +1 -0
  129. songhive/static/assets/AlbumsView-CFo1-4ck.css +1 -0
  130. songhive/static/assets/AppBanner-ClY_Bj8y.css +1 -0
  131. songhive/static/assets/AppBanner-DZ387FGW.js +1 -0
  132. songhive/static/assets/AppCheckbox-CLvYiYyd.js +1 -0
  133. songhive/static/assets/AppCheckbox-DfUo5m4X.css +1 -0
  134. songhive/static/assets/AppInput-BvOfcX7S.css +1 -0
  135. songhive/static/assets/AppInput-C8UZpDSo.js +1 -0
  136. songhive/static/assets/AppPagination-BqiOxstb.js +1 -0
  137. songhive/static/assets/AppPagination-Cg1SqJ7z.css +1 -0
  138. songhive/static/assets/AppSelect-BHk9zeEd.js +1 -0
  139. songhive/static/assets/AppSelect-DvRSiWvX.css +1 -0
  140. songhive/static/assets/AppTable-BJqp_qey.js +1 -0
  141. songhive/static/assets/AppTable-CANcd2lD.css +1 -0
  142. songhive/static/assets/ArtistEditView-BZKuZ1Js.js +1 -0
  143. songhive/static/assets/ArtistEditView-Cvv5wVUE.css +1 -0
  144. songhive/static/assets/ArtistView-68j-cDyo.css +1 -0
  145. songhive/static/assets/ArtistView-CmnpHC6h.js +1 -0
  146. songhive/static/assets/ArtistsView-Dw8Zp9uS.js +1 -0
  147. songhive/static/assets/ArtistsView-tSuYFfAR.css +1 -0
  148. songhive/static/assets/AuditView-DdUCFc9L.js +1 -0
  149. songhive/static/assets/AuditView-N1uoMt56.css +1 -0
  150. songhive/static/assets/BulkEditableGrid-B7PxH8P_.js +1 -0
  151. songhive/static/assets/BulkEditableGrid-CfWX9jz3.css +1 -0
  152. songhive/static/assets/CeleryView-JQFUmMGw.js +1 -0
  153. songhive/static/assets/CeleryView-LjpkKFnh.css +1 -0
  154. songhive/static/assets/DashboardView-7VBEirky.css +1 -0
  155. songhive/static/assets/DashboardView-eDkbDr6l.js +1 -0
  156. songhive/static/assets/DeleteModal-BBcGxskW.js +1 -0
  157. songhive/static/assets/DeleteModal-DmBbv5Ee.css +1 -0
  158. songhive/static/assets/EntityActions-CzbwCPJd.css +1 -0
  159. songhive/static/assets/EntityActions-DkBvO7Gf.js +1 -0
  160. songhive/static/assets/FavoritesView-C14k_Ctk.css +1 -0
  161. songhive/static/assets/FavoritesView-Cgl2eURe.js +1 -0
  162. songhive/static/assets/FileDetailView-B_tSQMqP.css +1 -0
  163. songhive/static/assets/FileDetailView-D67ZS61O.js +1 -0
  164. songhive/static/assets/FilesView-CbKBU4yp.js +1 -0
  165. songhive/static/assets/FilesView-DsjWX6xi.css +1 -0
  166. songhive/static/assets/ForbiddenView-CCDcSZDT.js +1 -0
  167. songhive/static/assets/ForbiddenView-CJFaq6jv.css +1 -0
  168. songhive/static/assets/GenreInput-BZ7SRQfM.css +1 -0
  169. songhive/static/assets/GenreInput-DqbN110z.js +1 -0
  170. songhive/static/assets/GenreList-BsgEnETR.js +1 -0
  171. songhive/static/assets/GenreList-kGIUP0Li.css +1 -0
  172. songhive/static/assets/GenreView-BUR2l5o6.js +1 -0
  173. songhive/static/assets/GenresView-DIbuPjTi.css +1 -0
  174. songhive/static/assets/GenresView-DgUj_GOx.js +1 -0
  175. songhive/static/assets/HashtagList-Bw9HIEti.css +1 -0
  176. songhive/static/assets/HashtagList-DVzMsmjx.js +1 -0
  177. songhive/static/assets/HashtagView-CI_SZHy4.js +1 -0
  178. songhive/static/assets/HashtagsView-BpAaUHIB.css +1 -0
  179. songhive/static/assets/HashtagsView-DB2BpBCf.js +1 -0
  180. songhive/static/assets/HistoryView-Bd8dkyCW.js +1 -0
  181. songhive/static/assets/HistoryView-DTz83IWR.css +1 -0
  182. songhive/static/assets/HomeView-BEo8E8kV.js +1 -0
  183. songhive/static/assets/ImageUploadField-BCYmyeKn.js +1 -0
  184. songhive/static/assets/ImageUploadField-DSaVBUYi.css +1 -0
  185. songhive/static/assets/InvitesView-DZ1vxcaE.js +1 -0
  186. songhive/static/assets/InvitesView-hmfKk-K5.css +1 -0
  187. songhive/static/assets/LibraryDetailView-BfwjqROM.css +1 -0
  188. songhive/static/assets/LibraryDetailView-D3XcFTpJ.js +1 -0
  189. songhive/static/assets/LibraryEditView-BiEoYPNQ.css +1 -0
  190. songhive/static/assets/LibraryEditView-CvNr2o2m.js +1 -0
  191. songhive/static/assets/LibraryView-D1jSssqI.js +1 -0
  192. songhive/static/assets/LibraryView-TgwqxIND.css +1 -0
  193. songhive/static/assets/LoginView-A2HLq23j.css +1 -0
  194. songhive/static/assets/LoginView-BU73yzPT.js +1 -0
  195. songhive/static/assets/NotFoundView-BxFXOWN-.css +1 -0
  196. songhive/static/assets/NotFoundView-De84sUhW.js +1 -0
  197. songhive/static/assets/PasswordResetConfirmView-BcBDhsjX.js +1 -0
  198. songhive/static/assets/PasswordResetConfirmView-CC_XZybH.css +1 -0
  199. songhive/static/assets/PasswordResetView-B2uexNh4.css +1 -0
  200. songhive/static/assets/PasswordResetView-BiDn9hMR.js +1 -0
  201. songhive/static/assets/PlaylistEditView-CSB3r4AJ.js +1 -0
  202. songhive/static/assets/PlaylistEditView-DZawW2Gj.css +1 -0
  203. songhive/static/assets/PlaylistView-BbXNWD9k.css +1 -0
  204. songhive/static/assets/PlaylistView-D1Uzp7DH.js +1 -0
  205. songhive/static/assets/PlaylistsView-Bb2ak7c7.css +1 -0
  206. songhive/static/assets/PlaylistsView-D7mQSRZB.js +1 -0
  207. songhive/static/assets/ProfileView-CeklF-St.css +1 -0
  208. songhive/static/assets/ProfileView-DmahCHAi.js +1 -0
  209. songhive/static/assets/RadioView-BvqHn5HI.js +1 -0
  210. songhive/static/assets/RadioView-CCydWzZ8.css +1 -0
  211. songhive/static/assets/RegisterView-CCji7WIi.css +1 -0
  212. songhive/static/assets/RegisterView-Ygn6omig.js +1 -0
  213. songhive/static/assets/ReportsView-CxDGqRWH.css +1 -0
  214. songhive/static/assets/ReportsView-QdqN-gNO.js +1 -0
  215. songhive/static/assets/SearchBar-D6YONt3S.css +1 -0
  216. songhive/static/assets/SearchBar-tAozVmFx.js +1 -0
  217. songhive/static/assets/SettingsView-BrhH6aQx.css +1 -0
  218. songhive/static/assets/SettingsView-Du5I7H31.js +1 -0
  219. songhive/static/assets/ShareDialog-BeTAlCxz.js +1 -0
  220. songhive/static/assets/ShareDialog-e0bm_N6h.css +1 -0
  221. songhive/static/assets/ShareView-Mo5eAbx-.css +1 -0
  222. songhive/static/assets/ShareView-uEuU9XgJ.js +1 -0
  223. songhive/static/assets/SkeletonLoader-BVAjfRPe.js +1 -0
  224. songhive/static/assets/SkeletonLoader-CDLOQWxb.css +1 -0
  225. songhive/static/assets/SortControl-ByWpQkTh.js +1 -0
  226. songhive/static/assets/SortControl-DK9rwXgC.css +1 -0
  227. songhive/static/assets/TagDetailView-BXVAj4mU.css +1 -0
  228. songhive/static/assets/TagDetailView-Bes26M_P.js +1 -0
  229. songhive/static/assets/TasksView-B6qqnHYq.js +1 -0
  230. songhive/static/assets/TasksView-Cbnf9_sp.css +1 -0
  231. songhive/static/assets/TrackEditView-CMaEbk5Q.js +1 -0
  232. songhive/static/assets/TrackEditView-DDROZ9y6.css +1 -0
  233. songhive/static/assets/TrackList-8bw2b-P5.js +1 -0
  234. songhive/static/assets/TrackList-CiA36Irj.css +1 -0
  235. songhive/static/assets/TrackView-BE6UYlJF.css +1 -0
  236. songhive/static/assets/TrackView-BNVa0oYo.js +1 -0
  237. songhive/static/assets/TracksView-2dNSYd86.js +1 -0
  238. songhive/static/assets/TracksView-CsYYiU4K.css +1 -0
  239. songhive/static/assets/UsersView-BrY7DJ9P.css +1 -0
  240. songhive/static/assets/UsersView-DR6wkxr5.js +1 -0
  241. songhive/static/assets/VerifyEmailView--lrF2mRv.js +1 -0
  242. songhive/static/assets/VerifyEmailView-CNDB5h5C.css +1 -0
  243. songhive/static/assets/admin-CFiq-f0G.js +1 -0
  244. songhive/static/assets/albums-B4MvYRjc.js +1 -0
  245. songhive/static/assets/artists-DKfgHH_Q.js +1 -0
  246. songhive/static/assets/enrich-Bxwh0z_4.js +1 -0
  247. songhive/static/assets/entity-DaWPHepj.js +1 -0
  248. songhive/static/assets/fa-brands-400-D1LuMI3I.ttf +0 -0
  249. songhive/static/assets/fa-brands-400-D_cYUPeE.woff2 +0 -0
  250. songhive/static/assets/fa-regular-400-BjRzuEpd.woff2 +0 -0
  251. songhive/static/assets/fa-regular-400-DZaxPHgR.ttf +0 -0
  252. songhive/static/assets/fa-solid-900-CTAAxXor.woff2 +0 -0
  253. songhive/static/assets/fa-solid-900-D0aA9rwL.ttf +0 -0
  254. songhive/static/assets/fa-v4compatibility-C9RhG_FT.woff2 +0 -0
  255. songhive/static/assets/fa-v4compatibility-CCth-dXg.ttf +0 -0
  256. songhive/static/assets/files-D77ujQWP.js +1 -0
  257. songhive/static/assets/genres-joIx5zmg.js +1 -0
  258. songhive/static/assets/hashtags-wRn1KQRn.js +1 -0
  259. songhive/static/assets/index-CxM6uG64.js +49 -0
  260. songhive/static/assets/index-PD_9JJZo.css +5 -0
  261. songhive/static/assets/libraries-uXDDcnhn.js +1 -0
  262. songhive/static/assets/playlists-DrK-KdGh.js +1 -0
  263. songhive/static/assets/share-Bg_PG2U-.js +1 -0
  264. songhive/static/assets/tracks-DHXuq6SE.js +1 -0
  265. songhive/static/assets/useCanManage-BnzH5ISp.js +1 -0
  266. songhive/static/assets/useChunkList-B4d_-AI4.js +1 -0
  267. songhive/static/assets/useConfirm-C79C6NEi.js +1 -0
  268. songhive/static/assets/useDebounce-CJIsE33m.js +1 -0
  269. songhive/static/assets/useEntityDelete-CGjTY8t8.js +1 -0
  270. songhive/static/assets/useEntityList-Bph6V0yj.js +1 -0
  271. songhive/static/assets/useEntityMeta-9Y3jxiSa.js +1 -0
  272. songhive/static/assets/useMediaQuery-BHGK0rwT.js +1 -0
  273. songhive/static/favicon.ico +0 -0
  274. songhive/static/index.html +14 -0
  275. songhive/static/logo.png +0 -0
  276. songhive/storage/__init__.py +34 -0
  277. songhive/storage/base.py +78 -0
  278. songhive/storage/exc.py +17 -0
  279. songhive/storage/local.py +84 -0
  280. songhive/storage/s3.py +218 -0
  281. songhive/streaming/__init__.py +4 -0
  282. songhive/streaming/handler.py +465 -0
  283. songhive/streaming/transcoder.py +161 -0
  284. songhive/tasks/__init__.py +14 -0
  285. songhive/tasks/api_tokens.py +38 -0
  286. songhive/tasks/celery.py +90 -0
  287. songhive/tasks/email.py +34 -0
  288. songhive/tasks/federation.py +241 -0
  289. songhive/tasks/images.py +100 -0
  290. songhive/tasks/import_.py +180 -0
  291. songhive/tasks/musicbrainz.py +60 -0
  292. songhive/tasks/storage.py +114 -0
  293. songhive/tasks/tags.py +320 -0
  294. songhive/tasks/transcoding.py +96 -0
  295. songhive/users/__init__.py +3 -0
  296. songhive/users/api_tokens.py +171 -0
  297. songhive/users/invites.py +166 -0
  298. songhive/users/manager.py +589 -0
  299. songhive/users/oauth.py +911 -0
  300. songhive/users/tokens.py +253 -0
  301. songhive/version.py +1 -0
  302. songhive/ws/__init__.py +3 -0
  303. songhive/ws/events.py +162 -0
  304. songhive-0.0.8.dist-info/METADATA +327 -0
  305. songhive-0.0.8.dist-info/RECORD +309 -0
  306. songhive-0.0.8.dist-info/WHEEL +5 -0
  307. songhive-0.0.8.dist-info/entry_points.txt +2 -0
  308. songhive-0.0.8.dist-info/licenses/LICENSE +235 -0
  309. songhive-0.0.8.dist-info/top_level.txt +1 -0
songhive/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from .version import __version__
2
+
3
+
4
+ def main():
5
+ """Songhive application entry point."""
6
+ from .app import main as _main
7
+
8
+ _main()
9
+
10
+
11
+ __all__ = ["main", "__version__"]
songhive/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from songhive import main
2
+
3
+ main()
@@ -0,0 +1,5 @@
1
+ """API package."""
2
+
3
+ # Avoid importing `create_app` at package level: it pulls in all API routes and
4
+ # can create circular imports when `users.tokens` imports
5
+ # `api.middleware.auth.create_access_token` during CLI startup.
@@ -0,0 +1,100 @@
1
+ """Shared helpers used by both FastAPI routes and middleware."""
2
+
3
+ import ipaddress
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from fastapi import Query, Request, Response
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def client_ip( # pylint: disable=too-many-branches
14
+ request: Request,
15
+ *,
16
+ trusted_hops: Optional[int] = None,
17
+ ) -> Optional[str]:
18
+ """
19
+ Return the client IP address, honoring common proxy headers.
20
+
21
+ The lookup order is:
22
+
23
+ 1. ``X-Forwarded-For`` if ``trusted_hops`` is configured or the header
24
+ is present (leftmost valid IP is treated as the originating client).
25
+ 2. ``X-Real-IP``.
26
+ 3. The RFC 7239 ``Forwarded`` header (``for=...`` parameter).
27
+ 4. ``request.client.host``.
28
+
29
+ ``trusted_hops`` controls how many entries from the right of the
30
+ ``X-Forwarded-For`` chain are skipped. A value of ``0`` means the header
31
+ is not trusted and is ignored. If ``None`` (the default), the header is
32
+ used without a configured trust depth and the leftmost valid address is
33
+ returned.
34
+ """
35
+ forwarded: Optional[str] = request.headers.get("X-Forwarded-For")
36
+ if forwarded and trusted_hops != 0:
37
+ parts = [ip.strip() for ip in forwarded.split(",") if ip.strip()]
38
+ if trusted_hops is not None and trusted_hops > 0:
39
+ if len(parts) > trusted_hops:
40
+ candidate = parts[-(trusted_hops + 1)]
41
+ if _is_valid_ip(candidate):
42
+ return candidate
43
+ else:
44
+ for candidate in parts:
45
+ if _is_valid_ip(candidate):
46
+ return candidate
47
+
48
+ real_ip: Optional[str] = request.headers.get("X-Real-IP")
49
+ if real_ip:
50
+ candidate = real_ip.strip()
51
+ if _is_valid_ip(candidate):
52
+ return candidate
53
+
54
+ forwarded_rfc: Optional[str] = request.headers.get("Forwarded")
55
+ if forwarded_rfc:
56
+ for directive in forwarded_rfc.replace(";", ",").split(","):
57
+ directive = directive.strip()
58
+ if directive.lower().startswith("for="):
59
+ value = directive[4:].strip().strip('"')
60
+ if value.startswith("["):
61
+ value = value.split("]")[0][1:]
62
+ elif ":" in value:
63
+ value = value.rsplit(":", 1)[0]
64
+ if value and value != "_hidden" and not value.startswith("_") and _is_valid_ip(value):
65
+ return value
66
+
67
+ if request.client and request.client.host:
68
+ return request.client.host
69
+
70
+ return None
71
+
72
+
73
+ def _is_valid_ip(value: str) -> bool:
74
+ """Return True if ``value`` is a valid IPv4 or IPv6 address."""
75
+ try:
76
+ ipaddress.ip_address(value)
77
+ except ValueError:
78
+ logger.debug("Ignoring invalid IP address %r", value)
79
+ return False
80
+ return True
81
+
82
+
83
+ @dataclass
84
+ class Pagination:
85
+ """Pagination parameters and helper to set the ``X-Total-Count`` header."""
86
+
87
+ limit: int
88
+ offset: int
89
+
90
+ def set_total(self, response: Response, total: int) -> None:
91
+ """Set the ``X-Total-Count`` header on ``response``."""
92
+ response.headers["X-Total-Count"] = str(total)
93
+
94
+
95
+ async def get_pagination(
96
+ limit: int = Query(20, ge=1, le=100),
97
+ offset: int = Query(0, ge=0),
98
+ ) -> Pagination:
99
+ """FastAPI dependency that parses ``limit``/``offset`` query params."""
100
+ return Pagination(limit=limit, offset=offset)
@@ -0,0 +1,39 @@
1
+ """Shared `?include=` query parameter helper."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Set
5
+
6
+ from fastapi import Query
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class IncludeQuery:
11
+ """Parsed and validated `?include=` query parameter."""
12
+
13
+ values: Set[str]
14
+
15
+ def __contains__(self, item: str) -> bool:
16
+ return item in self.values
17
+
18
+ def has(self, *items: str) -> bool:
19
+ return any(item in self.values for item in items)
20
+
21
+
22
+ def get_include(allowed: Set[str]):
23
+ """Return a FastAPI dependency that parses a comma-separated `?include=` list."""
24
+
25
+ def _parse(
26
+ include: Optional[str] = Query(
27
+ None,
28
+ description=f"Comma-separated relations to include. Allowed: {', '.join(sorted(allowed))}",
29
+ )
30
+ ) -> IncludeQuery:
31
+ values: Set[str] = set()
32
+ if include:
33
+ for part in include.split(","):
34
+ part = part.strip().lower()
35
+ if part in allowed:
36
+ values.add(part)
37
+ return IncludeQuery(values)
38
+
39
+ return _parse
@@ -0,0 +1,35 @@
1
+ """Shared sort parameter parsing for list endpoints."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Optional, Set
5
+
6
+ from fastapi import Query
7
+
8
+
9
+ @dataclass
10
+ class SortParams:
11
+ """Validated sort parameters for a list request."""
12
+
13
+ field: str
14
+ direction: str
15
+
16
+
17
+ def get_sort(
18
+ allowed: Set[str],
19
+ default_field: str,
20
+ default_dir: str = "asc",
21
+ ) -> Callable[..., Any]:
22
+ """
23
+ Build a FastAPI dependency that parses ``sort_by`` / ``sort_dir`` query
24
+ parameters and falls back to safe defaults for unknown values.
25
+ """
26
+
27
+ async def _dep(
28
+ sort_by: Optional[str] = Query(None, description="Field to sort by"),
29
+ sort_dir: Optional[str] = Query(None, description="Sort direction (asc or desc)"),
30
+ ) -> SortParams:
31
+ field = sort_by if sort_by in allowed else default_field
32
+ direction = sort_dir if sort_dir in ("asc", "desc") else default_dir
33
+ return SortParams(field=field, direction=direction)
34
+
35
+ return _dep
songhive/api/app.py ADDED
@@ -0,0 +1,264 @@
1
+ """
2
+ FastAPI application factory.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+ from typing import Any, AsyncGenerator, MutableMapping
10
+
11
+ from fastapi import FastAPI, HTTPException
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import FileResponse
14
+ from starlette.types import Receive, Send
15
+
16
+ from ..config.schema import SonghiveConfig
17
+ from ..models.base import get_session, init_db
18
+ from ..services.acl import audit_ownerless_private
19
+ from ..services.redis import close_redis_client, get_redis_client
20
+ from ..services.settings import apply_settings_overrides
21
+ from ..version import __version__
22
+ from .errors import install_error_handlers
23
+ from .routes import (
24
+ admin,
25
+ albums,
26
+ api_tokens,
27
+ artists,
28
+ auth,
29
+ favorites,
30
+ federation,
31
+ files,
32
+ genres,
33
+ hashtags,
34
+ history,
35
+ instance,
36
+ libraries,
37
+ playlists,
38
+ radios,
39
+ reports,
40
+ share,
41
+ share_urls,
42
+ shares,
43
+ tracks,
44
+ users,
45
+ )
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ def _sync_settings_overlay(config: SonghiveConfig) -> tuple[SonghiveConfig, bool]:
51
+ """
52
+ Apply DB settings overrides synchronously when no event loop is running.
53
+
54
+ ``create_app`` may be called before an asyncio loop exists (e.g. in a
55
+ worker process or during CLI startup), so this path runs the overlay in a
56
+ temporary event loop. When a loop is already running, it skips and lets the
57
+ ``_lifespan`` hook apply the overlay in the real application loop.
58
+ """
59
+ try:
60
+ asyncio.get_running_loop()
61
+ except RuntimeError:
62
+ pass
63
+ else:
64
+ return config, False
65
+
66
+ async def _run() -> SonghiveConfig:
67
+ async with get_session() as session:
68
+ return await apply_settings_overrides(session, config)
69
+
70
+ loop = None
71
+ try:
72
+ loop = asyncio.new_event_loop()
73
+ # ``wait_for`` provides a hard ceiling so a stuck database query cannot
74
+ # block server startup forever. Avoid ``asyncio.run`` here because it
75
+ # sets/unsets the current event loop, which can break Tornado's
76
+ # IOLoop when ``create_app`` is called from an async test context.
77
+ return loop.run_until_complete(asyncio.wait_for(_run(), timeout=10.0)), True
78
+ except asyncio.TimeoutError:
79
+ logger.warning("Settings overlay timed out; using config file/defaults")
80
+ return config, False
81
+ except Exception:
82
+ logger.exception("Failed to apply settings overrides at app creation")
83
+ return config, False
84
+ finally:
85
+ if loop is not None:
86
+ loop.close()
87
+
88
+
89
+ def _setup_static_routes(app: FastAPI):
90
+ """
91
+ Serve the built frontend SPA as the default handler.
92
+
93
+ API/WebSocket/stream paths are excluded so unknown /api/... /ws/...
94
+ /stream/... routes still 404. The default is only reached when no API route
95
+ (or redirect-slashes partial match) matches, so it never shadows API
96
+ endpoints.
97
+ """
98
+ static_dir = Path(__file__).resolve().parent.parent / "static"
99
+ if (static_dir / "index.html").is_file():
100
+
101
+ async def _serve_static(scope: MutableMapping[str, Any], receive: Receive, send: Send) -> None:
102
+ if scope["type"] != "http":
103
+ raise HTTPException(status_code=404)
104
+
105
+ path = scope["path"].lstrip("/")
106
+ if path.startswith("api/") or path.startswith("ws/") or path.startswith("stream/"):
107
+ raise HTTPException(status_code=404)
108
+
109
+ # Federation/ActivityPub requests to disabled endpoints should 404,
110
+ # not receive the SPA HTML shell.
111
+ for name, value in scope.get("headers", []):
112
+ if name.lower() == b"accept" and b"application/activity+json" in value:
113
+ raise HTTPException(status_code=404)
114
+
115
+ requested = (static_dir / path).resolve()
116
+ static_root = static_dir.resolve()
117
+ if requested.is_file() and str(requested).startswith(str(static_root)):
118
+ await FileResponse(requested)(scope, receive, send)
119
+ else:
120
+ await FileResponse(static_dir / "index.html")(scope, receive, send)
121
+
122
+ app.router.default = _serve_static
123
+
124
+
125
+ def create_app(config: SonghiveConfig) -> FastAPI:
126
+ """
127
+ Create and configure the FastAPI application.
128
+
129
+ :param config: The application configuration.
130
+ :returns: A configured FastAPI instance.
131
+ """
132
+ init_db(config.database.url)
133
+
134
+ @asynccontextmanager
135
+ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
136
+ app.state.redis = get_redis_client(config)
137
+
138
+ # If the synchronous overlay in create_app already ran, there is no
139
+ # need to re-read the same settings here. When it was skipped (because
140
+ # a loop was already running), this is the first chance to apply DB
141
+ # settings overrides using the real application loop and Redis.
142
+ if not getattr(app.state, "_sync_overlay_applied", False):
143
+ try:
144
+ async with get_session() as session:
145
+ await audit_ownerless_private(session)
146
+ app.state.config = await apply_settings_overrides(session, app.state.config)
147
+ except Exception:
148
+ logger.exception("Failed to apply settings overrides during startup")
149
+
150
+ yield
151
+ await close_redis_client()
152
+
153
+ app = FastAPI(
154
+ title="Songhive",
155
+ description="A federated and self-hosted music sharing service",
156
+ version=__version__,
157
+ debug=config.server.debug,
158
+ lifespan=_lifespan,
159
+ )
160
+
161
+ # Store config in app state for dependency injection. The synchronous
162
+ # overlay above handles the no-event-loop case; _lifespan handles the
163
+ # rest. Both exist because create_app is called in different contexts.
164
+ config, sync_overlay_applied = _sync_settings_overlay(config)
165
+ app.state.config = config
166
+ app.state._sync_overlay_applied = sync_overlay_applied
167
+ app.state.storage_service = None
168
+ app.state.storage_service_config = None
169
+
170
+ # CORS middleware
171
+ allow_credentials = True
172
+ if "*" in config.server.cors_origins:
173
+ logger.warning(
174
+ "Wildcard CORS origin (['*']) cannot be used with credentials; "
175
+ "disabling allow_credentials. Specify explicit origins to enable credentials."
176
+ )
177
+ allow_credentials = False
178
+
179
+ app.add_middleware(
180
+ CORSMiddleware,
181
+ allow_origins=config.server.cors_origins,
182
+ allow_credentials=allow_credentials,
183
+ allow_methods=["*"],
184
+ allow_headers=["*"],
185
+ )
186
+
187
+ # Register RFC 7807 problem detail exception handlers
188
+ install_error_handlers(app)
189
+
190
+ # Register API routes
191
+ api_prefix = "/api/v1"
192
+ app.include_router(auth.router, prefix=api_prefix, tags=["auth"])
193
+ app.include_router(api_tokens.router, prefix=api_prefix, tags=["api-tokens"])
194
+ app.include_router(users.router, prefix=api_prefix, tags=["users"])
195
+ app.include_router(artists.router, prefix=api_prefix, tags=["artists"])
196
+ app.include_router(albums.router, prefix=api_prefix, tags=["albums"])
197
+ app.include_router(tracks.router, prefix=api_prefix, tags=["tracks"])
198
+ app.include_router(playlists.router, prefix=api_prefix, tags=["playlists"])
199
+ app.include_router(libraries.router, prefix=api_prefix, tags=["libraries"])
200
+ app.include_router(hashtags.router, prefix=api_prefix, tags=["hashtags"])
201
+ app.include_router(genres.router, prefix=api_prefix, tags=["genres"])
202
+ app.include_router(favorites.router, prefix=api_prefix, tags=["favorites"])
203
+ app.include_router(history.router, prefix=api_prefix, tags=["history"])
204
+ app.include_router(radios.router, prefix=api_prefix, tags=["radios"])
205
+ app.include_router(reports.router, prefix=api_prefix, tags=["reports"])
206
+ app.include_router(reports.admin_router, prefix=api_prefix, tags=["reports"])
207
+ app.include_router(admin.router, prefix=api_prefix, tags=["admin"])
208
+ app.include_router(files.router, prefix=api_prefix, tags=["files"])
209
+ app.include_router(shares.router, prefix=api_prefix, tags=["shares"])
210
+ app.include_router(share_urls.router, prefix=api_prefix, tags=["share-urls"])
211
+ app.include_router(share.router, prefix=api_prefix, tags=["share"])
212
+ app.include_router(instance.v1_router, prefix="/api/v1")
213
+ app.include_router(instance.v2_router, prefix="/api/v2")
214
+
215
+ # Federation routes
216
+ if config.federation.enabled and config.federation.instance_domain:
217
+ app.include_router(federation.router)
218
+ _setup_federation(app, config)
219
+
220
+ _setup_static_routes(app)
221
+
222
+ return app
223
+
224
+
225
+ def _setup_federation(app: FastAPI, config: SonghiveConfig):
226
+ """Set up ActivityPub federation routes via pubby."""
227
+ try:
228
+ from pubby import ActivityPubHandler, ActorConfig
229
+ from pubby.server.adapters.fastapi import bind_activitypub
230
+ from pubby.server.adapters.fastapi_mastodon import bind_mastodon_api
231
+
232
+ from ..federation.storage import (
233
+ create_activitypub_storage,
234
+ get_or_create_private_key,
235
+ )
236
+
237
+ actor_config = ActorConfig(
238
+ base_url=f"https://{config.federation.instance_domain}",
239
+ username=config.federation.instance_name.lower().replace(" ", "-"),
240
+ name=config.federation.instance_name,
241
+ summary=config.federation.instance_description,
242
+ actor_path="/ap/actor",
243
+ type="Application",
244
+ )
245
+
246
+ storage = create_activitypub_storage(config.database.url)
247
+ private_key_path = get_or_create_private_key(config.federation.private_key_path)
248
+
249
+ handler = ActivityPubHandler(
250
+ storage=storage,
251
+ actor_config=actor_config,
252
+ private_key_path=str(private_key_path),
253
+ )
254
+ bind_activitypub(app, handler, prefix="/ap")
255
+ bind_mastodon_api(
256
+ app,
257
+ handler,
258
+ title=config.federation.instance_name,
259
+ description=config.federation.instance_description,
260
+ software_name="Songhive",
261
+ software_version=__version__,
262
+ )
263
+ except ImportError:
264
+ logger.error("Federation is enabled but pubby is not installed")
songhive/api/deps.py ADDED
@@ -0,0 +1,209 @@
1
+ """
2
+ FastAPI dependency injection helpers.
3
+ """
4
+
5
+ from typing import AsyncGenerator, Optional
6
+
7
+ from fastapi import Depends, HTTPException, Request, Security, status
8
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
9
+ from redis.asyncio import Redis
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from ..config.schema import SonghiveConfig
13
+ from ..models.base import get_session
14
+ from ..models.user import User
15
+ from ..services import acl
16
+ from ..services.auth import get_user_by_id
17
+ from ..services.storage import StorageService
18
+ from ..storage import get_storage
19
+ from ..users.api_tokens import validate_api_token
20
+ from .middleware.auth import decode_access_token, decode_token_payload, extract_token
21
+
22
+ bearer_scheme = HTTPBearer(
23
+ auto_error=False,
24
+ scheme_name="BearerAuth",
25
+ bearerFormat="JWT",
26
+ description="JWT access token obtained from /api/v1/auth/login or /api/v1/auth/refresh",
27
+ )
28
+
29
+
30
+ def _token_from_credentials_or_request(
31
+ request: Request,
32
+ credentials: Optional[HTTPAuthorizationCredentials],
33
+ ) -> Optional[str]:
34
+ """Extract the bearer token from resolved credentials or the request header."""
35
+ if isinstance(credentials, HTTPAuthorizationCredentials):
36
+ return credentials.credentials
37
+ return extract_token(request)
38
+
39
+
40
+ def get_config(request: Request) -> SonghiveConfig:
41
+ """Get the application config from the request state."""
42
+ config: SonghiveConfig = request.app.state.config
43
+ return config
44
+
45
+
46
+ async def get_effective_config(request: Request) -> SonghiveConfig:
47
+ """Return the effective config as stored in app state."""
48
+ return get_config(request)
49
+
50
+
51
+ def get_redis(request: Request) -> Redis:
52
+ """Get the shared async Redis client from the request state."""
53
+ redis: Redis = request.app.state.redis
54
+ return redis
55
+
56
+
57
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
58
+ """Get a database session via dependency injection."""
59
+ async with get_session() as session:
60
+ yield session
61
+
62
+
63
+ async def _get_current_user(
64
+ request: Request,
65
+ db: AsyncSession,
66
+ credentials: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
67
+ ) -> Optional[User]:
68
+ """Extract and validate the current user, returning ``None`` on any failure."""
69
+ token = _token_from_credentials_or_request(request, credentials)
70
+ if not token:
71
+ return None
72
+
73
+ config = get_config(request)
74
+
75
+ # Peek at the payload without enforcing ``exp`` so we can route by token type.
76
+ payload = decode_token_payload(token, config.auth.secret_key)
77
+ if payload is None:
78
+ return None
79
+
80
+ # Slow path: API-token JWT (exp enforced at DB row level).
81
+ if payload.get("token_type") == "api_token":
82
+ jti = payload.get("jti")
83
+ if not jti:
84
+ return None
85
+ redis = get_redis(request)
86
+ api_token = await validate_api_token(db, jti, redis=redis)
87
+ if api_token is None:
88
+ return None
89
+ user = await get_user_by_id(db, api_token.user_id)
90
+ return user if user is not None and user.is_active else None
91
+
92
+ # Fast path: short-lived access JWT (PyJWT enforces exp).
93
+ user_id = decode_access_token(token, config.auth.secret_key)
94
+ if user_id is not None:
95
+ user = await get_user_by_id(db, user_id)
96
+ return user if user is not None and user.is_active else None
97
+
98
+ return None
99
+
100
+
101
+ async def get_current_user(
102
+ request: Request,
103
+ db: AsyncSession = Depends(get_db),
104
+ credentials: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
105
+ ) -> User:
106
+ """
107
+ Extract and validate the current user from the request.
108
+
109
+ Returns the User model instance or raises 401 for missing, invalid,
110
+ inactive, or deleted users.
111
+ """
112
+ user = await _get_current_user(request, db, credentials)
113
+ if user is None:
114
+ raise HTTPException(
115
+ status_code=status.HTTP_401_UNAUTHORIZED,
116
+ detail="Not authenticated",
117
+ headers={"WWW-Authenticate": "Bearer"},
118
+ )
119
+ return user
120
+
121
+
122
+ async def get_current_user_optional(
123
+ request: Request,
124
+ db: AsyncSession = Depends(get_db),
125
+ credentials: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
126
+ ) -> Optional[User]:
127
+ """Extract and validate the current user, returning ``None`` when unauthenticated."""
128
+ return await _get_current_user(request, db, credentials)
129
+
130
+
131
+ def _get_share_token(request: Request) -> Optional[str]:
132
+ """Read a share token from the header, cookie, or query string (legacy)."""
133
+ token = request.headers.get("X-Share-Token")
134
+ if token:
135
+ return token
136
+ token = request.cookies.get("share_token")
137
+ if token:
138
+ return token
139
+ return request.query_params.get("token")
140
+
141
+
142
+ def require_access(item_type: str):
143
+ """Return a FastAPI dependency that enforces access to ``item_type`` resources."""
144
+ id_key = acl.ITEM_ID_KEYS.get(item_type)
145
+ if id_key is None:
146
+ raise RuntimeError(f"Unknown item type: {item_type!r}")
147
+
148
+ async def _dep(
149
+ request: Request,
150
+ db: AsyncSession = Depends(get_db),
151
+ user: Optional[User] = Depends(get_current_user_optional),
152
+ token: Optional[str] = Depends(_get_share_token),
153
+ ) -> bool:
154
+ """Load the requested item and verify the requester may access it."""
155
+ item_id = request.path_params.get(id_key)
156
+ if not item_id:
157
+ raise HTTPException(
158
+ status_code=status.HTTP_404_NOT_FOUND,
159
+ detail="Not found",
160
+ )
161
+
162
+ item = await acl.get_item(db, item_type, item_id)
163
+ if item is None:
164
+ raise HTTPException(
165
+ status_code=status.HTTP_404_NOT_FOUND,
166
+ detail="Not found",
167
+ )
168
+
169
+ if not await acl.can_access(db, user, item_type, item_id, share_token=token):
170
+ raise HTTPException(
171
+ status_code=status.HTTP_403_FORBIDDEN,
172
+ detail="Access denied",
173
+ )
174
+
175
+ return True
176
+
177
+ return _dep
178
+
179
+
180
+ def get_storage_service(request: Request) -> StorageService:
181
+ """
182
+ Get or create a cached StorageService, recreating it when storage config
183
+ changes.
184
+ """
185
+ config = get_config(request)
186
+ current = config.storage
187
+ cached = getattr(request.app.state, "storage_service", None)
188
+ cached_config = getattr(request.app.state, "storage_service_config", None)
189
+
190
+ # Compare against a deep copy so runtime config mutations (e.g. in tests) force a
191
+ # fresh backend and prevent a cached service from using stale connection state.
192
+ if cached is not None and cached_config == current:
193
+ return cached
194
+
195
+ backend = get_storage(current)
196
+ service = StorageService(backend, current)
197
+ request.app.state.storage_service = service
198
+ request.app.state.storage_service_config = current.model_copy(deep=True)
199
+ return service
200
+
201
+
202
+ async def require_admin(current_user: User = Depends(get_current_user)):
203
+ """Require admin privileges."""
204
+ if not current_user.is_admin:
205
+ raise HTTPException(
206
+ status_code=status.HTTP_403_FORBIDDEN,
207
+ detail="Admin access required",
208
+ )
209
+ return current_user