Compare commits
17 Commits
60f86652f7
...
master
Author | SHA1 | Date | |
---|---|---|---|
f668f59f70 | |||
91a9c69cba | |||
2b00bec445 | |||
54c6740dfe | |||
5ef8692a3a | |||
e5e1987bd1 | |||
ff3b2765a7 | |||
![]() |
7c69d73083 | ||
![]() |
90c3398ba3 | ||
![]() |
9abe867c31 | ||
![]() |
ccfaefbf46 | ||
![]() |
a1e889778c | ||
![]() |
01bd11a942 | ||
![]() |
b4aaa4410c | ||
![]() |
010c6bd191 | ||
![]() |
14adf10c68 | ||
![]() |
90f86feb39 |
33
.build.yaml
33
.build.yaml
@@ -1,33 +0,0 @@
|
||||
image: debian/bookworm
|
||||
|
||||
secrets:
|
||||
- a19cbc4c-b6a1-414e-bf1c-1e06abc684eb
|
||||
|
||||
tasks:
|
||||
- print-env: |
|
||||
echo "$JOB_ID"
|
||||
echo "$JOB_URL"
|
||||
echo "$BUILD_SUBMITTER"
|
||||
echo "$BUILD_REASON"
|
||||
echo "$GIT_REF"
|
||||
- setup-env: |
|
||||
sudo timedatectl set-timezone America/Denver
|
||||
echo "SLUG=ghcr.io/cwpearson/ytdlp-site" >> ~/.buildenv
|
||||
echo "DATE=$(date +"%Y%m%d_%H%M")" >> ~/.buildenv
|
||||
- prerequisites: |
|
||||
bash ytdlp-site/.ci/debian_setup_docker.sh
|
||||
- build: |
|
||||
cd ytdlp-site
|
||||
docker build . --file Dockerfile --build-arg GIT_SHA=$(git rev-parse HEAD) --tag "$SLUG:$DATE" --tag "$SLUG:latest"
|
||||
- deploy: |
|
||||
if [ "$GIT_REF" != "refs/heads/master" ]; then exit 0; fi
|
||||
set +x
|
||||
cat ~/.ghcr_token | docker login ghcr.io -u cwpearson --password-stdin
|
||||
set -x
|
||||
docker push "$SLUG:latest"
|
||||
docker push "$SLUG:$DATE"
|
||||
|
||||
triggers:
|
||||
- action: email
|
||||
condition: failure
|
||||
to: Carl Pearson <srht@carlpearson.net>
|
17
.ci/ubuntu_setup_docker.sh
Normal file
17
.ci/ubuntu_setup_docker.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
# Add Docker's official GPG key:
|
||||
sudo apt-get update
|
||||
sudo apt-get install ca-certificates curl
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
# Add the repository to Apt sources:
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt-get update
|
||||
|
||||
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
sudo usermod -aG docker $(whoami)
|
55
.gitea/workflows/build-deploy.yml
Normal file
55
.gitea/workflows/build-deploy.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Build and Deploy ytdlp-site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
# Runs every Friday at 2:30 AM UTC
|
||||
- cron: '30 2 * * 5'
|
||||
env:
|
||||
SLUG: git.carlpearson.net/cwpearson/ytdlp-site
|
||||
TZ: America/Denver
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Print environment info
|
||||
run: |
|
||||
echo "Job ID: ${{ github.run_id }}"
|
||||
echo "Job URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "Build Submitter: ${{ github.actor }}"
|
||||
echo "Build Reason: ${{ github.event_name }}"
|
||||
echo "Git Ref: ${{ github.ref }}"
|
||||
|
||||
- name: Setup environment
|
||||
run: |
|
||||
echo "DATE=$(date +"%Y%m%d_%H%M")" >> $GITHUB_ENV
|
||||
echo "GIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Install prerequisites
|
||||
run: |
|
||||
bash .ci/ubuntu_setup_docker.sh
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build . \
|
||||
--file Dockerfile \
|
||||
--build-arg GIT_SHA=${{ env.GIT_SHA }} \
|
||||
--tag ${{ env.SLUG }}:${{ env.DATE }} \
|
||||
--tag ${{ env.SLUG }}:latest
|
||||
|
||||
- name: Deploy to Container Registry
|
||||
if: gitea.ref == 'refs/heads/master' && gitea.event_name == 'push'
|
||||
run: |
|
||||
echo "${{ secrets.GIT_PASSWORD }}" | docker login git.carlpearson.net -u "${{ secrets.GIT_USERNAME }}" --password-stdin
|
||||
docker push ${{ env.SLUG }}:latest
|
||||
docker push ${{ env.SLUG }}:${{ env.DATE }}
|
@@ -13,10 +13,10 @@ ADD ffmpeg /src/ffmpeg
|
||||
ADD handlers /src/handlers
|
||||
ADD media /src/media
|
||||
ADD originals /src/originals
|
||||
Add playlists /src/playlists
|
||||
ADD playlists /src/playlists
|
||||
ADD transcodes /src/transcodes
|
||||
ADD users /src/users
|
||||
Add ytdlp /src/ytdlp
|
||||
ADD ytdlp /src/ytdlp
|
||||
ADD go.mod /src/.
|
||||
|
||||
RUN cd /src && go mod tidy
|
||||
|
10
README.md
10
README.md
@@ -49,6 +49,10 @@ Build and push this container to ghcr
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Video Page
|
||||
- [ ] Link to author
|
||||
- Videos Page
|
||||
- [x] Link to author
|
||||
- [ ] License
|
||||
- [x] Delete failed videos
|
||||
- [ ] edit original metadata
|
||||
@@ -62,16 +66,14 @@ Build and push this container to ghcr
|
||||
- [x] `ffmpeg` version
|
||||
- [x] `yt-dlp` version
|
||||
- [x] disk space
|
||||
- [ ] add progress bar to represent usage
|
||||
- [x] add progress bar to represent usage
|
||||
- [ ] CPU history
|
||||
- [ ] skip buttons for audio player
|
||||
- [ ] 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
|
||||
- [ ] move original video to bottom
|
||||
- [x] sort videos most to least recent
|
||||
- [x] header on playlist page
|
||||
- [x] Choose database directory
|
||||
- [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 {
|
||||
title string
|
||||
artist string
|
||||
title string
|
||||
artist string
|
||||
uploadDate time.Time
|
||||
uploaderUrl string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func getYtdlpExt(url string, args []string) (string, error) {
|
||||
args = append(args, "--simulate", "--print", "%(ext)s", url)
|
||||
func getYtdlpUploadDate(url string, args []string) (time.Time, error) {
|
||||
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...)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
@@ -195,6 +215,14 @@ func getYtdlpMeta(url string, args []string) (Meta, error) {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
@@ -556,8 +584,10 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
|
||||
}
|
||||
log.Debugf("original metadata %v", origMeta)
|
||||
err = db.Model(&originals.Original{}).Where("id = ?", originalID).Updates(map[string]interface{}{
|
||||
"title": origMeta.title,
|
||||
"artist": origMeta.artist,
|
||||
"title": origMeta.title,
|
||||
"artist": origMeta.artist,
|
||||
"upload_date": origMeta.uploadDate,
|
||||
"uploader_url": origMeta.uploaderUrl,
|
||||
}).Error
|
||||
if err != nil {
|
||||
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.POST("/video/:id/process", processHandler, 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_audio/:id", deleteAudioHandler, handlers.AuthMiddleware)
|
||||
e.POST("/transcode_to_video/:id", transcodeToVideoHandler, handlers.AuthMiddleware)
|
||||
|
@@ -2,6 +2,7 @@ package originals
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
"ytdlp-site/database"
|
||||
"ytdlp-site/transcodes"
|
||||
|
||||
@@ -23,14 +24,17 @@ const (
|
||||
|
||||
type Original struct {
|
||||
gorm.Model
|
||||
UserID uint
|
||||
URL string
|
||||
Title string
|
||||
Artist string
|
||||
Status Status
|
||||
Audio bool // video download requested
|
||||
Video bool // audio download requested
|
||||
Watched bool
|
||||
UserID uint
|
||||
URL string
|
||||
Title string
|
||||
Artist string
|
||||
UploadDate time.Time
|
||||
UploaderUrl string
|
||||
Status Status
|
||||
Audio bool // video download requested
|
||||
Video bool // audio download requested
|
||||
Watched bool
|
||||
Timestamp float64
|
||||
|
||||
Playlist bool // part of a playlist
|
||||
PlaylistID uint // Playlist.ID (if part of a playlist)
|
||||
|
@@ -1,42 +1,99 @@
|
||||
|
||||
// Get all video and audio elements
|
||||
const mediaElements = document.querySelectorAll('video, audio');
|
||||
|
||||
// Generate a unique key for this page
|
||||
const pageKey = `mediaProgress_${window.location.pathname}`;
|
||||
// Get the video ID from the hidden input
|
||||
const videoId = document.getElementById('video-id').value;
|
||||
|
||||
// Function to save the current time of the most recently played media
|
||||
function saveMediaProgress(media) {
|
||||
localStorage.setItem(pageKey, media.currentTime);
|
||||
// Get the initial timestamp from the hidden input
|
||||
const initialTimestamp = parseFloat(document.getElementById('video-timestamp').value || 0);
|
||||
|
||||
// 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 loadMediaProgress() {
|
||||
const savedTime = localStorage.getItem(pageKey);
|
||||
if (savedTime) {
|
||||
mediaElements.forEach(media => {
|
||||
media.currentTime = Math.min(parseFloat(savedTime), media.duration || Infinity);
|
||||
});
|
||||
// Function to start the update timer
|
||||
function startUpdateTimer() {
|
||||
// Clear any existing timer
|
||||
stopUpdateTimer();
|
||||
|
||||
// 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
|
||||
mediaElements.forEach(media => {
|
||||
// Save progress when the media is playing
|
||||
media.addEventListener('timeupdate', () => {
|
||||
if (!media.paused) {
|
||||
saveMediaProgress(media);
|
||||
// Set initial timestamp when media is ready
|
||||
media.addEventListener('loadedmetadata', () => {
|
||||
// Only set if the initial timestamp is within the media duration
|
||||
if (initialTimestamp > 0 && initialTimestamp < media.duration) {
|
||||
media.currentTime = initialTimestamp;
|
||||
}
|
||||
});
|
||||
|
||||
// Also save when the media is paused
|
||||
media.addEventListener('pause', () => saveMediaProgress(media));
|
||||
|
||||
// Load the saved progress when the media is ready
|
||||
media.addEventListener('loadedmetadata', loadMediaProgress);
|
||||
|
||||
// Clear progress when any media ends
|
||||
media.addEventListener('ended', () => {
|
||||
localStorage.removeItem(pageKey);
|
||||
// When media starts playing
|
||||
media.addEventListener('play', () => {
|
||||
// Set this as the active media
|
||||
activeMedia = media;
|
||||
// Start the update timer
|
||||
startUpdateTimer();
|
||||
});
|
||||
});
|
||||
|
||||
// When media is paused
|
||||
media.addEventListener('pause', () => {
|
||||
// Send the current timestamp
|
||||
sendTimestampToServer(media.currentTime);
|
||||
|
||||
// If this is the active media, stop the timer
|
||||
if (activeMedia === media) {
|
||||
stopUpdateTimer();
|
||||
activeMedia = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Reset timestamp when media ends
|
||||
media.addEventListener('ended', () => {
|
||||
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();
|
||||
});
|
||||
|
@@ -10,4 +10,12 @@
|
||||
.video-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.video-artist-link {
|
||||
|
||||
a,
|
||||
a:visited {
|
||||
color: gray;
|
||||
}
|
||||
}
|
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<div class="build-id">
|
||||
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>
|
||||
{{end}}
|
||||
|
@@ -56,6 +56,7 @@
|
||||
<select name="fps" id="fps-select">
|
||||
<option value="24">24 fps</option>
|
||||
<option value="25">25 fps</option>
|
||||
<option value="29.97">29.97 fps</option>
|
||||
<option value="30">30 fps</option>
|
||||
<option value="59.94">59.94 fps</option>
|
||||
<option value="60">60 fps</option>
|
||||
@@ -104,7 +105,8 @@
|
||||
</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>
|
||||
|
||||
{{template "footer" .}}
|
||||
|
@@ -35,7 +35,11 @@
|
||||
<div class="video-title video-title-bare {{$bareHidden}}">
|
||||
{{.Title}}
|
||||
</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 video-status">{{.Status}}</div>
|
||||
<div class="video-info">
|
||||
|
Reference in New Issue
Block a user