- .m4a uploads are now accepted in add/edit, and M4A handling now keeps the original source while adding a generated black-frame H.264 MP4 companion source when ffmpeg can run.

The core conversion helper is in [includes/db.php (line 
1161)](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php:1161). 
The upload flows insert the converted MP4 first for playback and retain 
the original M4A as a separate source in [admin/add.php (line 
129)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/add.php:129) 
and [admin/edit.php (line 
172)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/edit.php:172). 
I also updated the admin upload hints, [README.md (line 
77)](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md:77), and 
[changelog.md (line 
14)](/Users/tyemeclifford/Documents/GH/mediaplayer/changelog.md:14).
This commit is contained in:
Ty Clifford
2026-06-25 17:47:54 -04:00
parent 57d3c81dad
commit 47de94a3df
5 changed files with 98 additions and 17 deletions
+3 -1
View File
@@ -74,6 +74,7 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly.
- **MP4** (H.264) — widest compatibility - **MP4** (H.264) — widest compatibility
- **WebM** (VP9) — smaller size, modern browsers - **WebM** (VP9) — smaller size, modern browsers
- **OGV** — Firefox fallback (legacy) - **OGV** — Firefox fallback (legacy)
- **M4A** — kept as an original source and converted to a black-frame H.264 MP4 companion source when `ffmpeg` is installed
6. Set quality labels (1080p, 720p, 480p, etc.) 6. Set quality labels (1080p, 720p, 480p, etc.)
7. Save — Video.js will use the stored source location and auto-select the best format the browser supports 7. Save — Video.js will use the stored source location and auto-select the best format the browser supports
@@ -81,7 +82,7 @@ External URLs must use `http://` or `https://`. Uploaded files and remote URLs c
When adding an external URL, the admin form can fetch title, description, duration, thumbnail metadata, and readable provider view counts through oEmbed, page metadata, and JSON-LD. The save action repeats the metadata lookup as a fallback, so blank titles can be filled from supported external pages even if the browser autofill was not used. When adding an external URL, the admin form can fetch title, description, duration, thumbnail metadata, and readable provider view counts through oEmbed, page metadata, and JSON-LD. The save action repeats the metadata lookup as a fallback, so blank titles can be filled from supported external pages even if the browser autofill was not used.
If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server. If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server. Uploaded M4A files also use `ffmpeg` to create a black-frame H.264 MP4 playback source while keeping the original M4A source.
### Live Homepage Option ### Live Homepage Option
@@ -142,6 +143,7 @@ Use the **Sort Order** field — lower numbers appear first. Default is 0. The *
- Playback rate controls: 0.5×, 1×, 1.25×, 1.5×, 2× - Playback rate controls: 0.5×, 1×, 1.25×, 1.5×, 2×
- Multiple source fallback — browser picks best format automatically - Multiple source fallback — browser picks best format automatically
- Local uploaded sources and remote URL sources - Local uploaded sources and remote URL sources
- M4A uploads can add a generated black-frame H.264 MP4 companion source
- Provider embeds for popular video and audio platforms - Provider embeds for popular video and audio platforms
- Thumbnail posters - Thumbnail posters
- Per-video view counts and optional public library statistics - Per-video view counts and optional public library statistics
+26 -4
View File
@@ -88,7 +88,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$inserted_sources = 0; $inserted_sources = 0;
$moved_files = []; $moved_files = [];
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi']; $allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi','m4a'];
for ($i = 0; $i < $source_count; $i++) { for ($i = 0; $i < $source_count; $i++) {
$type = ($source_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local'; $type = ($source_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local';
@@ -123,13 +123,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext; $fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) { if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$file_path = 'videos/' . $fname;
$moved_files[] = $videos_dir . $fname; $moved_files[] = $videos_dir . $fname;
if ($ext === 'm4a') {
$converted_path = media_black_video_from_m4a($file_path, $slug, $i);
if ($converted_path !== '') {
$moved_files[] = MEDIA_DIR . $converted_path;
$converted_label = $label !== '' ? $label . ' (MP4)' : 'Converted MP4';
$db->prepare(" $db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order) INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?)
")->execute([$video_id, $label, 'local', 'videos/' . $fname, '', mime_from_ext($fname), $quality, $sort]); ")->execute([$video_id, $converted_label, 'local', $converted_path, '', 'video/mp4', $quality, $sort]);
$inserted_sources++; $inserted_sources++;
} }
$original_label = $label !== '' ? $label . ' (M4A)' : 'Original M4A';
$db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?)
")->execute([$video_id, $original_label, 'local', $file_path, '', mime_from_ext($fname), '', $converted_path !== '' ? $sort + 1 : $sort]);
$inserted_sources++;
} else {
$db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?)
")->execute([$video_id, $label, 'local', $file_path, '', mime_from_ext($fname), $quality, $sort]);
$inserted_sources++;
}
}
} }
if ($error) { if ($error) {
@@ -309,7 +331,7 @@ render_head('Add Video — Admin', '
<p class="section-heading">Video Sources</p> <p class="section-heading">Video Sources</p>
<p class="hint" style="margin-bottom:1rem;"> <p class="hint" style="margin-bottom:1rem;">
Upload a video file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo. The player will use a provider embed when the URL supports it. Upload a video file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo. The player will use a provider embed when the URL supports it.
Uploaded files: <strong>MP4, WebM, OGV, MOV, MKV, AVI</strong>. Remote URLs must use <strong>http://</strong> or <strong>https://</strong>. Uploaded files: <strong>MP4, WebM, OGV, MOV, MKV, AVI, M4A</strong>. M4A uploads keep the original source and add a black-frame H.264 MP4 source when ffmpeg is available. Remote URLs must use <strong>http://</strong> or <strong>https://</strong>.
</p> </p>
<div class="sources-section" id="sources-container"> <div class="sources-section" id="sources-container">
@@ -325,7 +347,7 @@ render_head('Add Video — Admin', '
<div class="form-group source-location-panel source-location-local"> <div class="form-group source-location-panel source-location-local">
<label class="form-label">Video File</label> <label class="form-label">Video File</label>
<input class="form-input" type="file" name="source_file[]" <input class="form-input" type="file" name="source_file[]"
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi" accept="video/mp4,video/webm,video/ogg,audio/mp4,.mp4,.webm,.ogv,.mov,.mkv,.avi,.m4a"
style="padding:.45rem .6rem;"> style="padding:.45rem .6rem;">
</div> </div>
<div class="form-group source-location-panel source-location-remote" hidden> <div class="form-group source-location-panel source-location-remote" hidden>
+26 -4
View File
@@ -127,7 +127,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$new_files = $_FILES['new_source_file'] ?? []; $new_files = $_FILES['new_source_file'] ?? [];
$videos_dir = MEDIA_DIR . 'videos/'; $videos_dir = MEDIA_DIR . 'videos/';
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true); if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi']; $allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi','m4a'];
$new_count = max( $new_count = max(
count($new_types), count($new_types),
@@ -166,13 +166,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($ext, $allowed_video, true)) continue; if (!in_array($ext, $allowed_video, true)) continue;
$fname = $slug . '_' . time() . '_' . $i . '.' . $ext; $fname = $slug . '_' . time() . '_' . $i . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) { if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$file_path = 'videos/' . $fname;
$moved_files[] = $videos_dir . $fname; $moved_files[] = $videos_dir . $fname;
if ($ext === 'm4a') {
$converted_path = media_black_video_from_m4a($file_path, $slug, $i);
if ($converted_path !== '') {
$moved_files[] = MEDIA_DIR . $converted_path;
$converted_label = $label !== '' ? $label . ' (MP4)' : 'Converted MP4';
$db->prepare(" $db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order) INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?)
")->execute([$id, $label, 'local', 'videos/' . $fname, '', mime_from_ext($fname), $quality, $sort]); ")->execute([$id, $converted_label, 'local', $converted_path, '', 'video/mp4', $quality, $sort]);
$inserted_source_ids[] = (int)$db->lastInsertId(); $inserted_source_ids[] = (int)$db->lastInsertId();
} }
$original_label = $label !== '' ? $label . ' (M4A)' : 'Original M4A';
$db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?)
")->execute([$id, $original_label, 'local', $file_path, '', mime_from_ext($fname), '', $converted_path !== '' ? $sort + 1 : $sort]);
$inserted_source_ids[] = (int)$db->lastInsertId();
} else {
$db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?)
")->execute([$id, $label, 'local', $file_path, '', mime_from_ext($fname), $quality, $sort]);
$inserted_source_ids[] = (int)$db->lastInsertId();
}
}
} }
if ($error) { if ($error) {
@@ -425,7 +447,7 @@ render_head('Edit Video — Admin', '
<!-- ── Add new sources ── --> <!-- ── Add new sources ── -->
<div class="card" style="margin-bottom:1rem;"> <div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Add New Sources</p> <p class="section-heading">Add New Sources</p>
<p class="hint" style="margin-bottom:.85rem;">Upload another file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo.</p> <p class="hint" style="margin-bottom:.85rem;">Upload another file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo. M4A uploads keep the original source and add a black-frame H.264 MP4 source when ffmpeg is available.</p>
<div class="source-list" id="new-sources-container"> <div class="source-list" id="new-sources-container">
<div class="add-source-row" data-new-row="0"> <div class="add-source-row" data-new-row="0">
<div class="form-group"> <div class="form-group">
@@ -438,7 +460,7 @@ render_head('Edit Video — Admin', '
<div class="form-group source-location-panel source-location-local"> <div class="form-group source-location-panel source-location-local">
<label class="form-label">Video File</label> <label class="form-label">Video File</label>
<input class="form-input" type="file" name="new_source_file[]" <input class="form-input" type="file" name="new_source_file[]"
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi" accept="video/mp4,video/webm,video/ogg,audio/mp4,.mp4,.webm,.ogv,.mov,.mkv,.avi,.m4a"
style="padding:.45rem .6rem;"> style="padding:.45rem .6rem;">
</div> </div>
<div class="form-group source-location-panel source-location-remote" hidden> <div class="form-group source-location-panel source-location-remote" hidden>
+1
View File
@@ -11,6 +11,7 @@ All notable changes to this media player project.
- Added an admin upload flow that supports either direct uploads or external provider/page URLs. - Added an admin upload flow that supports either direct uploads or external provider/page URLs.
- Added metadata fetching for external URLs to populate title, description, duration, thumbnails, publish dates, and readable provider view counts where available. - Added metadata fetching for external URLs to populate title, description, duration, thumbnails, publish dates, and readable provider view counts where available.
- Added automatic thumbnail grabbing for external URLs, plus first-frame thumbnail generation for local videos when `ffmpeg` is available. - Added automatic thumbnail grabbing for external URLs, plus first-frame thumbnail generation for local videos when `ffmpeg` is available.
- Added M4A upload support that keeps the original M4A source and adds a generated black-frame H.264 MP4 source when `ffmpeg` is available.
- Added a YouTube channel importer that scrapes channel videos, previews imports, skips duplicates, and stores imported videos as embedded YouTube sources. - Added a YouTube channel importer that scrapes channel videos, previews imports, skips duplicates, and stores imported videos as embedded YouTube sources.
- Added YouTube timestamp correction so imported and existing YouTube videos can use their original publish dates. - Added YouTube timestamp correction so imported and existing YouTube videos can use their original publish dates.
- Added an **Upkeeping** section to the admin dashboard for batch maintenance actions. - Added an **Upkeeping** section to the admin dashboard for batch maintenance actions.
+34
View File
@@ -1158,6 +1158,40 @@ function media_ffmpeg_path(): string {
return $path !== '' && is_executable($path) ? $path : ''; return $path !== '' && is_executable($path) ? $path : '';
} }
function media_black_video_from_m4a(string $file_path, string $slug, int $index = 0): string {
$ffmpeg = media_ffmpeg_path();
if ($ffmpeg === '') return '';
$source = MEDIA_DIR . ltrim($file_path, '/');
if ($file_path === '' || !is_file($source) || !is_readable($source)) return '';
if (strtolower(pathinfo($source, PATHINFO_EXTENSION)) !== 'm4a') return '';
$videos_dir = MEDIA_DIR . 'videos/';
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
if (!function_exists('exec') || media_disabled_function('exec')) return '';
$label = media_clean_token($slug);
if ($label === '') $label = 'video';
$fname = $label . '_m4a_black_' . $index . '_' . time() . '_' . substr(sha1($file_path), 0, 8) . '.mp4';
$target = $videos_dir . $fname;
$cmd = escapeshellarg($ffmpeg)
. ' -y -f lavfi -i ' . escapeshellarg('color=c=black:s=1280x720:r=30')
. ' -i ' . escapeshellarg($source)
. ' -shortest -map 0:v:0 -map 1:a:0'
. ' -c:v libx264 -preset veryfast -tune stillimage -pix_fmt yuv420p'
. ' -c:a aac -b:a 192k -movflags +faststart '
. escapeshellarg($target) . ' 2>/dev/null';
@exec($cmd, $output, $code);
if ($code === 0 && is_file($target) && filesize($target) > 0) {
return 'videos/' . $fname;
}
if (is_file($target)) unlink($target);
return '';
}
function media_thumbnail_from_local_video(string $file_path, string $slug): string { function media_thumbnail_from_local_video(string $file_path, string $slug): string {
$ffmpeg = media_ffmpeg_path(); $ffmpeg = media_ffmpeg_path();
if ($ffmpeg === '') return ''; if ($ffmpeg === '') return '';