feat(downloads): local file upload ingest — media library backend (E7 S5a)

Add the non-yt-dlp ingest path so a user-uploaded file joins the same download
library and inherits its function set. Any file type is accepted: media
(video/audio) gets the full set (metadata, share, play/edit), a generic file
(pdf/zip/docx/…) is download-only.

- MediaAsset.media_kind ("video"|"audio"|"file") — the stored discriminator that
  gates library actions. Set at every finalize (download probe, editor carries the
  source's kind, upload probe); consolidates the ad-hoc audio-detect that lived only
  in links.py into one invariant (downloads.mediakind.derive). Migration 0076 adds
  the column + backfills existing rows.
- downloads/upload.py: ingest_upload probes the file (ffprobe → codecs/dims/duration),
  places it in a per-user .uploads tree, and creates an already-ready asset + done job
  (the same cache-hit shortcut enqueue uses). Uploads never expire (irreplaceable).
- POST /api/downloads/upload: streaming multipart with the per-file cap
  (download_upload_max_bytes, default 500 MiB) enforced on actual bytes read; quota
  charges the known size up front (quota.check_upload).
- Security: a generic file is always served force-download (attachment +
  application/octet-stream) on both the in-app and public endpoints, so uploaded
  HTML/SVG can't execute inline. Edit + redownload reject non-media / upload sources.
- python-multipart added (first multipart endpoint in the app).
This commit is contained in:
2026-08-29 08:02:11 +02:00
parent 9eaa01617c
commit fe0b34cd00
17 changed files with 578 additions and 19 deletions
+10 -5
View File
@@ -65,6 +65,9 @@ def _asset(**over):
height=720,
vcodec="h264",
acodec="aac",
media_kind="video",
size_bytes=1000,
container="mp4",
source_webpage_url=None,
source_kind="url",
)
@@ -74,12 +77,13 @@ def _asset(**over):
class TestPublicMetaAudioFlag:
"""The watch page picks <audio> vs <video> from this flag; WebKit refuses an audio-only mp3 in a
<video>, so an audio-only asset (codec present, no video stream) must report audio=True."""
<video>, so an audio-only asset (media_kind='audio') must report audio=True. The flag now reads
the stored media_kind (the single source), not a re-derived codec check."""
_link = SimpleNamespace(allow_download=True)
def test_audio_only_asset_is_flagged_audio(self):
asset = _asset(vcodec=None, acodec="mp3", width=None, height=None)
asset = _asset(media_kind="audio", vcodec=None, acodec="mp3", width=None, height=None)
meta = public_meta(self._link, None, asset, "/f.mp3")
assert meta["audio"] is True
@@ -87,10 +91,11 @@ class TestPublicMetaAudioFlag:
meta = public_meta(self._link, None, _asset(), "/f.mp4")
assert meta["audio"] is False
def test_a_silent_video_is_not_audio(self):
# Video present but no audio track — still a video player, not the audio bar.
meta = public_meta(self._link, None, _asset(acodec=None), "/f.mp4")
def test_a_generic_file_is_not_audio(self):
# A non-media upload (media_kind='file') has no player at all — never the audio bar.
meta = public_meta(self._link, None, _asset(media_kind="file"), "/f.bin")
assert meta["audio"] is False
assert meta["media_kind"] == "file"
class TestCreateLinkDefault: