Преглед изворни кода

Add transcode seek to Musicify and Movie module

Toby Chui пре 1 недеља
родитељ
комит
9bece996ce

+ 2 - 0
src/mediaServer.go

@@ -46,6 +46,8 @@ func mediaServer_init() {
 	if ffmpegInstalled {
 		//ffmpeg installed. allow transcode
 		http.HandleFunc("/media/transcode/", mediaServer.ServeVideoWithTranscode)
+		http.HandleFunc("/media/transcode/audio/", mediaServer.ServeAudioWithTranscode)
+		http.HandleFunc("/media/duration/", mediaServer.GetAudioDuration)
 	} else {
 		//ffmpeg not installed. Redirect transcode endpoint back to /media/
 		http.HandleFunc("/media/transcode/", func(w http.ResponseWriter, r *http.Request) {

+ 102 - 3
src/mod/media/mediaserver/mediaserver.go

@@ -3,6 +3,7 @@ package mediaserver
 import (
 	"crypto/md5"
 	"encoding/hex"
+	"encoding/json"
 	"errors"
 	"io"
 	"net/http"
@@ -313,6 +314,11 @@ func (s *Instance) ServeVideoWithTranscode(w http.ResponseWriter, r *http.Reques
 		transcodeOutputResolution = transcoder.TranscodeResolution_360p
 	}
 
+	var startTime float64
+	if startTimeStr, _ := utils.GetPara(r, "start"); startTimeStr != "" {
+		startTime, _ = strconv.ParseFloat(startTimeStr, 64)
+	}
+
 	targetFshAbs := targetFsh.FileSystemAbstraction
 	transcodeSourceFile := realFilepath
 	if filesystem.FileExists(transcodeSourceFile) {
@@ -323,7 +329,7 @@ func (s *Instance) ServeVideoWithTranscode(w http.ResponseWriter, r *http.Reques
 			utils.SendErrorResponse(w, err.Error())
 			return
 		}
-		transcoder.TranscodeAndStream(w, r, transcodeSrcFileAbsPath, transcodeOutputResolution)
+		transcoder.TranscodeAndStream(w, r, transcodeSrcFileAbsPath, transcodeOutputResolution, startTime)
 		return
 	} else {
 		//This file is from a remote file system. Check if it already has a local buffer
@@ -339,7 +345,7 @@ func (s *Instance) ServeVideoWithTranscode(w http.ResponseWriter, r *http.Reques
 					if string(localFileHash) == remoteFileHash {
 						//Hash matches. Serve local buffered file
 						buffFileAbs, _ := filepath.Abs(buffFile)
-						transcoder.TranscodeAndStream(w, r, buffFileAbs, transcodeOutputResolution)
+						transcoder.TranscodeAndStream(w, r, buffFileAbs, transcodeOutputResolution, startTime)
 						return
 					}
 				}
@@ -358,7 +364,7 @@ func (s *Instance) ServeVideoWithTranscode(w http.ResponseWriter, r *http.Reques
 
 			//Buffer completed. Start transcode
 			buffFileAbs, _ := filepath.Abs(buffFile)
-			transcoder.TranscodeAndStream(w, r, buffFileAbs, transcodeOutputResolution)
+			transcoder.TranscodeAndStream(w, r, buffFileAbs, transcodeOutputResolution, startTime)
 			return
 		} else {
 			utils.SendErrorResponse(w, "unable to transcode remote file with file buffer disabled")
@@ -476,3 +482,96 @@ func (s *Instance) GetHashFromRemoteFile(fshAbs filesystem.FileSystemAbstraction
 	hash := md5.Sum([]byte(statHash))
 	return hex.EncodeToString(hash[:]), nil
 }
+
+// Serve audio file with real-time transcoder, supporting seeking via &start= parameter
+func (s *Instance) ServeAudioWithTranscode(w http.ResponseWriter, r *http.Request) {
+	userinfo, _ := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	targetFsh, vpath, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	// Parse sample rate (default 48000)
+	sampleRateStr, _ := utils.GetPara(r, "samplerate")
+	sampleRateInt, _ := strconv.Atoi(sampleRateStr)
+	var sampleRate transcoder.TranscodeAudioSampleRate
+	switch sampleRateInt {
+	case 16000:
+		sampleRate = transcoder.TranscodeAudio_16kHz
+	case 24000:
+		sampleRate = transcoder.TranscodeAudio_24kHz
+	default:
+		sampleRate = transcoder.TranscodeAudio_48kHz
+	}
+
+	// Parse start time for seeking (default 0)
+	startStr, _ := utils.GetPara(r, "start")
+	startTime, _ := strconv.ParseFloat(startStr, 64)
+
+	if filesystem.FileExists(realFilepath) {
+		absPath, err := filepath.Abs(realFilepath)
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+		transcoder.TranscodeAndStreamAudio(w, r, absPath, sampleRate, startTime)
+		return
+	}
+
+	// Remote file: try local buffer first, then download and transcode
+	ps, _ := targetFsh.GetUniquePathHash(vpath, userinfo.Username)
+	buffpool := filepath.Join(s.options.TmpDirectory, "fsbuffpool")
+	buffFile := filepath.Join(buffpool, ps)
+	if fs.FileExists(buffFile) {
+		remoteFileHash, err := s.GetHashFromRemoteFile(targetFsh.FileSystemAbstraction, realFilepath)
+		if err == nil {
+			localFileHash, err := os.ReadFile(buffFile + ".hash")
+			if err == nil && string(localFileHash) == remoteFileHash {
+				buffFileAbs, _ := filepath.Abs(buffFile)
+				transcoder.TranscodeAndStreamAudio(w, r, buffFileAbs, sampleRate, startTime)
+				return
+			}
+		}
+	}
+
+	if !s.options.EnableFileBuffering {
+		utils.SendErrorResponse(w, "unable to transcode remote file with file buffer disabled")
+		return
+	}
+
+	os.MkdirAll(buffpool, 0775)
+	s.options.Logger.PrintAndLog("Media Server", "Buffering audio from remote file system for transcode", nil)
+	if err := s.BufferRemoteFileToTmp(buffFile, targetFsh, realFilepath); err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	buffFileAbs, _ := filepath.Abs(buffFile)
+	transcoder.TranscodeAndStreamAudio(w, r, buffFileAbs, sampleRate, startTime)
+}
+
+// GetAudioDuration returns the duration of a local audio file in seconds using ffprobe
+func (s *Instance) GetAudioDuration(w http.ResponseWriter, r *http.Request) {
+	targetFsh, _, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	if targetFsh.RequireBuffer || !filesystem.FileExists(realFilepath) {
+		js, _ := json.Marshal(map[string]interface{}{"duration": 0, "error": "remote file"})
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(js)
+		return
+	}
+
+	duration, err := transcoder.GetAudioDuration(realFilepath)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	js, _ := json.Marshal(map[string]float64{"duration": duration})
+	w.Header().Set("Content-Type", "application/json")
+	w.Write(js)
+}

+ 125 - 7
src/mod/media/transcoder/transcoder.go

@@ -8,10 +8,12 @@ package transcoder
 */
 
 import (
+	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"os/exec"
+	"strconv"
 	"time"
 
 	"imuslab.com/arozos/mod/info/logger"
@@ -27,26 +29,34 @@ const (
 )
 
 // Transcode and stream the given file. Make sure ffmpeg is installed before calling to transcoder.
-func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string, resolution TranscodeOutputResolution) {
+// startTime is a seek offset in seconds; pass 0 to start from the beginning.
+func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string, resolution TranscodeOutputResolution, startTime float64) {
 	// Build the FFmpeg command based on the resolution parameter
 	var cmd *exec.Cmd
 
 	transcodeFormatArgs := []string{"-f", "mp4", "-vcodec", "libx264", "-preset", "superfast", "-g", "60", "-movflags", "frag_keyframe+empty_moov+faststart", "pipe:1"}
-	var args []string
+	var preInputArgs []string
+	if startTime > 0.001 {
+		preInputArgs = []string{"-ss", fmt.Sprintf("%.3f", startTime)}
+	}
+	var middleArgs []string
 	switch resolution {
 	case "360p":
-		args = append([]string{"-i", inputFile, "-vf", "scale=-1:360"}, transcodeFormatArgs...)
+		middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:360"}
 	case "720p":
-		args = append([]string{"-i", inputFile, "-vf", "scale=-1:720"}, transcodeFormatArgs...)
+		middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:720"}
 	case "1080p":
-		args = append([]string{"-i", inputFile, "-vf", "scale=-1:1080"}, transcodeFormatArgs...)
+		middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:1080"}
 	case "":
-		// Original resolution
-		args = append([]string{"-i", inputFile}, transcodeFormatArgs...)
+		middleArgs = []string{"-i", inputFile}
 	default:
 		http.Error(w, "Invalid resolution parameter", http.StatusBadRequest)
 		return
 	}
+	var args []string
+	args = append(args, preInputArgs...)
+	args = append(args, middleArgs...)
+	args = append(args, transcodeFormatArgs...)
 	cmd = exec.Command("ffmpeg", args...)
 
 	// Set response headers for streaming MP4 video
@@ -116,3 +126,111 @@ func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string
 	<-done
 	logger.PrintAndLog("Transcoder", "[Media Server] Transcode client disconnected", nil)
 }
+
+type TranscodeAudioSampleRate int
+
+const (
+	TranscodeAudio_16kHz TranscodeAudioSampleRate = 16000
+	TranscodeAudio_24kHz TranscodeAudioSampleRate = 24000
+	TranscodeAudio_48kHz TranscodeAudioSampleRate = 48000
+)
+
+// TranscodeAndStreamAudio transcodes an audio file to MP3 and streams it.
+// startTime is the seek offset in seconds; pass 0 to start from the beginning.
+func TranscodeAndStreamAudio(w http.ResponseWriter, r *http.Request, inputFile string, sampleRate TranscodeAudioSampleRate, startTime float64) {
+	var args []string
+	if startTime > 0.001 {
+		args = append(args, "-ss", fmt.Sprintf("%.3f", startTime))
+	}
+	args = append(args,
+		"-i", inputFile,
+		"-vn",
+		"-acodec", "libmp3lame",
+		"-ar", fmt.Sprintf("%d", int(sampleRate)),
+		"-b:a", "128k",
+		"-f", "mp3",
+		"pipe:1",
+	)
+	cmd := exec.Command("ffmpeg", args...)
+
+	w.Header().Set("Content-Type", "audio/mpeg")
+	w.Header().Set("Transfer-Encoding", "chunked")
+	w.Header().Set("Cache-Control", "no-cache, no-store")
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		http.Error(w, "Failed to create output pipe", http.StatusInternalServerError)
+		return
+	}
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		http.Error(w, "Failed to create error pipe", http.StatusInternalServerError)
+		return
+	}
+	if err := cmd.Start(); err != nil {
+		http.Error(w, "Failed to start FFmpeg", http.StatusInternalServerError)
+		return
+	}
+
+	done := make(chan struct{})
+
+	go func() {
+		<-r.Context().Done()
+		time.Sleep(300 * time.Millisecond)
+		cmd.Process.Kill()
+		done <- struct{}{}
+	}()
+
+	go func() {
+		if _, err := io.Copy(w, stdout); err != nil {
+			cmd.Process.Kill()
+			return
+		}
+	}()
+
+	go func() {
+		errOutput, _ := io.ReadAll(stderr)
+		if len(errOutput) > 0 {
+			logger.PrintAndLog("Transcoder", fmt.Sprintf("FFmpeg audio error output: %s", string(errOutput)), nil)
+		}
+	}()
+
+	go func() {
+		if err := cmd.Wait(); err != nil {
+			logger.PrintAndLog("Transcoder", fmt.Sprintf("FFmpeg audio process exited: %v", err), nil)
+		}
+	}()
+
+	<-done
+	logger.PrintAndLog("Transcoder", "[Media Server] Audio transcode client disconnected", nil)
+}
+
+// GetAudioDuration returns the duration of a local audio file in seconds using ffprobe.
+func GetAudioDuration(inputFile string) (float64, error) {
+	cmd := exec.Command("ffprobe",
+		"-v", "quiet",
+		"-print_format", "json",
+		"-show_format",
+		inputFile,
+	)
+	output, err := cmd.Output()
+	if err != nil {
+		return 0, fmt.Errorf("ffprobe failed: %w", err)
+	}
+
+	var result struct {
+		Format struct {
+			Duration string `json:"duration"`
+		} `json:"format"`
+	}
+	if err := json.Unmarshal(output, &result); err != nil {
+		return 0, fmt.Errorf("failed to parse ffprobe output: %w", err)
+	}
+
+	duration, err := strconv.ParseFloat(result.Format.Duration, 64)
+	if err != nil {
+		return 0, fmt.Errorf("invalid duration value: %w", err)
+	}
+	return duration, nil
+}

+ 9 - 9
src/mod/media/transcoder/transcoder_test.go

@@ -48,7 +48,7 @@ func TestTranscodeAndStream_InvalidResolution(t *testing.T) {
 	req := httptest.NewRequest(http.MethodGet, "/media/transcode", nil)
 	rr := httptest.NewRecorder()
 
-	TranscodeAndStream(rr, req, "/some/file.mkv", "invalid-resolution")
+	TranscodeAndStream(rr, req, "/some/file.mkv", "invalid-resolution", 0)
 
 	if rr.Code != http.StatusBadRequest {
 		t.Errorf("expected status %d for invalid resolution, got %d", http.StatusBadRequest, rr.Code)
@@ -65,7 +65,7 @@ func TestTranscodeAndStream_NoFfmpeg(t *testing.T) {
 	req := httptest.NewRequest(http.MethodGet, "/media/transcode", nil)
 	rr := httptest.NewRecorder()
 
-	TranscodeAndStream(rr, req, "/some/nonexistent.mp4", TranscodeResolution_original)
+	TranscodeAndStream(rr, req, "/some/nonexistent.mp4", TranscodeResolution_original, 0)
 
 	if rr.Code != http.StatusInternalServerError {
 		t.Errorf("expected status %d when ffmpeg is missing, got %d", http.StatusInternalServerError, rr.Code)
@@ -93,7 +93,7 @@ func TestTranscodeAndStream_ResolutionSwitch(t *testing.T) {
 			req := httptest.NewRequest(http.MethodGet, "/media/transcode", nil)
 			rr := httptest.NewRecorder()
 
-			TranscodeAndStream(rr, req, "/nonexistent.mp4", tc.res)
+			TranscodeAndStream(rr, req, "/nonexistent.mp4", tc.res, 0)
 
 			// Should NOT be 400 – the resolution is recognised.
 			if rr.Code == http.StatusBadRequest {
@@ -148,7 +148,7 @@ func TestTranscodeAndStream_WithFakeFfmpeg(t *testing.T) {
 		cancel()
 	}()
 
-	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_original)
+	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_original, 0)
 }
 
 // TestTranscodeAndStream_WithFakeFfmpeg360p covers the 360p resolution branch
@@ -169,7 +169,7 @@ func TestTranscodeAndStream_WithFakeFfmpeg360p(t *testing.T) {
 		cancel()
 	}()
 
-	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_360p)
+	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_360p, 0)
 }
 
 // TestTranscodeAndStream_WithFakeFfmpeg720p covers the 720p resolution branch.
@@ -189,7 +189,7 @@ func TestTranscodeAndStream_WithFakeFfmpeg720p(t *testing.T) {
 		cancel()
 	}()
 
-	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_720p)
+	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_720p, 0)
 }
 
 // TestTranscodeAndStream_WithFakeFfmpeg1080p covers the 1080p branch.
@@ -209,7 +209,7 @@ func TestTranscodeAndStream_WithFakeFfmpeg1080p(t *testing.T) {
 		cancel()
 	}()
 
-	TranscodeAndStream(rr, req, "/dev/null", TranscodeOutputResolution("1080p"))
+	TranscodeAndStream(rr, req, "/dev/null", TranscodeOutputResolution("1080p"), 0)
 }
 
 // makeFakeFfmpegWithStderrDir creates a temporary directory with a fake "ffmpeg"
@@ -247,7 +247,7 @@ func TestTranscodeAndStream_WithFfmpegStderrAndError(t *testing.T) {
 		cancel()
 	}()
 
-	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_original)
+	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_original, 0)
 
 	// Allow goroutines time to run their stderr/wait branches before the test exits.
 	time.Sleep(100 * time.Millisecond)
