Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: CI

# Only Go sources feed the build/vet/test steps, so doc- and asset-only
# changes skip CI. schema.sql is go:embed'ed, hence included.
# Only Go sources feed the build/vet/test steps, so doc- and asset-only changes skip CI. schema.sql is go:embed'ed, hence included.
on:
push:
branches: [main]
Expand Down
13 changes: 7 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,20 @@ on:
workflow_dispatch:

permissions:
actions: write
contents: write
packages: write

jobs:
binaries:
name: Build and attach binaries
# Only a real release has somewhere to upload to; a manual dispatch run
# builds the image but skips this job.
# Only a real release has somewhere to upload to; a manual dispatch run builds the image but skips this job.
if: github.event_name == 'release'
runs-on: ubuntu-latest
strategy:
matrix:
# Mirrors the platforms the Docker image is built for; there is no
# bundled yt-dlp/ffmpeg/imagemagick here, so this is the bare Go
# binary only, for people who already have those tools on PATH.
# bundled yt-dlp/ffmpeg/imagemagick here, so this is the bare Go binary only, for people who already have those tools on PATH.
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v7
Expand Down Expand Up @@ -91,5 +90,7 @@ jobs:
APP_VERSION=${{ github.ref_name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Use the v2 cache service explicitly. The legacy v1 service can
# return `not_found` while exporting a layer after the image push has already succeeded.
cache-from: type=gha,scope=aiofiles,version=2
cache-to: type=gha,scope=aiofiles,mode=max,version=2,ignore-error=true
28 changes: 23 additions & 5 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,23 @@ function isIOS() {
return /Macintosh/.test(ua) && (window.navigator.maxTouchPoints || 0) > 1;
}

var SAVE_TYPES = {
mp4: "video/mp4", m4v: "video/mp4", mov: "video/quicktime", webm: "video/webm",
mkv: "video/x-matroska", mp3: "audio/mpeg", m4a: "audio/mp4", aac: "audio/aac",
ogg: "audio/ogg", opus: "audio/ogg", wav: "audio/wav", flac: "audio/flac",
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
webp: "image/webp", avif: "image/avif", heic: "image/heic", heif: "image/heif",
tif: "image/tiff", tiff: "image/tiff", bmp: "image/bmp",
};

/* The share sheet only offers Save to Photos for a recognised image or video
type, so a generic or missing one is replaced by the extension's. */
function saveType(blob, name) {
var type = String(blob.type || "").toLowerCase();
if (/^(image|video|audio)\//.test(type)) return type;
return SAVE_TYPES[extOf(name)] || type || "application/octet-stream";
}

/* Mirrors the .job phone breakpoint in app.css. */
function isMobileViewport() {
return !!(window.matchMedia && window.matchMedia("(max-width: 680px)").matches);
Expand Down Expand Up @@ -1272,7 +1289,9 @@ function mediaApp() {
onPick(event, tab) {
var files = event.target.files;
if (files && files.length) this.addFiles(files, tab);
event.target.value = "";
/* Not cleared here: iOS drops the picked files' temp copies when the
input is reset, before they have been read. The input resets on its
next click instead. */
},

/* One batch, one kind: the options on screen belong to either a video or
Expand Down Expand Up @@ -2382,9 +2401,7 @@ function mediaApp() {
if (tick !== saveTick) return;
saveRequest = null;

savePending = new File([blob], name, {
type: blob.type || "application/octet-stream",
});
savePending = new File([blob], name, { type: saveType(blob, name) });
this.save.phase = "ready";
/* The share sheet wants a fresh user gesture and the fetch has probably
outlived the tap that started it. Try anyway - a short fetch usually
Expand All @@ -2398,7 +2415,8 @@ function mediaApp() {
var file = savePending;
if (!file) return;

var payload = { files: [file], title: file.name };
/* No title or text: iOS saves those as an extra .txt next to the file. */
var payload = { files: [file] };
var sharable =
navigator.canShare && navigator.share && navigator.canShare(payload);
/* No file sharing on old iOS, or on plain HTTP where it is undefined. */
Expand Down
22 changes: 19 additions & 3 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,8 @@ <h1>New job</h1>
<input
type="file"
multiple
accept="image/*,video/*,audio/*"
x-on:click="$el.value = ''"
x-on:change="onPick($event, 'convert')"
aria-label="Choose a video or image to convert"
/>
Expand Down Expand Up @@ -582,7 +584,13 @@ <h1>New job</h1>
<i data-lucide="plus"></i>
<span x-text="uploadKind('convert') === 'image' ? 'Add more images' : 'Add more videos'"
>Add more</span>
<input type="file" multiple x-on:change="onPick($event, 'convert')" />
<input
type="file"
multiple
accept="image/*,video/*,audio/*"
x-on:click="$el.value = ''"
x-on:change="onPick($event, 'convert')"
/>
</label>
<span class="hint" x-text="uploads.convert.length + ' file' + (uploads.convert.length === 1 ? '' : 's')"></span>
<button type="button" class="btn btn-sm btn-quiet" x-on:click="clearUploads('convert')">
Expand Down Expand Up @@ -1018,6 +1026,8 @@ <h1>New job</h1>
<input
type="file"
multiple
accept="image/*,video/*,audio/*"
x-on:click="$el.value = ''"
x-on:change="onPick($event, 'compress')"
aria-label="Choose a video or image to compress"
/>
Expand Down Expand Up @@ -1062,7 +1072,13 @@ <h1>New job</h1>
<i data-lucide="plus"></i>
<span x-text="uploadKind('compress') === 'image' ? 'Add more images' : 'Add more videos'"
>Add more</span>
<input type="file" multiple x-on:change="onPick($event, 'compress')" />
<input
type="file"
multiple
accept="image/*,video/*,audio/*"
x-on:click="$el.value = ''"
x-on:change="onPick($event, 'compress')"
/>
</label>
<span class="hint" x-text="uploads.compress.length + ' file' + (uploads.compress.length === 1 ? '' : 's')"></span>
<button type="button" class="btn btn-sm btn-quiet" x-on:click="clearUploads('compress')">
Expand Down Expand Up @@ -1353,7 +1369,7 @@ <h1>Jobs</h1>
</div>
<div class="btn-row">
<label class="sr-only" for="job-filter">Filter by status</label>
<select id="job-filter" x-model="jobFilter" x-on:change="jobPage = 1" style="width: auto">
<select id="job-filter" x-model="jobFilter" x-on:change="jobPage = 1; refreshIcons()" style="width: auto">
<template x-for="s in statusFilters" :key="s.value">
<option x-bind:value="s.value" x-text="s.label"
x-bind:selected="s.value === String(jobFilter)"></option>
Expand Down
33 changes: 32 additions & 1 deletion internal/api/handlers_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
}
s.log.Debug("output outside download dir, streaming through Go", "path", job.OutputPath)
}
if ct := contentTypeFor(name); ct != "application/octet-stream" {
w.Header().Set("Content-Type", ct)
}
http.ServeFile(w, r, job.OutputPath)
}

Expand All @@ -267,8 +270,36 @@ func accelPath(prefix, root, outputPath string) (string, bool) {
return strings.TrimRight(prefix, "/") + "/" + strings.Join(segments, "/"), true
}

// mediaTypes covers the outputs the tool writes. The system mime table is
// often absent in slim images, and an octet-stream download hides the iOS
// "Save to Photos" action.
var mediaTypes = map[string]string{
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
".webm": "video/webm",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".aac": "audio/aac",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".flac": "audio/flac",
".heic": "image/heic",
".heif": "image/heif",
".avif": "image/avif",
".tif": "image/tiff",
".tiff": "image/tiff",
".bmp": "image/bmp",
}

func contentTypeFor(name string) string {
if ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); ct != "" {
ext := strings.ToLower(filepath.Ext(name))
if ct, ok := mediaTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
Expand Down
Loading