Compare commits
10 Commits
60f86652f7
...
7c69d73083
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7c69d73083 | ||
![]() |
90c3398ba3 | ||
![]() |
9abe867c31 | ||
![]() |
ccfaefbf46 | ||
![]() |
a1e889778c | ||
![]() |
01bd11a942 | ||
![]() |
b4aaa4410c | ||
![]() |
010c6bd191 | ||
![]() |
14adf10c68 | ||
![]() |
90f86feb39 |
10
README.md
10
README.md
@@ -49,6 +49,10 @@ Build and push this container to ghcr
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
- Video Page
|
||||||
|
- [ ] Link to author
|
||||||
|
- Videos Page
|
||||||
|
- [x] Link to author
|
||||||
- [ ] License
|
- [ ] License
|
||||||
- [x] Delete failed videos
|
- [x] Delete failed videos
|
||||||
- [ ] edit original metadata
|
- [ ] edit original metadata
|
||||||
@@ -62,16 +66,14 @@ Build and push this container to ghcr
|
|||||||
- [x] `ffmpeg` version
|
- [x] `ffmpeg` version
|
||||||
- [x] `yt-dlp` version
|
- [x] `yt-dlp` version
|
||||||
- [x] disk space
|
- [x] disk space
|
||||||
- [ ] add progress bar to represent usage
|
- [x] add progress bar to represent usage
|
||||||
- [ ] CPU history
|
- [ ] CPU history
|
||||||
- [ ] skip buttons for audio player
|
- [ ] skip buttons for audio player
|
||||||
- [ ] Track progress via video URL rather than ID
|
- [ ] Track progress via video URL rather than ID
|
||||||
- [ ] Show author on video page
|
|
||||||
- [ ] Link to author on video page
|
|
||||||
- [ ] Link to author on videos page
|
|
||||||
- [ ] video clips
|
- [ ] video clips
|
||||||
- [ ] move original video to bottom
|
- [ ] move original video to bottom
|
||||||
- [x] sort videos most to least recent
|
- [x] sort videos most to least recent
|
||||||
- [x] header on playlist page
|
- [x] header on playlist page
|
||||||
- [x] Choose database directory
|
- [x] Choose database directory
|
||||||
- [x] add extension to download
|
- [x] add extension to download
|
||||||
|
- [x] server-side watch progress
|
||||||
|
42
handlers.go
42
handlers.go
@@ -115,8 +115,10 @@ func downloadPostHandler(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Meta struct {
|
type Meta struct {
|
||||||
title string
|
title string
|
||||||
artist string
|
artist string
|
||||||
|
uploadDate time.Time
|
||||||
|
uploaderUrl string
|
||||||
}
|
}
|
||||||
|
|
||||||
func getYtdlpTitle(url string, args []string) (string, error) {
|
func getYtdlpTitle(url string, args []string) (string, error) {
|
||||||
@@ -172,8 +174,26 @@ func getYtdlpArtist(url string, args []string) (string, error) {
|
|||||||
return strings.TrimSpace(string(stdout)), nil
|
return strings.TrimSpace(string(stdout)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getYtdlpExt(url string, args []string) (string, error) {
|
func getYtdlpUploadDate(url string, args []string) (time.Time, error) {
|
||||||
args = append(args, "--simulate", "--print", "%(ext)s", url)
|
args = append(args, "--simulate", "--print", "%(upload_date>%Y-%m-%d)s", url)
|
||||||
|
stdout, _, err := ytdlp.Run(args...)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln(err)
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dateStr := strings.TrimSpace(string(stdout))
|
||||||
|
|
||||||
|
uploadDate, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("failed to parse date '%s': %w", dateStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploadDate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getYtdlpUploaderUrl(url string, args []string) (string, error) {
|
||||||
|
args = append(args, "--simulate", "--print", "%(uploader_url)s", url)
|
||||||
stdout, _, err := ytdlp.Run(args...)
|
stdout, _, err := ytdlp.Run(args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
@@ -195,6 +215,14 @@ func getYtdlpMeta(url string, args []string) (Meta, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return meta, err
|
return meta, err
|
||||||
}
|
}
|
||||||
|
meta.uploadDate, err = getYtdlpUploadDate(url, args)
|
||||||
|
if err != nil {
|
||||||
|
return meta, err
|
||||||
|
}
|
||||||
|
meta.uploaderUrl, err = getYtdlpUploaderUrl(url, args)
|
||||||
|
if err != nil {
|
||||||
|
return meta, err
|
||||||
|
}
|
||||||
|
|
||||||
return meta, nil
|
return meta, nil
|
||||||
}
|
}
|
||||||
@@ -556,8 +584,10 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
|
|||||||
}
|
}
|
||||||
log.Debugf("original metadata %v", origMeta)
|
log.Debugf("original metadata %v", origMeta)
|
||||||
err = db.Model(&originals.Original{}).Where("id = ?", originalID).Updates(map[string]interface{}{
|
err = db.Model(&originals.Original{}).Where("id = ?", originalID).Updates(map[string]interface{}{
|
||||||
"title": origMeta.title,
|
"title": origMeta.title,
|
||||||
"artist": origMeta.artist,
|
"artist": origMeta.artist,
|
||||||
|
"upload_date": origMeta.uploadDate,
|
||||||
|
"uploader_url": origMeta.uploaderUrl,
|
||||||
}).Error
|
}).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorln("couldn't store metadata:", err)
|
log.Errorln("couldn't store metadata:", err)
|
||||||
|
54
handlers/set_timestamp.go
Normal file
54
handlers/set_timestamp.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"ytdlp-site/database"
|
||||||
|
"ytdlp-site/originals"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetTimestamp(c echo.Context) error {
|
||||||
|
// Parse the ID from the URL parameter
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid ID format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a struct to receive the timestamp from JSON
|
||||||
|
type TimestampRequest struct {
|
||||||
|
Timestamp float64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the request body
|
||||||
|
var req TimestampRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
log.Errorln(err)
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugln("set video", id, "timestamp:", req.Timestamp)
|
||||||
|
|
||||||
|
// Update the timestamp field with the value from the request
|
||||||
|
db := database.Get()
|
||||||
|
result := db.Model(&originals.Original{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Update("timestamp", req.Timestamp)
|
||||||
|
|
||||||
|
// Handle database errors
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Errorln(result.Error)
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Database error"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if record was found
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
log.Errorln(gorm.ErrRecordNotFound)
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Record not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.NoContent(http.StatusOK)
|
||||||
|
}
|
1
main.go
1
main.go
@@ -144,6 +144,7 @@ func main() {
|
|||||||
e.GET("/temp/:token", tempHandler)
|
e.GET("/temp/:token", tempHandler)
|
||||||
e.POST("/video/:id/process", processHandler, handlers.AuthMiddleware)
|
e.POST("/video/:id/process", processHandler, handlers.AuthMiddleware)
|
||||||
e.POST("/video/:id/toggle_watched", handlers.ToggleWatched, handlers.AuthMiddleware)
|
e.POST("/video/:id/toggle_watched", handlers.ToggleWatched, handlers.AuthMiddleware)
|
||||||
|
e.POST("/video/:id/set_timestamp", handlers.SetTimestamp, handlers.AuthMiddleware)
|
||||||
e.POST("/delete_video/:id", deleteVideoHandler, handlers.AuthMiddleware)
|
e.POST("/delete_video/:id", deleteVideoHandler, handlers.AuthMiddleware)
|
||||||
e.POST("/delete_audio/:id", deleteAudioHandler, handlers.AuthMiddleware)
|
e.POST("/delete_audio/:id", deleteAudioHandler, handlers.AuthMiddleware)
|
||||||
e.POST("/transcode_to_video/:id", transcodeToVideoHandler, handlers.AuthMiddleware)
|
e.POST("/transcode_to_video/:id", transcodeToVideoHandler, handlers.AuthMiddleware)
|
||||||
|
@@ -2,6 +2,7 @@ package originals
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
"ytdlp-site/database"
|
"ytdlp-site/database"
|
||||||
"ytdlp-site/transcodes"
|
"ytdlp-site/transcodes"
|
||||||
|
|
||||||
@@ -23,14 +24,17 @@ const (
|
|||||||
|
|
||||||
type Original struct {
|
type Original struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
UserID uint
|
UserID uint
|
||||||
URL string
|
URL string
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Status Status
|
UploadDate time.Time
|
||||||
Audio bool // video download requested
|
UploaderUrl string
|
||||||
Video bool // audio download requested
|
Status Status
|
||||||
Watched bool
|
Audio bool // video download requested
|
||||||
|
Video bool // audio download requested
|
||||||
|
Watched bool
|
||||||
|
Timestamp float64
|
||||||
|
|
||||||
Playlist bool // part of a playlist
|
Playlist bool // part of a playlist
|
||||||
PlaylistID uint // Playlist.ID (if part of a playlist)
|
PlaylistID uint // Playlist.ID (if part of a playlist)
|
||||||
|
@@ -1,42 +1,99 @@
|
|||||||
|
|
||||||
// Get all video and audio elements
|
// Get all video and audio elements
|
||||||
const mediaElements = document.querySelectorAll('video, audio');
|
const mediaElements = document.querySelectorAll('video, audio');
|
||||||
|
|
||||||
// Generate a unique key for this page
|
// Get the video ID from the hidden input
|
||||||
const pageKey = `mediaProgress_${window.location.pathname}`;
|
const videoId = document.getElementById('video-id').value;
|
||||||
|
|
||||||
// Function to save the current time of the most recently played media
|
// Get the initial timestamp from the hidden input
|
||||||
function saveMediaProgress(media) {
|
const initialTimestamp = parseFloat(document.getElementById('video-timestamp').value || 0);
|
||||||
localStorage.setItem(pageKey, media.currentTime);
|
|
||||||
|
// Track which media element is currently playing
|
||||||
|
let activeMedia = null;
|
||||||
|
let updateTimer = null;
|
||||||
|
const UPDATE_INTERVAL = 5000; // 5 seconds in milliseconds
|
||||||
|
|
||||||
|
// Function to send the current time to the server
|
||||||
|
function sendTimestampToServer(timestamp) {
|
||||||
|
console.log(videoId, "->", timestamp)
|
||||||
|
fetch(`/video/${videoId}/set_timestamp`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ "timestamp": timestamp })
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error sending timestamp to server:', error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to load and set the saved time for all media elements
|
// Function to start the update timer
|
||||||
function loadMediaProgress() {
|
function startUpdateTimer() {
|
||||||
const savedTime = localStorage.getItem(pageKey);
|
// Clear any existing timer
|
||||||
if (savedTime) {
|
stopUpdateTimer();
|
||||||
mediaElements.forEach(media => {
|
|
||||||
media.currentTime = Math.min(parseFloat(savedTime), media.duration || Infinity);
|
// Start a new timer that sends updates every 5 seconds
|
||||||
});
|
updateTimer = setInterval(() => {
|
||||||
|
if (activeMedia && !activeMedia.paused) {
|
||||||
|
sendTimestampToServer(activeMedia.currentTime);
|
||||||
|
}
|
||||||
|
}, UPDATE_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to stop the update timer
|
||||||
|
function stopUpdateTimer() {
|
||||||
|
if (updateTimer) {
|
||||||
|
clearInterval(updateTimer);
|
||||||
|
updateTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up event listeners for each media element
|
// Set up event listeners for each media element
|
||||||
mediaElements.forEach(media => {
|
mediaElements.forEach(media => {
|
||||||
// Save progress when the media is playing
|
// Set initial timestamp when media is ready
|
||||||
media.addEventListener('timeupdate', () => {
|
media.addEventListener('loadedmetadata', () => {
|
||||||
if (!media.paused) {
|
// Only set if the initial timestamp is within the media duration
|
||||||
saveMediaProgress(media);
|
if (initialTimestamp > 0 && initialTimestamp < media.duration) {
|
||||||
|
media.currentTime = initialTimestamp;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also save when the media is paused
|
// When media starts playing
|
||||||
media.addEventListener('pause', () => saveMediaProgress(media));
|
media.addEventListener('play', () => {
|
||||||
|
// Set this as the active media
|
||||||
|
activeMedia = media;
|
||||||
|
// Start the update timer
|
||||||
|
startUpdateTimer();
|
||||||
|
});
|
||||||
|
|
||||||
// Load the saved progress when the media is ready
|
// When media is paused
|
||||||
media.addEventListener('loadedmetadata', loadMediaProgress);
|
media.addEventListener('pause', () => {
|
||||||
|
// Send the current timestamp
|
||||||
|
sendTimestampToServer(media.currentTime);
|
||||||
|
|
||||||
// Clear progress when any media ends
|
// If this is the active media, stop the timer
|
||||||
|
if (activeMedia === media) {
|
||||||
|
stopUpdateTimer();
|
||||||
|
activeMedia = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset timestamp when media ends
|
||||||
media.addEventListener('ended', () => {
|
media.addEventListener('ended', () => {
|
||||||
localStorage.removeItem(pageKey);
|
sendTimestampToServer(0);
|
||||||
|
|
||||||
|
// If this is the active media, stop the timer
|
||||||
|
if (activeMedia === media) {
|
||||||
|
stopUpdateTimer();
|
||||||
|
activeMedia = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up when the page is unloaded
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
// Send final timestamp if media is playing
|
||||||
|
if (activeMedia && !activeMedia.paused) {
|
||||||
|
sendTimestampToServer(activeMedia.currentTime);
|
||||||
|
}
|
||||||
|
stopUpdateTimer();
|
||||||
|
});
|
||||||
|
@@ -11,3 +11,11 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-artist-link {
|
||||||
|
|
||||||
|
a,
|
||||||
|
a:visited {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
}
|
@@ -5,7 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="build-id">
|
<div class="build-id">
|
||||||
Build ID: <a
|
Build ID: <a
|
||||||
href="https://github.com/cwpearson/ytdlp-site/compare/{{.Footer.BuildId}}..master">{{.Footer.BuildIdShort}}</a>
|
href="https://git.sr.ht/~cwpearson/ytdlp-site/commit/{{.Footer.BuildId}}">{{.Footer.BuildIdShort}}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@@ -56,6 +56,7 @@
|
|||||||
<select name="fps" id="fps-select">
|
<select name="fps" id="fps-select">
|
||||||
<option value="24">24 fps</option>
|
<option value="24">24 fps</option>
|
||||||
<option value="25">25 fps</option>
|
<option value="25">25 fps</option>
|
||||||
|
<option value="29.97">29.97 fps</option>
|
||||||
<option value="30">30 fps</option>
|
<option value="30">30 fps</option>
|
||||||
<option value="59.94">59.94 fps</option>
|
<option value="59.94">59.94 fps</option>
|
||||||
<option value="60">60 fps</option>
|
<option value="60">60 fps</option>
|
||||||
@@ -104,7 +105,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="video-id" value="{{.original.ID}}">
|
||||||
|
<input type="hidden" id="video-timestamp" value="{{.original.Timestamp}}">
|
||||||
<script src="/static/script/save-media-progress.js"></script>
|
<script src="/static/script/save-media-progress.js"></script>
|
||||||
|
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
|
@@ -35,7 +35,11 @@
|
|||||||
<div class="video-title video-title-bare {{$bareHidden}}">
|
<div class="video-title video-title-bare {{$bareHidden}}">
|
||||||
{{.Title}}
|
{{.Title}}
|
||||||
</div>
|
</div>
|
||||||
<div class="video-info">{{.Artist}}</div>
|
{{if (eq .UploaderUrl "")}}
|
||||||
|
<div class="video-info video-artist-bare">{{.Artist}}</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="video-info video-artist-link"><a href="{{.UploaderUrl}}">{{.Artist}}</a></div>
|
||||||
|
{{end}}
|
||||||
<div class="video-info"><a href="{{.URL}}">{{.URL}}</a></div>
|
<div class="video-info"><a href="{{.URL}}">{{.URL}}</a></div>
|
||||||
<div class="video-info video-status">{{.Status}}</div>
|
<div class="video-info video-status">{{.Status}}</div>
|
||||||
<div class="video-info">
|
<div class="video-info">
|
||||||
|
Reference in New Issue
Block a user