@@ -272,6 +272,6 @@ func TestTranscodeAndStream_WithFfmpegStderrAndError360p(t *testing.T) {
 		cancel()
 	}()
 
-	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_360p)
+	TranscodeAndStream(rr, req, "/dev/null", TranscodeResolution_360p, 0)
 	time.Sleep(100 * time.Millisecond)
 }

+ 118 - 30
src/web/Movie/index.html

@@ -1395,7 +1395,7 @@ function connectCast() {
                         : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
                     _castSend('media.load', {
                         filepath: ep.filepath, name: ep.name, type: 'video',
-                        src: src, startTime: vid.currentTime || 0
+                        src: src, startTime: (isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime) || 0
                     });
                     // Volume after load, before play — ensures it overrides any default
                     // the Arozcast side might apply during media initialisation.
@@ -1457,16 +1457,21 @@ function _castResumeLocally() {
     if (playingIndex < 0 || !currentEpisodes || !currentEpisodes[playingIndex]) { return; }
     var ep  = currentEpisodes[playingIndex];
     var pos = castCurrentTime;
-    var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
-    var src = isWebPlayable(ext)
-        ? MEDIA_API + '?file=' + encodeURIComponent(ep.filepath)
-        : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
     var vid = document.getElementById('main-video');
-    vid.src = src;
-    vid.load();
-    if (pos > 0) {
-        $(vid).one('loadedmetadata.castresume', function() { vid.currentTime = pos; });
+    if (isTranscodedVideo && pos > 0) {
+        // Seek-by-reload: restart the transcode stream at the cast position
+        transcodeSeekOffset = pos;
+        vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath) + '&start=' + pos.toFixed(3);
+    } else {
+        var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
+        vid.src = isWebPlayable(ext)
+            ? MEDIA_API     + '?file=' + encodeURIComponent(ep.filepath)
+            : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
+        if (pos > 0) {
+            $(vid).one('loadedmetadata.castresume', function() { vid.currentTime = pos; });
+        }
     }
+    vid.load();
     $('#play-icon').attr('src', 'img/icons/play_white.svg');
     showToast('Arozcast disconnected — click play to resume');
 }
@@ -1573,6 +1578,11 @@ var playerReturnView  = 'library';
 var pendingResumePos  = 0;
 var watchSaveInterval = null;
 
+// Transcode seek state (seek-by-reload for non-web-playable formats)
+var transcodeSeekOffset = 0;    // seconds already consumed before current chunk start
+var isTranscodedVideo   = false; // true when current video plays via transcode endpoint
+var transcodeDuration   = 0;    // duration pre-fetched via /media/duration/ (seconds)
+
 // Folder browse state
 var folderViewPath   = '/';
 var folderViewVideos = [];
@@ -1778,13 +1788,15 @@ function closeMovieInfo() {
 // ─── Watch position (resume) ──────────────────────────────────────────────────
 function saveWatchPosition() {
     var vid = document.getElementById('main-video');
-    if (playingIndex < 0 || !currentEpisodes || !vid.duration || vid.duration < 3600) { return; }
+    var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
+    if (playingIndex < 0 || !currentEpisodes || !effectiveDuration || effectiveDuration < 3600) { return; }
     var ep = currentEpisodes[playingIndex];
-    if (!ep || vid.currentTime < 10) { return; }
+    var effectiveTime = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
+    if (!ep || effectiveTime < 10) { return; }
     ao_module_agirun(SCRIPT_SET_WATCHTIME, {
         filepath: ep.filepath,
-        position: Math.floor(vid.currentTime),
-        duration: Math.floor(vid.duration)
+        position: Math.floor(effectiveTime),
+        duration: Math.floor(effectiveDuration)
     }, function () {}, function () {});
 }
 
@@ -1807,8 +1819,16 @@ function showResumePopup(savedPos, duration) {
     showControls();
 
     $('#resume-btn-continue').off('click').on('click', function () {
-        vid.currentTime = pendingResumePos;
-        vid.play();
+        if (isTranscodedVideo && playingIndex >= 0 && currentEpisodes[playingIndex]) {
+            var ep = currentEpisodes[playingIndex];
+            transcodeSeekOffset = pendingResumePos;
+            vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath) + '&start=' + pendingResumePos.toFixed(3);
+            vid.load();
+            vid.play();
+        } else {
+            vid.currentTime = pendingResumePos;
+            vid.play();
+        }
         $('#resume-popup').removeClass('active');
     });
     $('#resume-btn-restart').off('click').on('click', function () {
@@ -2431,6 +2451,11 @@ function startPlayback(index) {
         ? MEDIA_API   + '?file=' + encodeURIComponent(ep.filepath)
         : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
 
+    // Reset transcode seek state for the new episode
+    transcodeSeekOffset = 0;
+    isTranscodedVideo = !isWebPlayable(ext);
+    transcodeDuration = 0;
+
     var vid = document.getElementById('main-video');
 
     if (castMode && _castConnected()) {
@@ -2471,18 +2496,41 @@ function startPlayback(index) {
     showView('player');
     showControls();
 
-    // After metadata loads, offer to resume if the video is >1 hr and has a saved position
-    (function (epFilepath) {
-        $(vid).off('loadedmetadata.resume').one('loadedmetadata.resume', function () {
-            if (vid.duration > 3600) {
-                ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: epFilepath }, function (data) {
-                    if (data && !data.error && data.position > 30 && data.position < vid.duration * 0.95) {
-                        showResumePopup(data.position, vid.duration);
+    // For non-transcoded video: offer to resume via the native loadedmetadata event.
+    // For transcoded video: wait for the duration fetch, then check for a saved position.
+    if (!isTranscodedVideo) {
+        (function (epFilepath) {
+            $(vid).off('loadedmetadata.resume').one('loadedmetadata.resume', function () {
+                if (vid.duration > 3600) {
+                    ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: epFilepath }, function (data) {
+                        if (data && !data.error && data.position > 30 && data.position < vid.duration * 0.95) {
+                            showResumePopup(data.position, vid.duration);
+                        }
+                    });
+                }
+            });
+        })(ep.filepath);
+    } else {
+        $(vid).off('loadedmetadata.resume'); // clear any stale handler
+        (function (capturedEp, capturedIndex) {
+            fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(capturedEp.filepath))
+                .then(function (r) { return r.json(); })
+                .then(function (data) {
+                    if (data.duration > 0 && playingIndex === capturedIndex &&
+                            currentEpisodes[capturedIndex] &&
+                            currentEpisodes[capturedIndex].filepath === capturedEp.filepath) {
+                        transcodeDuration = data.duration;
+                        if (transcodeDuration > 3600) {
+                            ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: capturedEp.filepath }, function (wdata) {
+                                if (wdata && !wdata.error && wdata.position > 30 && wdata.position < transcodeDuration * 0.95) {
+                                    showResumePopup(wdata.position, transcodeDuration);
+                                }
+                            });
+                        }
                     }
-                });
-            }
-        });
-    })(ep.filepath);
+                }).catch(function () {});
+        })(ep, index);
+    }
 }
 
 function renderSidebar(episodes, playing) {
@@ -2616,6 +2664,16 @@ function initVideoControls() {
             }
             return;
         }
