Skip to content

DeArrow integration for the videos widget #593

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,16 +627,20 @@ Preview:
![](images/videos-widget-preview.png)

#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| channels | array | yes | |
| playlists | array | no | |
| limit | integer | no | 25 |
| style | string | no | horizontal-cards |
| collapse-after | integer | no | 7 |
| collapse-after-rows | integer | no | 4 |
| include-shorts | boolean | no | false |
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
| Name | Type | Required | Default |
|-------------------------|----------|----------|--------------------------------------------|
| channels | array | yes | |
| playlists | array | no | |
| limit | integer | no | 25 |
| style | string | no | horizontal-cards |
| collapse-after | integer | no | 7 |
| collapse-after-rows | integer | no | 4 |
| include-shorts | boolean | no | false |
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
| use-dearrow-titles | bool | no | false |
| use-dearrow-thumbnails | bool | no | false |
| dearrow-titles-url | string | no | https://sponsor.ajay.app |
| dearrow-thumbnails-url | string | no | https://dearrow-thumb.ajay.app |

##### `channels`
A list of channels IDs.
Expand Down Expand Up @@ -691,6 +695,24 @@ Placeholders:

`{VIDEO-ID}` - the ID of the video

##### `use-dearrow-titles`
Tries to switch the video titles with more precise ones using the [DeArrow API](https://dearrow.ajay.app/).

##### `use-dearrow-thumbnails`
Tries to switch the video thumbails with less distracting ones using the [DeArrow API](https://dearrow.ajay.app/).

##### `dearrow-titles-instance-url`
The base URL for a DeArrow titles API instance hosted somewhere other than on ajay.app. Example:
```yaml
dearrow-titles-instance-url: https://dearrow.minibomba.pro/sbserver/
```

##### `dearrow-thumbnails-instance-url`
The base URL for a DeArrow thumbnails API instance hosted somewhere other than on ajay.app. Example:
```yaml
dearrow-thumbnails-instance-url: https://dearrow.minibomba.pro/sbserver/
```

### Hacker News
Display a list of posts from [Hacker News](https://news.ycombinator.com/).

Expand Down
179 changes: 167 additions & 12 deletions internal/glance/widget-videos.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@ var (
)

type videosWidget struct {
widgetBase `yaml:",inline"`
Videos videoList `yaml:"-"`
VideoUrlTemplate string `yaml:"video-url-template"`
Style string `yaml:"style"`
CollapseAfter int `yaml:"collapse-after"`
CollapseAfterRows int `yaml:"collapse-after-rows"`
Channels []string `yaml:"channels"`
Playlists []string `yaml:"playlists"`
Limit int `yaml:"limit"`
IncludeShorts bool `yaml:"include-shorts"`
widgetBase `yaml:",inline"`
Videos videoList `yaml:"-"`
VideoUrlTemplate string `yaml:"video-url-template"`
Style string `yaml:"style"`
CollapseAfter int `yaml:"collapse-after"`
CollapseAfterRows int `yaml:"collapse-after-rows"`
Channels []string `yaml:"channels"`
Playlists []string `yaml:"playlists"`
Limit int `yaml:"limit"`
IncludeShorts bool `yaml:"include-shorts"`
UseDearrowTitles bool `yaml:"use-dearrow-titles"`
UseDearrowThumbnails bool `yaml:"use-dearrow-thumbnails"`
DearrowTitlesInstanceUrl string `yaml:"dearrow-titles-instance-url"`
DearrowThumbnailsInstanceUrl string `yaml:"dearrow-thumbnails-instance-url"`
}

func (widget *videosWidget) initialize() error {
Expand Down Expand Up @@ -64,7 +68,7 @@ func (widget *videosWidget) initialize() error {
}

func (widget *videosWidget) update(ctx context.Context) {
videos, err := fetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
videos, err := fetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts, widget.UseDearrowTitles, widget.UseDearrowThumbnails, widget.DearrowTitlesInstanceUrl, widget.DearrowThumbnailsInstanceUrl)

if !widget.canContinueUpdateAfterHandlingErr(err) {
return
Expand Down Expand Up @@ -110,6 +114,68 @@ type youtubeFeedResponseXml struct {
} `xml:"entry"`
}

type dearrowBrandingResponseJson struct {
Titles []struct {
Title string `json:"title"`
Original bool `json:"original"`
Votes int `json:"votes"`
Locked bool `json:"locked"`
UUID string `json:"UUID"`
UserID string `json:"userID,omitempty"`
} `json:"titles"`
Thumbnails []struct {
Timestamp float32 `json:"timestamp"`
Original bool `json:"original"`
Votes int `json:"votes"`
Locked bool `json:"locked"`
UUID string `json:"UUID"`
UserID string `json:"userID,omitempty"`
} `json:"thumbnails"`
RandomTime float32 `json:"randomTime"`
VideoDuration *float32 `json:"videoDuration"`
}

// https://wiki.sponsor.ajay.app/w/API_Docs/DeArrow
func dearrowGetFirstMatchingTitle(response dearrowBrandingResponseJson) (string, bool) {
if len(response.Titles) > 0 {
for _, title := range response.Titles {
if title.Locked || title.Votes >= 0 {
return title.Title, true
}
}
}

for _, thumbnail := range response.Thumbnails {
if thumbnail.Locked || thumbnail.Votes >= 0 {
return "", true
}
}

return "", false
}

func dearrowGetThumbnailTask(client requestDoer) func(*http.Request) (string, error) {
return func(request *http.Request) (string, error) {
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()

if response.StatusCode == http.StatusOK { // status code 200
return request.URL.String(), nil
}

if response.StatusCode == http.StatusNoContent { // status code 204
failReason := response.Header.Get("X-Failure-Reason")
slog.Error("Failed to get the DeArrow thumbnail. Reason:", failReason, nil)
return "", fmt.Errorf("no content, X-Failure-Reason: %s", failReason)
}

return "", nil
}
}

func parseYoutubeFeedTime(t string) time.Time {
parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
if err != nil {
Expand Down Expand Up @@ -138,7 +204,7 @@ func (v videoList) sortByNewest() videoList {
return v
}

func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate string, includeShorts bool) (videoList, error) {
func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate string, includeShorts bool, useDearrowTitles bool, useDearrowThumbnails bool, dearrowTitlesInstanceUrl string, dearrowThumbnailsInstanceUrl string) (videoList, error) {
requests := make([]*http.Request, 0, len(channelOrPlaylistIDs))

for i := range channelOrPlaylistIDs {
Expand Down Expand Up @@ -206,6 +272,95 @@ func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate
return nil, errNoContent
}

if useDearrowTitles || useDearrowThumbnails {
if dearrowTitlesInstanceUrl == "" {
dearrowTitlesInstanceUrl = "https://sponsor.ajay.app"
}

if dearrowThumbnailsInstanceUrl == "" {
dearrowThumbnailsInstanceUrl = "https://dearrow-thumb.ajay.app"
}

var dearrowTitleRequests []*http.Request
var dearrowTitleIndices []int

var dearrowThumbnailRequests []*http.Request
var dearrowThumbnailIndices []int

for i, vid := range videos {
if vid.Url == "#" {
continue
}
parsedUrl, err := url.Parse(vid.Url)
if err != nil {
slog.Error("Failed to parse video URL for dearrow", "url", vid.Url, "error", err)
continue
}
videoID := parsedUrl.Query().Get("v")
if videoID == "" {
continue
}

if useDearrowTitles {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/branding?videoID=%s", dearrowTitlesInstanceUrl, videoID), nil)
if err != nil {
slog.Error("Failed to create dearrow branding request", "videoID", videoID, "error", err)
continue
}
dearrowTitleRequests = append(dearrowTitleRequests, req)
dearrowTitleIndices = append(dearrowTitleIndices, i)
}

if useDearrowThumbnails {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/getThumbnail?videoID=%s", dearrowThumbnailsInstanceUrl, videoID), nil)
if err != nil {
slog.Error("Failed to create dearrow branding request", "videoID", videoID, "error", err)
continue
}
dearrowThumbnailRequests = append(dearrowThumbnailRequests, req)
dearrowThumbnailIndices = append(dearrowThumbnailIndices, i)
}
}

if useDearrowTitles {
jobDearrowTitles := newJob(decodeJsonFromRequestTask[dearrowBrandingResponseJson](defaultHTTPClient), dearrowTitleRequests).withWorkers(30)
dearrowTitleResponses, dearrowTitleErrs, err := workerPoolDo(jobDearrowTitles)
if err != nil {
slog.Error("Failed to complete dearrow branding job pool", "error", err)
}

for j, videoIndex := range dearrowTitleIndices {
if dearrowTitleErrs[j] != nil {
continue
}

brandingResponse := dearrowTitleResponses[j]
if newTitle, ok := dearrowGetFirstMatchingTitle(brandingResponse); ok && newTitle != "" {
videos[videoIndex].Title = newTitle
}
}
}

if useDearrowThumbnails {
jobDearrowThumbnails := newJob(dearrowGetThumbnailTask(defaultHTTPClient), dearrowThumbnailRequests).withWorkers(30)
dearrowThumbnailResponses, dearrowThumbnailErrs, err := workerPoolDo(jobDearrowThumbnails)
if err != nil {
slog.Error("Failed to complete dearrow getThumbnail job pool", "error", err)
}

for j, videoIndex := range dearrowThumbnailIndices {
if dearrowThumbnailErrs[j] != nil {
continue
}

thumbnailResponse := dearrowThumbnailResponses[j]
if thumbnailResponse != "" {
videos[videoIndex].ThumbnailUrl = thumbnailResponse
}
}
}
}

videos.sortByNewest()

if failed > 0 {
Expand Down