+        if (isTranscodedVideo && transcodeDuration > 0 && playingIndex >= 0 && currentEpisodes[playingIndex]) {
+            var pct = e.offsetX / $(this).width();
+            var seekTo = pct * transcodeDuration;
+            transcodeSeekOffset = seekTo;
+            var ep = currentEpisodes[playingIndex];
+            vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath) + '&start=' + seekTo.toFixed(3);
+            vid.load();
+            vid.play();
+            return;
+        }
         if (vid.duration) {
             var pct = e.offsetX / $(this).width();
             vid.currentTime = pct * vid.duration;
@@ -2624,6 +2682,18 @@ function initVideoControls() {
 
     // Video events
     $(vid).on('timeupdate', function () {
+        if (isTranscodedVideo) {
+            var displayTime = vid.currentTime + transcodeSeekOffset;
+            if (transcodeDuration > 0) {
+                var pct = (displayTime / transcodeDuration) * 100;
+                $prog.css('width', pct + '%');
+                $thumb.css('left', 'calc(' + pct + '% - 7px)');
+                $time.text(formatTime(displayTime) + ' / ' + formatTime(transcodeDuration));
+            } else {
+                $time.text(formatTime(displayTime) + ' / --:--');
+            }
+            return;
+        }
         if (!vid.duration) { return; }
         var pct = (vid.currentTime / vid.duration) * 100;
         $prog.css('width', pct + '%');
@@ -2636,13 +2706,16 @@ function initVideoControls() {
         // Periodically save position for videos longer than 1 hr
         if (watchSaveInterval) { clearInterval(watchSaveInterval); }
         watchSaveInterval = setInterval(function () {
-            if (!vid.paused && vid.duration > 3600) { saveWatchPosition(); }
+            var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
+            if (!vid.paused && effectiveDuration > 3600) { saveWatchPosition(); }
         }, 30000);
     });
     $(vid).on('pause', function () {
         $('#play-icon').attr('src', 'img/icons/play_white.svg');
         if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
-        if (vid.duration > 3600 && vid.currentTime > 30) { saveWatchPosition(); }
+        var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
+        var effectiveTime = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
+        if (effectiveDuration > 3600 && effectiveTime > 30) { saveWatchPosition(); }
     });
     $(vid).on('ended', function () {
         clearWatchPosition(); // video finished naturally — remove resume point
@@ -2863,8 +2936,10 @@ function renderInfoContent() {
         html += infoRow('Title',          ep ? ep.name : '–');
         html += infoRow('Resolution',     (vid.videoWidth && vid.videoHeight)
             ? vid.videoWidth + ' × ' + vid.videoHeight : '–');
-        html += infoRow('Duration',       vid.duration   ? formatTime(vid.duration)    : '–');
-        html += infoRow('Position',       vid.currentTime ? formatTime(vid.currentTime) : '–');
+        var infoDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
+        var infoPosition = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
+        html += infoRow('Duration',       infoDuration   ? formatTime(infoDuration)    : '–');
+        html += infoRow('Position',       infoPosition   ? formatTime(infoPosition)    : '–');
         html += infoRow('Playback speed', vid.playbackRate + '×');
         html += infoRow('Volume',         vid.muted ? 'Muted' : Math.round(vid.volume * 100) + '%');
         if (ep) { html += infoRow('File path', ep.filepath || '–'); }
@@ -2927,6 +3002,13 @@ function initKeyboard() {
                         _castSend('media.seekrel', { delta: 10 });
                         castCurrentTime = Math.min(castDuration, castCurrentTime + 10);
                         _castUpdateProgressUI();
+                    } else if (isTranscodedVideo && playingIndex >= 0 && currentEpisodes[playingIndex]) {
+                        var newPos = vid.currentTime + transcodeSeekOffset + 10;
+                        if (transcodeDuration > 0) { newPos = Math.min(transcodeDuration, newPos); }
+                        transcodeSeekOffset = newPos;
+                        var seekEp = currentEpisodes[playingIndex];
+                        vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(seekEp.filepath) + '&start=' + newPos.toFixed(3);
+                        vid.load(); vid.play();
                     } else { vid.currentTime = Math.min(vid.duration || 0, vid.currentTime + 10); }
                     showControls(); break;
                 case 'ArrowLeft':
@@ -2935,6 +3017,12 @@ function initKeyboard() {
                         _castSend('media.seekrel', { delta: -10 });
                         castCurrentTime = Math.max(0, castCurrentTime - 10);
                         _castUpdateProgressUI();
+                    } else if (isTranscodedVideo && playingIndex >= 0 && currentEpisodes[playingIndex]) {
+                        var newPos = Math.max(0, vid.currentTime + transcodeSeekOffset - 10);
+                        transcodeSeekOffset = newPos;
+                        var seekEp = currentEpisodes[playingIndex];
+                        vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(seekEp.filepath) + '&start=' + newPos.toFixed(3);
+                        vid.load(); vid.play();
                     } else { vid.currentTime = Math.max(0, vid.currentTime - 10); }
                     showControls(); break;
                 case 'ArrowUp':

+ 86 - 1
src/web/Musicify/index.html

@@ -721,6 +721,31 @@
             background: var(--accent-glow); border: 1px solid rgba(168,85,247,.25);
             border-radius: 10px; padding: 16px; text-align: center; margin-bottom: 4px;
         }
+        /* ── Settings View ───────────────────────────────────────────────── */
+        .settings-section {
+            background: var(--bg2); border: 1px solid var(--border);
+            border-radius: 10px; padding: 18px; margin-bottom: 20px;
+        }
+        .settings-section-title {
+            font-size: 13px; font-weight: 700; color: var(--text);
+            text-transform: uppercase; letter-spacing: .06em; margin-bottom: 4px;
+        }
+        .settings-section-desc {
+            font-size: 12px; color: var(--text2); margin-bottom: 14px; line-height: 1.5;
+        }
+        .transcode-option {
+            display: flex; align-items: center; gap: 10px;
+            padding: 9px 12px; border-radius: 7px; cursor: pointer;
+            border: 1px solid var(--border); background: var(--bg3);
+            margin-bottom: 8px; transition: border-color .15s, background .15s;
+        }
+        .transcode-option:last-child { margin-bottom: 0; }
+        .transcode-option:hover { border-color: var(--accent); background: var(--bg2); }
+        .transcode-option.selected { border-color: var(--accent); background: var(--active-bg); }
+        .transcode-option input[type=radio] { accent-color: var(--accent); width: 15px; height: 15px; flex-shrink: 0; cursor: pointer; }
+        .transcode-option-label { flex: 1; }
+        .transcode-option-label strong { font-size: 13.5px; color: var(--text); font-weight: 600; }
+        .transcode-option-label span { display: block; font-size: 11px; color: var(--text3); margin-top: 1px; }
     </style>
     <script>
         // ─── Global helpers (must be defined before Alpine evaluates templates) ───────
@@ -803,6 +828,11 @@
                     <i class="ui plus circle icon"></i>
                     <span>New Playlist</span>
                 </div>
+
+                <div class="sidebar-section-label" style="margin-top:8px;">App</div>
+                <a class="nav-item" :class="{active: view==='settings'}" x-on:click="navigateTo('settings')">
+                    <i class="ui sliders horizontal icon"></i> Settings
+                </a>
             </div>
         </nav>
 
@@ -1287,6 +1317,61 @@
                 <br><br><br>
             </div>
 
+            <!-- ── SETTINGS ──────────────────────────────────────────────────── -->
+            <div x-show="view === 'settings' && !loading">
+                <div class="content-header"><h1>Settings</h1></div>
+                <div class="content-body">
+
+                    <!-- Transcode subsection -->
+                    <div class="settings-section">
+                        <div class="settings-section-title">Transcode</div>
+                        <div class="settings-section-desc">
+                            Automatically transcode audio formats not supported by your browser (FLAC, OGG, WMA, Opus, WebM) to MP3 using the server's FFmpeg. Recommended for iPhone and Safari users.
+                        </div>
+
+                        <label class="transcode-option" :class="{selected: transcodeMode==='disabled'}">
+                            <input type="radio" name="transcodeMode" value="disabled" x-model="transcodeMode" x-on:change="saveTranscodeMode()">
+                            <div class="transcode-option-label">
+                                <strong>Disabled</strong>
+                                <span>Use browser's native audio decoder (default on desktop)</span>
+                            </div>
+                        </label>
+
+                        <label class="transcode-option" :class="{selected: transcodeMode==='16'}">
+                            <input type="radio" name="transcodeMode" value="16" x-model="transcodeMode" x-on:change="saveTranscodeMode()">
+                            <div class="transcode-option-label">
+                                <strong>16 kHz MP3</strong>
+                                <span>Low quality · lowest bandwidth · voice / podcasts</span>
+                            </div>
+                        </label>
+
+                        <label class="transcode-option" :class="{selected: transcodeMode==='24'}">
+                            <input type="radio" name="transcodeMode" value="24" x-model="transcodeMode" x-on:change="saveTranscodeMode()">
+                            <div class="transcode-option-label">
+                                <strong>24 kHz MP3</strong>
+                                <span>Medium quality · balanced bandwidth</span>
+                            </div>
+                        </label>
+
+                        <label class="transcode-option" :class="{selected: transcodeMode==='48'}">
+                            <input type="radio" name="transcodeMode" value="48" x-model="transcodeMode" x-on:change="saveTranscodeMode()">
+                            <div class="transcode-option-label">
+                                <strong>48 kHz MP3</strong>
+                                <span>Best quality · recommended for music · default</span>
+                            </div>
+                        </label>
+
+                        <div style="margin-top:14px;padding:10px 12px;background:var(--bg3);border-radius:7px;border:1px solid var(--border);">
+                            <span style="font-size:12px;color:var(--text3);">
+                                <i class="ui info circle icon" style="color:var(--accent);"></i>
+                                Transcoding requires FFmpeg on the server. Seeking on transcoded tracks restarts the stream from the new position.
+                            </span>
+                        </div>
+                    </div>
+
+                </div>
+            </div>
+
         </main><!-- /.main-content -->
 
         <!-- ═══ QUEUE PANEL ════════════════════════════════════════════════ -->
@@ -1380,7 +1465,7 @@
                         x-on:change="endSeek($event.target.value)"
                         x-on:input="currentTime = parseFloat($event.target.value)">
                 </div>
-                <span class="seek-time right" x-text="formatTime(duration)"></span>
+                <span class="seek-time right" x-text="(_currentTrackTranscoded && !duration) ? '--:--' : formatTime(duration)"></span>
             </div>
         </div>
 

+ 137 - 10
src/web/Musicify/musicify.js

@@ -98,6 +98,11 @@ function musicifyApp() {
         // ── Helpers (accessible from Alpine template expressions) ─────────────
         isSidebarDesktop() { return window.innerWidth > 768; },
 
+        // ── Transcode ────────────────────────────────────────────────────────
+        transcodeMode: '48',           // 'disabled' | '16' | '24' | '48' (kHz)
+        _transcodeSeekOffset: 0,       // seconds already seeked past in current transcode stream
+        _currentTrackTranscoded: false,// true when current track is served via transcode endpoint
+
         // ── Arozcast ─────────────────────────────────────────────────────────
         castMode: false,
         castConnected: false,
@@ -125,10 +130,18 @@ function musicifyApp() {
             const self = this;
 
             this._audio.addEventListener('timeupdate', () => {
-                if (!self.isSeeking) self.currentTime = self._audio.currentTime;
+                if (!self.isSeeking) {
+                    self.currentTime = self._audio.currentTime + self._transcodeSeekOffset;
+                }
             });
             this._audio.addEventListener('loadedmetadata', () => {
-                self.duration = self._audio.duration || 0;
+                var d = self._audio.duration;
+                if (self._currentTrackTranscoded) {
+                    // Don't override the pre-fetched duration for transcoded streams
+                    if (!self.duration && d && isFinite(d) && d > 0) self.duration = d;
+                } else {
+                    self.duration = (d && isFinite(d)) ? d : 0;
+                }
             });
             this._audio.addEventListener('ended', () => { self._onEnded(); });
             this._audio.addEventListener('error', () => { self._onError(); });
@@ -157,6 +170,10 @@ function musicifyApp() {
                     try { self.recentlyPlayed = JSON.parse(val).slice(0, 12); } catch(e) {}
                 }
             });
+            var _savedTranscode = localStorage.getItem('musicify_transcodeMode');
+            if (_savedTranscode === 'disabled' || _savedTranscode === '16' || _savedTranscode === '24' || _savedTranscode === '48') {
+                this.transcodeMode = _savedTranscode;
+            }
 
             // MediaSession
             this._setupMediaSession();
@@ -868,6 +885,8 @@ function musicifyApp() {
             this.coverError = false;
             this.currentTime = 0;
             this.duration = 0;
+            this._transcodeSeekOffset = 0;
+            this._currentTrackTranscoded = false;
             if (this.castMode) {
                 this._castSend('media.load', {
                     filepath: song.filepath,
@@ -879,9 +898,20 @@ function musicifyApp() {
                 this._audio.pause();
                 this.isPlaying = true;
             } else {
-                this._audio.src = ao_root + 'media?file=' + encodeURIComponent(song.filepath);
+                this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(song));
+                this._audio.src = this._getAudioSrc(song);
                 this._audio.load();
                 this._audio.play().catch(() => {});
+                if (this._currentTrackTranscoded) {
+                    var _prefetchSong = song;
+                    fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(song.filepath))
+                        .then(r => r.json())
+                        .then(data => {
+                            if (data.duration > 0 && this.currentTrack && this.currentTrack.filepath === _prefetchSong.filepath) {
+                                this.duration = data.duration;
+                            }
+                        }).catch(() => {});
+                }
             }
             this._saveRecentlyPlayed(song);
             this._setupMediaSession();
@@ -947,12 +977,22 @@ function musicifyApp() {
         },
 
         seekTo(val) {
+            val = parseFloat(val);
             if (this.castMode) {
-                this._castSend('media.seek', { time: parseFloat(val) });
-                this.currentTime = parseFloat(val);
+                this._castSend('media.seek', { time: val });
+                this.currentTime = val;
+                return;
+            }
+            if (this._currentTrackTranscoded && this.currentTrack) {
+                // Seek by reloading the transcode stream from the new position
+                this._transcodeSeekOffset = val;
+                this.currentTime = val;
+                this._audio.src = this._getAudioSrc(this.currentTrack, val);
+                this._audio.load();
+                this._audio.play().catch(() => {});
                 return;
             }
-            this._audio.currentTime = parseFloat(val);
+            this._audio.currentTime = val;
             this.currentTime = this._audio.currentTime;
         },
 
@@ -995,6 +1035,78 @@ function musicifyApp() {
             }
         },
 
+        // ════════════════════════════════════════════════════════════════════
+        //  TRANSCODE HELPERS
+        // ════════════════════════════════════════════════════════════════════
+        _needsTranscode(song) {
+            if (!song || !song.ext) return false;
+            var nonNative = ['flac', 'ogg', 'wma', 'webm', 'opus'];
+            return nonNative.indexOf(song.ext.toLowerCase()) !== -1;
+        },
+
+        // Returns the playback URL for a song, using the transcode endpoint when needed.
+        // startTime (seconds) is only appended when seeking a transcoded stream.
+        _getAudioSrc(song, startTime) {
+            if (!song) return '';
+            if (this.transcodeMode !== 'disabled' && this._needsTranscode(song)) {
+                var url = ao_root + 'media/transcode/audio/?file=' + encodeURIComponent(song.filepath) +
+                    '&samplerate=' + this.transcodeMode + '000';
+                if (startTime && startTime > 0.001) url += '&start=' + parseFloat(startTime).toFixed(3);
+                return url;
+            }
+            return ao_root + 'media?file=' + encodeURIComponent(song.filepath);
+        },
+
+        saveTranscodeMode() {
+            localStorage.setItem('musicify_transcodeMode', this.transcodeMode);
+
+            // If a non-native track is currently loaded, reload it immediately at the
+            // current position so seeks work correctly under the new mode.
+            if (this.currentTrack && this._needsTranscode(this.currentTrack) && !this.castMode) {
+                var resumeAt = this.currentTime; // already includes _transcodeSeekOffset
+                var wasPlaying = this.isPlaying;
+                var willTranscode = (this.transcodeMode !== 'disabled');
+
+                this._suppressEnded = true;
+                this._transcodeSeekOffset = 0;
+                this._currentTrackTranscoded = willTranscode;
+                this.duration = 0;
+
+                if (willTranscode && resumeAt > 0.001) {
+                    // Transcoded seek: bake the position into the stream URL
+                    this._transcodeSeekOffset = resumeAt;
+                    this._audio.src = this._getAudioSrc(this.currentTrack, resumeAt);
+                } else {
+                    this._audio.src = this._getAudioSrc(this.currentTrack);
+                }
+                this._audio.load();
+
+                if (willTranscode) {
+                    // Re-fetch duration for the transcoded stream
+                    var _song = this.currentTrack;
+                    fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(_song.filepath))
+                        .then(r => r.json())
+                        .then(data => {
+                            if (data.duration > 0 && this.currentTrack && this.currentTrack.filepath === _song.filepath) {
+                                this.duration = data.duration;
+                            }
+                        }).catch(() => {});
+                } else if (resumeAt > 0) {
+                    // Native audio: seek to position after metadata is ready
+                    var self = this;
+                    this._audio.addEventListener('loadedmetadata', function() {
+                        self._audio.currentTime = resumeAt;
+                    }, { once: true });
+                }
+
+                if (wasPlaying) {
+                    this._audio.play().catch(() => {});
+                }
+            }
+
+            this._showToast('Transcode: ' + (this.transcodeMode === 'disabled' ? 'disabled' : this.transcodeMode + ' kHz'));
+        },
+
         _onEnded() {
             if (this._suppressEnded) return;
             if (this.repeat === 'one') {
@@ -1394,11 +1506,19 @@ function musicifyApp() {
                     // All retries exhausted — fall back to local playback
                     if (this.currentTrack) {
                         var resumeAt = this.currentTime;
-                        this._audio.src = ao_root + 'media?file=' + encodeURIComponent(this.currentTrack.filepath);
+                        var self = this;
+                        this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(this.currentTrack));
+                        this._transcodeSeekOffset = 0;
+                        if (this._currentTrackTranscoded && resumeAt > 0.001) {
+                            this._transcodeSeekOffset = resumeAt;
+                            this._audio.src = this._getAudioSrc(this.currentTrack, resumeAt);
+                        } else {
+                            this._audio.src = this._getAudioSrc(this.currentTrack);
+                        }
                         this._audio.volume = this.volume / 100;
                         this._audio.muted = this.isMuted;
                         this._audio.load();
-                        if (resumeAt > 0) {
+                        if (!this._currentTrackTranscoded && resumeAt > 0) {
                             this._audio.addEventListener('loadedmetadata', function() {
                                 self._audio.currentTime = resumeAt;
                             }, { once: true });
@@ -1511,11 +1631,18 @@ function musicifyApp() {
             if (this.currentTrack) {
                 var resumeAt = this.currentTime;
                 var self = this;
-                this._audio.src = ao_root + 'media?file=' + encodeURIComponent(this.currentTrack.filepath);
+                this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(this.currentTrack));
+                this._transcodeSeekOffset = 0;
+                if (this._currentTrackTranscoded && resumeAt > 0.001) {
+                    this._transcodeSeekOffset = resumeAt;
+                    this._audio.src = this._getAudioSrc(this.currentTrack, resumeAt);
+                } else {
+                    this._audio.src = this._getAudioSrc(this.currentTrack);
+                }
                 this._audio.volume = this.volume / 100;
                 this._audio.muted = this.isMuted;
                 this._audio.load();
-                if (resumeAt > 0) {
+                if (!this._currentTrackTranscoded && resumeAt > 0) {
                     this._audio.addEventListener('loadedmetadata', function() {
                         self._audio.currentTime = resumeAt;
                     }, { once: true });