Remove not needed packages
@@ -1,11 +1,6 @@
|
||||
SUBDIRS := cmd/api cmd/nginx
|
||||
BINPATH := $(abspath ./bin)
|
||||
GOLANGCI := $(BINPATH)/golangci-lint
|
||||
|
||||
.PHONY: push
|
||||
push:
|
||||
for d in $(SUBDIRS); do $(MAKE) -C $$d push; done
|
||||
|
||||
$(GOLANGCI):
|
||||
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(BINPATH) v1.17.1
|
||||
$(GOLANGCI) --version
|
||||
@@ -16,9 +11,4 @@ lint: $(GOLANGCI)
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
go test -short ./...
|
||||
|
||||
.PHONY: up
|
||||
up:
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
go test ./...
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
api
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM alpine
|
||||
RUN apk --update --no-cache add ca-certificates
|
||||
COPY ./api /api
|
||||
ENTRYPOINT ["/api"]
|
||||
@@ -1,14 +0,0 @@
|
||||
OUT := api
|
||||
SRC := $(wildcard *.go)
|
||||
|
||||
build: $(SRC)
|
||||
GOOS=linux go build -v -o $(OUT)
|
||||
|
||||
.PHONY: push
|
||||
push: build
|
||||
docker build -t mxpv/podsync_api .
|
||||
docker push mxpv/podsync_api
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
- rm -f $(OUT)
|
||||
@@ -1,141 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/jessevdk/go-flags"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/builders"
|
||||
"github.com/mxpv/podsync/pkg/feeds"
|
||||
"github.com/mxpv/podsync/pkg/handler"
|
||||
"github.com/mxpv/podsync/pkg/queue"
|
||||
"github.com/mxpv/podsync/pkg/storage"
|
||||
"github.com/mxpv/podsync/pkg/support"
|
||||
)
|
||||
|
||||
type Opts struct {
|
||||
YouTubeAPIKey string `long:"youtube-key" required:"true" env:"YOUTUBE_API_KEY"`
|
||||
VimeoAPIKey string `long:"vimeo-key" required:"true" env:"VIMEO_API_KEY"`
|
||||
PatreonClientID string `long:"patreon-client-id" required:"true" env:"PATREON_CLIENT_ID"`
|
||||
PatreonSecret string `long:"patreon-secret" required:"true" env:"PATREON_SECRET"`
|
||||
PatreonRedirectURL string `long:"patreon-redirect-url" required:"true" env:"PATREON_REDIRECT_URL"`
|
||||
PatreonWebhooksSecret string `long:"patreon-webhook-secret" required:"true" env:"PATREON_WEBHOOKS_SECRET"`
|
||||
PostgresConnectionURL string `long:"pg-url" env:"POSTGRES_CONNECTION_URL"`
|
||||
CookieSecret string `long:"cookie-secret" required:"true" env:"COOKIE_SECRET"`
|
||||
DynamoFeedsTableName string `long:"dynamo-feeds-table" env:"DYNAMO_FEEDS_TABLE_NAME"`
|
||||
DynamoPledgesTableName string `long:"dynamo-pledges-table" env:"DYNAMO_PLEDGES_TABLE_NAME"`
|
||||
RedisURL string `long:"redis-url" required:"true" env:"REDIS_CONNECTION_URL"`
|
||||
UpdaterURL string `long:"updater-url" required:"true" env:"UPDATER_URL"`
|
||||
UpdaterQueueURL string `long:"updater-queue-url" required:"true" env:"UPDATER_SQS_QUEUE_URL"`
|
||||
Debug bool `long:"debug" env:"DEBUG"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Create core services
|
||||
|
||||
var opts Opts
|
||||
if _, err := flags.Parse(&opts); err != nil {
|
||||
log.WithError(err).Fatal("failed to read configuration")
|
||||
}
|
||||
|
||||
if opts.Debug {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
database, err := storage.NewDynamo()
|
||||
if err != nil {
|
||||
log.WithError(err).Fatal("failed to create database")
|
||||
}
|
||||
|
||||
if opts.DynamoPledgesTableName != "" {
|
||||
database.PledgesTableName = aws.String(opts.DynamoPledgesTableName)
|
||||
}
|
||||
|
||||
if opts.DynamoFeedsTableName != "" {
|
||||
database.FeedsTableName = aws.String(opts.DynamoFeedsTableName)
|
||||
}
|
||||
|
||||
patreon := support.NewPatreon(database)
|
||||
|
||||
// Queue
|
||||
|
||||
updateQueue := queue.New(ctx, opts.UpdaterQueueURL)
|
||||
|
||||
// Builders
|
||||
|
||||
youtube, err := builders.NewYouTubeBuilder(opts.YouTubeAPIKey)
|
||||
if err != nil {
|
||||
log.WithError(err).Fatal("failed to create YouTube builder")
|
||||
}
|
||||
|
||||
vimeo, err := builders.NewVimeoBuilder(ctx, opts.VimeoAPIKey)
|
||||
if err != nil {
|
||||
log.WithError(err).Fatal("failed to create Vimeo builder")
|
||||
}
|
||||
|
||||
generic := builders.NewRemote(opts.UpdaterURL)
|
||||
|
||||
feed, err := feeds.NewFeedService(database, updateQueue, map[api.Provider]feeds.Builder{
|
||||
api.ProviderYoutube: youtube,
|
||||
api.ProviderVimeo: vimeo,
|
||||
api.ProviderGeneric: generic,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.WithError(err).Fatal("failed to create feed service")
|
||||
}
|
||||
|
||||
web := handler.New(feed, patreon, handler.Opts{
|
||||
CookieSecret: opts.CookieSecret,
|
||||
RedisHost: opts.RedisURL,
|
||||
PatreonClientID: opts.PatreonClientID,
|
||||
PatreonSecret: opts.PatreonSecret,
|
||||
PatreonRedirectURL: opts.PatreonRedirectURL,
|
||||
PatreonWebhooksSecret: opts.PatreonWebhooksSecret,
|
||||
})
|
||||
|
||||
srv := http.Server{
|
||||
Addr: fmt.Sprintf(":%d", 5001),
|
||||
Handler: web,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Infof("running listener at %s", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.WithError(err).Error("failed to listen")
|
||||
}
|
||||
}()
|
||||
|
||||
<-stop
|
||||
|
||||
log.Info("shutting down server")
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.WithError(err).Error("server shutdown failed")
|
||||
}
|
||||
|
||||
// Close SQS
|
||||
updateQueue.Close()
|
||||
|
||||
if err := database.Close(); err != nil {
|
||||
log.WithError(err).Error("failed to close database")
|
||||
}
|
||||
|
||||
log.Info("server gracefully stopped")
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
# Build fronend
|
||||
FROM node:11-slim AS builder
|
||||
WORKDIR /work
|
||||
COPY ui/ .
|
||||
RUN npm install
|
||||
RUN npm run build -- --prod --aot
|
||||
|
||||
# Build nginx to host static files
|
||||
FROM nginx:stable
|
||||
|
||||
COPY cmd/nginx/podsync.conf /etc/nginx/nginx.conf
|
||||
|
||||
COPY cmd/nginx/robots.txt /var/www/podsync/
|
||||
COPY --from=builder /work/dist/ui/ /var/www/podsync/
|
||||
@@ -1,4 +0,0 @@
|
||||
.PHONY: push
|
||||
push:
|
||||
docker build -t mxpv/nginx -f Dockerfile ../../
|
||||
docker push mxpv/nginx
|
||||
@@ -1,95 +0,0 @@
|
||||
user nginx;
|
||||
worker_processes 1;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain application/json text/css application/javascript application/x-javascript text/javascript text/xml application/xml application/rss+xml application/atom+xml application/rdf+xml;
|
||||
gzip_min_length 1000;
|
||||
gzip_proxied any;
|
||||
|
||||
upstream api {
|
||||
server api:5001;
|
||||
}
|
||||
|
||||
upstream resolver {
|
||||
server resolver:5002;
|
||||
}
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
|
||||
server {
|
||||
server_name _;
|
||||
listen 80;
|
||||
|
||||
root /var/www/podsync/;
|
||||
index index.html;
|
||||
|
||||
# Cache media: images, icons, video, audio, HTC
|
||||
# See https://serversforhackers.com/c/nginx-caching
|
||||
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|ogg|ogv|webm|htc)$ {
|
||||
expires 3M;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Cache CSS and Javascript
|
||||
location ~* \.(?:css|js)$ {
|
||||
expires 1y;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Cache fonts
|
||||
location ~* \.(?:ttf|woff|woff2|eot|otf)$ {
|
||||
expires 1y;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /nginx-ping {
|
||||
access_log off;
|
||||
return 200 "pong\n";
|
||||
}
|
||||
|
||||
location /download {
|
||||
proxy_read_timeout 180s;
|
||||
proxy_pass http://resolver;
|
||||
}
|
||||
|
||||
location = /api/webhooks {
|
||||
proxy_pass http://api;
|
||||
client_max_body_size 128k;
|
||||
}
|
||||
|
||||
location ~ (/user|/api) {
|
||||
proxy_pass http://api;
|
||||
}
|
||||
|
||||
location ~* \.(?:html)$ {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/ @api;
|
||||
}
|
||||
|
||||
location @api {
|
||||
proxy_pass http://api;
|
||||
proxy_buffers 8 16k;
|
||||
proxy_buffer_size 16k;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /$
|
||||
Allow: /*.js
|
||||
Allow: /*.css
|
||||
Allow: /faq
|
||||
Allow: /assets/*
|
||||
Allow: /fontawesome-webfont*
|
||||
Disallow: /
|
||||
Host: www.podsync.net
|
||||
@@ -1,5 +0,0 @@
|
||||
.idea/
|
||||
.DS_Store/
|
||||
venv/
|
||||
package/
|
||||
__pycache__/
|
||||
@@ -1,6 +0,0 @@
|
||||
.idea/
|
||||
venv/
|
||||
package/
|
||||
__pycache__/
|
||||
|
||||
function.zip
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM python:alpine3.7
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN apk add --virtual deps --no-cache build-base && \
|
||||
pip3 install --no-cache-dir --requirement requirements.txt --target /app && \
|
||||
apk del deps && \
|
||||
addgroup -S app && adduser -S app -G app
|
||||
|
||||
USER app
|
||||
|
||||
ENTRYPOINT ["python3", "-m", "sanic", "server.app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
CMD ["--workers", "1"]
|
||||
@@ -1,31 +0,0 @@
|
||||
|
||||
build:
|
||||
pip3 install --requirement requirements.txt --target package
|
||||
cd package && zip -r9 ../function.zip .
|
||||
zip -g function.zip lambda.py resolver.py
|
||||
|
||||
deploy: build
|
||||
aws --profile Podsync lambda create-function \
|
||||
--function-name Resolver \
|
||||
--role $(shell aws --profile Podsync iam get-role --role-name PodsyncResolverLambdaRole --query 'Role.Arn' --output text) \
|
||||
--runtime python3.7 \
|
||||
--handler lambda.handler \
|
||||
--zip-file fileb://function.zip \
|
||||
--timeout 10 \
|
||||
--memory-size 128
|
||||
|
||||
update: build
|
||||
aws --profile Podsync lambda update-function-code \
|
||||
--function-name Resolver \
|
||||
--zip-file fileb://function.zip
|
||||
|
||||
.PHONY: push
|
||||
push:
|
||||
docker build -t mxpv/resolver .
|
||||
docker push mxpv/resolver
|
||||
|
||||
|
||||
clean:
|
||||
rm -rf package function.zip
|
||||
|
||||
.PHONY: deploy update clean
|
||||
@@ -1,12 +0,0 @@
|
||||
from resolver import *
|
||||
|
||||
|
||||
def handler(event, lambda_context):
|
||||
feed_id = event['feed_id']
|
||||
video_id = event['video_id']
|
||||
|
||||
redirect_url = download(feed_id, video_id)
|
||||
return {
|
||||
'code': 302,
|
||||
'redirect_url': redirect_url,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
youtube_dl==2019.07.16
|
||||
sanic==18.12
|
||||
boto3==1.9.151
|
||||
@@ -1,164 +0,0 @@
|
||||
import os
|
||||
import youtube_dl
|
||||
import boto3
|
||||
from datetime import datetime, time
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
ANONYMOUS_FEED_REQUESTS_LIMIT = 100
|
||||
|
||||
|
||||
class InvalidUsage(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class QuotaExceeded(Exception):
|
||||
pass
|
||||
|
||||
|
||||
dynamodb = boto3.resource('dynamodb')
|
||||
|
||||
feeds_table = dynamodb.Table(os.getenv('DYNAMO_FEEDS_TABLE_NAME', 'Feeds'))
|
||||
counter_table = dynamodb.Table(os.getenv('DYNAMO_RESOLVE_COUNTERS_TABLE', 'ResolveCounters'))
|
||||
|
||||
opts = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'forceurl': True,
|
||||
'simulate': True,
|
||||
'skip_download': True,
|
||||
'call_home': False,
|
||||
'nocheckcertificate': True
|
||||
}
|
||||
|
||||
url_formats = {
|
||||
'youtube': 'https://youtube.com/watch?v={}',
|
||||
'vimeo': 'https://vimeo.com/{}',
|
||||
}
|
||||
|
||||
|
||||
def download(feed_id, video_id):
|
||||
if not feed_id:
|
||||
raise InvalidUsage('Invalid feed id')
|
||||
|
||||
# Remove extension and check if video id is ok
|
||||
video_id = os.path.splitext(video_id)[0]
|
||||
if not video_id:
|
||||
raise InvalidUsage('Invalid video id')
|
||||
|
||||
# Query feed metadata info from DynamoDB
|
||||
item = _get_metadata(feed_id)
|
||||
|
||||
# Update resolve requests counter
|
||||
count = _update_resolve_counter(feed_id)
|
||||
level = int(item['featurelevel'])
|
||||
if count > ANONYMOUS_FEED_REQUESTS_LIMIT and level == 0:
|
||||
raise QuotaExceeded('Too many requests. Daily limit is %d. Consider upgrading account to get unlimited '
|
||||
'access' % ANONYMOUS_FEED_REQUESTS_LIMIT)
|
||||
|
||||
# Build URL
|
||||
provider = item['provider']
|
||||
tpl = url_formats[provider]
|
||||
if not tpl:
|
||||
raise InvalidUsage('Invalid feed')
|
||||
url = tpl.format(video_id)
|
||||
|
||||
redirect_url = _resolve(url, item)
|
||||
return redirect_url
|
||||
|
||||
|
||||
def _get_metadata(feed_id):
|
||||
response = feeds_table.get_item(
|
||||
Key={'HashID': feed_id},
|
||||
ProjectionExpression='#P,#F,#Q,#L',
|
||||
ExpressionAttributeNames={
|
||||
'#P': 'Provider',
|
||||
'#F': 'Format',
|
||||
'#Q': 'Quality',
|
||||
'#L': 'FeatureLevel',
|
||||
},
|
||||
)
|
||||
|
||||
item = response['Item']
|
||||
|
||||
# Make dict keys lowercase
|
||||
return dict((k.lower(), v) for k, v in item.items())
|
||||
|
||||
|
||||
def _update_resolve_counter(feed_id):
|
||||
if not feed_id:
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
day = now.strftime('%Y%m%d')
|
||||
|
||||
expires = now + relativedelta(months=3)
|
||||
|
||||
response = counter_table.update_item(
|
||||
Key={
|
||||
'FeedID': feed_id,
|
||||
'Day': int(day),
|
||||
},
|
||||
UpdateExpression='ADD #count :one SET #exp = if_not_exists(#exp, :ttl)',
|
||||
ExpressionAttributeNames={
|
||||
'#count': 'Count',
|
||||
'#exp': 'Expires',
|
||||
},
|
||||
ExpressionAttributeValues={
|
||||
':one': 1,
|
||||
':ttl': int(expires.timestamp()),
|
||||
},
|
||||
ReturnValues='UPDATED_NEW',
|
||||
)
|
||||
|
||||
attrs = response['Attributes']
|
||||
return attrs['Count']
|
||||
|
||||
|
||||
def _resolve(url, metadata):
|
||||
if not url:
|
||||
raise InvalidUsage('Invalid URL')
|
||||
|
||||
print('Resolving %s' % url)
|
||||
|
||||
try:
|
||||
provider = metadata['provider']
|
||||
|
||||
with youtube_dl.YoutubeDL(opts) as ytdl:
|
||||
info = ytdl.extract_info(url, download=False)
|
||||
if provider == 'youtube':
|
||||
return _yt_choose_url(ytdl, info, metadata)
|
||||
elif provider == 'vimeo':
|
||||
return _vimeo_choose_url(info, metadata)
|
||||
else:
|
||||
raise ValueError('undefined provider')
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise
|
||||
|
||||
|
||||
def _yt_choose_url(ytdl, info, metadata):
|
||||
is_video = metadata['format'] == 'video'
|
||||
is_high_quality = metadata['quality'] == 'high'
|
||||
|
||||
if is_video:
|
||||
fmt = 'best[ext=mp4]' if is_high_quality else 'worst[ext=mp4]'
|
||||
else:
|
||||
fmt = 'bestaudio' if is_high_quality else 'worstaudio'
|
||||
|
||||
selector = ytdl.build_format_selector(fmt)
|
||||
selected = next(selector(info))
|
||||
if 'fragment_base_url' in selected:
|
||||
return selected['fragment_base_url']
|
||||
|
||||
return selected['url']
|
||||
|
||||
|
||||
def _vimeo_choose_url(info, metadata):
|
||||
# Query formats with 'extension' = mp4 and 'format_id' = http-1080p/http-720p/../http-360p
|
||||
fmt_list = [x for x in info['formats'] if x['ext'] == 'mp4' and x['format_id'].startswith('http-')]
|
||||
|
||||
ordered = sorted(fmt_list, key=lambda x: x['width'], reverse=True)
|
||||
is_high_quality = metadata['quality'] == 'high'
|
||||
item = ordered[0] if is_high_quality else ordered[-1]
|
||||
|
||||
return item['url']
|
||||
@@ -1,56 +0,0 @@
|
||||
import resolver as ytdl
|
||||
import unittest
|
||||
|
||||
|
||||
class TestYtdl(unittest.TestCase):
|
||||
def test_resolve(self):
|
||||
self.assertIsNotNone(
|
||||
ytdl._resolve('https://youtube.com/watch?v=ygIUF678y40',
|
||||
{'format': 'video', 'quality': 'low', 'provider': 'youtube'}))
|
||||
self.assertIsNotNone(
|
||||
ytdl._resolve('https://youtube.com/watch?v=WyaEiO4hyik',
|
||||
{'format': 'audio', 'quality': 'high', 'provider': 'youtube'}))
|
||||
self.assertIsNotNone(
|
||||
ytdl._resolve('https://youtube.com/watch?v=5mjUF2j9dgA',
|
||||
{'format': 'video', 'quality': 'low', 'provider': 'youtube'})
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
ytdl._resolve('https://www.youtube.com/watch?v=2nH7xAMqD2g',
|
||||
{'format': 'video', 'quality': 'high', 'provider': 'youtube'})
|
||||
)
|
||||
|
||||
def test_vimeo(self):
|
||||
self.assertIsNotNone(
|
||||
ytdl._resolve('https://vimeo.com/237715420', {'format': 'video', 'quality': 'low', 'provider': 'vimeo'}))
|
||||
self.assertIsNotNone(
|
||||
ytdl._resolve('https://vimeo.com/275211960', {'format': 'video', 'quality': 'high', 'provider': 'vimeo'})
|
||||
)
|
||||
|
||||
def test_youtube_resolve_audio(self):
|
||||
self.assertIsNotNone(
|
||||
ytdl._resolve('https://youtube.com/watch?v=UMrb1tG38w8',
|
||||
{'format': 'audio', 'quality': 'high', 'provider': 'youtube'})
|
||||
)
|
||||
|
||||
|
||||
class TestDynamo(unittest.TestCase):
|
||||
def test_metadata(self):
|
||||
item = ytdl._get_metadata('86qZ')
|
||||
self.assertIsNotNone(item)
|
||||
self.assertIsNotNone(item['format'])
|
||||
self.assertIsNotNone(item['quality'])
|
||||
self.assertIsNotNone(item['provider'])
|
||||
|
||||
def test_counter(self):
|
||||
counter = ytdl._update_resolve_counter('86qZ')
|
||||
self.assertEqual(counter, 1)
|
||||
counter = ytdl._update_resolve_counter('86qZ')
|
||||
self.assertEqual(counter, 2)
|
||||
|
||||
def test_download(self):
|
||||
url = ytdl.download('86qZ', '7XJYLq3gviY')
|
||||
self.assertIsNotNone(url)
|
||||
|
||||
def test_quota_check(self):
|
||||
with self.assertRaises(ytdl.QuotaExceeded):
|
||||
ytdl.download('xro548QlJ', 'j51NFs0bZ9c')
|
||||
@@ -1,30 +0,0 @@
|
||||
import resolver
|
||||
|
||||
from sanic import Sanic, response
|
||||
from sanic.exceptions import ServerError, InvalidUsage
|
||||
|
||||
app = Sanic()
|
||||
|
||||
|
||||
@app.route('/download/<feed_id>/<video_id>', methods=['GET', 'HEAD'])
|
||||
async def download(req, feed_id, video_id):
|
||||
if req.method == 'HEAD':
|
||||
return response.text('')
|
||||
|
||||
try:
|
||||
redirect_url = resolver.download(feed_id, video_id)
|
||||
return response.redirect(redirect_url)
|
||||
except resolver.InvalidUsage:
|
||||
raise InvalidUsage()
|
||||
except resolver.QuotaExceeded:
|
||||
raise ServerError('Too many requests. Daily limit is 1000. Consider upgrading account to get unlimited access.',
|
||||
status_code=429)
|
||||
|
||||
|
||||
@app.get('/ping')
|
||||
async def ping(req):
|
||||
return response.text('pong')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=8080)
|
||||
@@ -1,2 +0,0 @@
|
||||
[install]
|
||||
prefix=
|
||||
@@ -1,6 +0,0 @@
|
||||
.idea/
|
||||
venv/
|
||||
package/
|
||||
__pycache__/
|
||||
|
||||
function.zip
|
||||
@@ -1,6 +0,0 @@
|
||||
.idea/
|
||||
venv/
|
||||
package/
|
||||
__pycache__/
|
||||
|
||||
function.zip
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM python:alpine3.7
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN pip3 install --no-cache-dir --requirement requirements.txt --target /app && \
|
||||
addgroup -g 1000 -S app && adduser -u 1000 -G app -S app
|
||||
|
||||
USER app
|
||||
|
||||
ENTRYPOINT ["python3"]
|
||||
CMD ["-u", "main.py"]
|
||||
@@ -1,33 +0,0 @@
|
||||
TIMEOUT := 300
|
||||
ROLE := AWSLambdaBasicExecutionRole
|
||||
NAME := Updater
|
||||
|
||||
build:
|
||||
pip3 install --requirement requirements.txt --target package
|
||||
cd package && zip -r9 ../function.zip .
|
||||
zip -g function.zip function.py updater.py
|
||||
|
||||
deploy: build
|
||||
aws --profile Podsync lambda create-function \
|
||||
--function-name $(NAME) \
|
||||
--role $(shell aws --profile Podsync iam get-role --role-name $(ROLE) --query 'Role.Arn' --output text) \
|
||||
--runtime python3.7 \
|
||||
--handler function.handler \
|
||||
--zip-file fileb://function.zip \
|
||||
--timeout $(TIMEOUT) \
|
||||
--memory-size 128
|
||||
|
||||
update: build
|
||||
aws --profile Podsync lambda update-function-code \
|
||||
--function-name $(NAME) \
|
||||
--zip-file fileb://function.zip
|
||||
|
||||
.PHONY: push
|
||||
push:
|
||||
docker build -t mxpv/updater .
|
||||
docker push mxpv/updater
|
||||
|
||||
clean:
|
||||
rm -rf package function.zip
|
||||
|
||||
.PHONY: deploy update clean
|
||||
@@ -1,138 +0,0 @@
|
||||
import datetime
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
import youtube_dl
|
||||
import updater
|
||||
|
||||
sqs = boto3.client('sqs')
|
||||
sqs_url = os.getenv('UPDATER_SQS_QUEUE_URL')
|
||||
print('Using SQS URL: {}'.format(sqs_url))
|
||||
|
||||
dynamodb = boto3.resource('dynamodb')
|
||||
feeds_table_name = os.getenv('DYNAMO_FEEDS_TABLE_NAME', 'Feeds')
|
||||
print('Using DynamoDB table: {}'.format(feeds_table_name))
|
||||
feeds_table = dynamodb.Table(feeds_table_name)
|
||||
|
||||
|
||||
def _get_episodes(feed_id):
|
||||
resp = feeds_table.get_item(
|
||||
Key={'HashID': feed_id},
|
||||
ProjectionExpression='#D',
|
||||
ExpressionAttributeNames={'#D': 'EpisodesData'}
|
||||
)
|
||||
|
||||
old_episodes = []
|
||||
resp_item = resp['Item']
|
||||
raw = resp_item.get('EpisodesData')
|
||||
if not raw:
|
||||
return old_episodes
|
||||
|
||||
print('Received episodes compressed data of size: {} bytes'.format(len(raw.value)))
|
||||
old_content = gzip.decompress(raw.value).decode('utf-8') # Decompress from gzip
|
||||
old_episodes = json.loads(old_content) # Deserialize from string to json
|
||||
return old_episodes
|
||||
|
||||
|
||||
def _update(item):
|
||||
# Unpack fields
|
||||
|
||||
feed_id = item['id']
|
||||
url = item['url']
|
||||
last_id = item['last_id']
|
||||
start = int(item['start'])
|
||||
count = int(item['count'])
|
||||
fmt = item.get('format', 'video')
|
||||
quality = item.get('quality', 'high')
|
||||
ytdl_fmt = updater.get_format(fmt, quality)
|
||||
|
||||
# Playlist need special handling
|
||||
link_type = item.get('link_type')
|
||||
is_playlist = link_type == 'playlist'
|
||||
|
||||
old_episodes = []
|
||||
|
||||
if is_playlist:
|
||||
# Query old episodes in advance for playlist in order to compare the diff
|
||||
old_episodes = _get_episodes(feed_id)
|
||||
|
||||
# Invoke youtube-dl and pull updates
|
||||
|
||||
print('Updating feed {} (last id: {}, start: {}, count: {}, fmt: {}, type: {})'.format(
|
||||
feed_id, last_id, start, count, ytdl_fmt, link_type))
|
||||
new_episodes, new_last_id, dirty = updater.get_updates(start, count, url, ytdl_fmt, last_id, old_episodes)
|
||||
|
||||
if new_last_id is None:
|
||||
# Sometimes youtube-dl fails to pull updates
|
||||
print('! New last id is None, retrying...')
|
||||
new_episodes, new_last_id, dirty = updater.get_updates(start, count, url, ytdl_fmt, last_id, old_episodes)
|
||||
|
||||
if not dirty:
|
||||
print('No updates found for {}'.format(feed_id))
|
||||
return
|
||||
else:
|
||||
print('Found {} new episode(s) (new last id: {})'.format(len(new_episodes), new_last_id))
|
||||
|
||||
# Get records from DynamoDB and decompress episodes
|
||||
|
||||
if is_playlist:
|
||||
episodes = new_episodes
|
||||
else:
|
||||
old_episodes = _get_episodes(feed_id)
|
||||
episodes = new_episodes + old_episodes # Prepand the new episodes
|
||||
if is_playlist:
|
||||
del episodes[count:]
|
||||
|
||||
# Compress episodes and submit update query
|
||||
|
||||
data = bytes(json.dumps(episodes), 'utf-8')
|
||||
compressed = gzip.compress(data)
|
||||
print('Sending new compressed data of size: {} bytes ({} episodes)'.format(len(compressed), len(episodes)))
|
||||
|
||||
feeds_table.update_item(
|
||||
Key={
|
||||
'HashID': feed_id,
|
||||
},
|
||||
UpdateExpression='SET #episodesData = :data, #last_id = :last_id, #updated_at = :now REMOVE #episodes',
|
||||
ExpressionAttributeNames={
|
||||
'#episodesData': 'EpisodesData',
|
||||
'#episodes': 'Episodes',
|
||||
'#last_id': 'LastID',
|
||||
'#updated_at': 'UpdatedAt',
|
||||
},
|
||||
ExpressionAttributeValues={
|
||||
':now': int(datetime.datetime.utcnow().timestamp()),
|
||||
':last_id': new_last_id,
|
||||
':data': compressed,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Running updater')
|
||||
while True:
|
||||
response = sqs.receive_message(QueueUrl=sqs_url, MaxNumberOfMessages=10)
|
||||
messages = response.get('Messages')
|
||||
if not messages:
|
||||
continue
|
||||
|
||||
print('=> Got {} new message(s) to process'.format(len(messages)))
|
||||
for msg in messages:
|
||||
print('-' * 64)
|
||||
|
||||
body = msg.get('Body')
|
||||
receipt_handle = msg.get('ReceiptHandle')
|
||||
|
||||
try:
|
||||
# Run updater
|
||||
_update(json.loads(body))
|
||||
# Delete message from SQS
|
||||
sqs.delete_message(QueueUrl=sqs_url, ReceiptHandle=receipt_handle)
|
||||
print('Done')
|
||||
except (ValueError, youtube_dl.utils.DownloadError) as e:
|
||||
print(str(e))
|
||||
# These kind of errors are not retryable, so delete message from the queue
|
||||
sqs.delete_message(QueueUrl=sqs_url, ReceiptHandle=receipt_handle)
|
||||
except Exception as e:
|
||||
print('! ERROR ({}): {}'.format(type(e), str(e)))
|
||||
@@ -1,2 +0,0 @@
|
||||
boto3==1.9.129
|
||||
youtube_dl==2019.08.02
|
||||
@@ -1,2 +0,0 @@
|
||||
[install]
|
||||
prefix=
|
||||
@@ -1,130 +0,0 @@
|
||||
import youtube_dl
|
||||
from datetime import datetime
|
||||
|
||||
BEST_FORMAT = "bestvideo+bestaudio/best"
|
||||
DEFAULT_PAGE_SIZE = 50
|
||||
|
||||
|
||||
def get_format(fmt, quality):
|
||||
if fmt == 'video':
|
||||
# Video
|
||||
if quality == 'high':
|
||||
return 'best[ext=mp4]'
|
||||
else:
|
||||
return 'worst[ext=mp4]'
|
||||
else:
|
||||
# Audio
|
||||
if quality == 'high':
|
||||
return 'bestaudio'
|
||||
else:
|
||||
return 'worstaudio'
|
||||
|
||||
|
||||
def get_updates(start, count, url, fmt, last_id=None, playlist=None):
|
||||
if start < 1:
|
||||
raise ValueError('Invalid start value')
|
||||
|
||||
if count < 1 or count > 600:
|
||||
raise ValueError('Invalid count value')
|
||||
|
||||
if not url:
|
||||
raise ValueError('Invalid resource URL %s' % url)
|
||||
|
||||
if playlist is None:
|
||||
playlist = []
|
||||
|
||||
end = start + count - 1
|
||||
|
||||
opts = {
|
||||
'playliststart': start,
|
||||
'playlistend': end,
|
||||
'extract_flat': 'in_playlist',
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'simulate': True,
|
||||
'skip_download': True,
|
||||
}
|
||||
|
||||
dirty = False
|
||||
|
||||
with youtube_dl.YoutubeDL(opts) as ytdl:
|
||||
selector = ytdl.build_format_selector(fmt)
|
||||
feed_info = ytdl.extract_info(url, download=False)
|
||||
|
||||
videos = []
|
||||
|
||||
entries = feed_info['entries']
|
||||
if not len(entries): # No episodes
|
||||
return videos, None
|
||||
|
||||
is_playlist = len(playlist) > 0
|
||||
playlist_lookup = {}
|
||||
if is_playlist:
|
||||
for item in playlist:
|
||||
playlist_lookup[item['ID']] = item
|
||||
|
||||
for idx, entry in enumerate(entries):
|
||||
video_id = entry['id']
|
||||
|
||||
if is_playlist:
|
||||
if video_id in playlist_lookup:
|
||||
videos.append(playlist_lookup[video_id])
|
||||
continue
|
||||
else:
|
||||
# If already seen this video previously, stop pulling updates
|
||||
if last_id and video_id == last_id:
|
||||
break
|
||||
|
||||
dirty = True
|
||||
|
||||
# Query video metadata from YouTube
|
||||
try:
|
||||
result = ytdl.process_ie_result(entry, download=False)
|
||||
except youtube_dl.utils.DownloadError:
|
||||
continue
|
||||
|
||||
# Convert '20190101' to unix time
|
||||
date_str = result.get('upload_date')
|
||||
date = datetime.strptime(date_str, '%Y%m%d')
|
||||
|
||||
# Duration in seconds
|
||||
duration = int(result.get('duration'))
|
||||
size = _get_size(result, selector, fmt, duration)
|
||||
|
||||
videos.append({
|
||||
'ID': video_id,
|
||||
'Title': result.get('title'),
|
||||
'Description': result.get('description'),
|
||||
'Thumbnail': result.get('thumbnail'),
|
||||
'Duration': duration,
|
||||
'VideoURL': result.get('webpage_url'),
|
||||
'PubDate': int(date.timestamp()),
|
||||
'Size': size,
|
||||
})
|
||||
|
||||
idx = 0 if not playlist else -1
|
||||
new_last_id = entries[idx]['id']
|
||||
return videos, new_last_id, dirty
|
||||
|
||||
|
||||
def _get_size(video, selector, fmt, duration):
|
||||
try:
|
||||
selected = next(selector(video))
|
||||
except KeyError:
|
||||
selected = video
|
||||
|
||||
if 'requested_formats' in selected:
|
||||
return sum(int(f['filesize']) for f in selected['requested_formats'])
|
||||
|
||||
if selected.get('filesize') is not None:
|
||||
return int(selected['filesize'])
|
||||
|
||||
# Calculate approximate file size
|
||||
|
||||
is_high = 'best' in fmt
|
||||
is_audio = 'audio' in fmt
|
||||
|
||||
if is_audio:
|
||||
return (16000 if is_high else 6000) * duration
|
||||
else:
|
||||
return (350000 if is_high else 100000) * duration
|
||||
@@ -1,42 +0,0 @@
|
||||
import updater
|
||||
import unittest
|
||||
|
||||
TEST_URL = 'https://www.youtube.com/user/CNN/videos'
|
||||
|
||||
|
||||
class TestUpdater(unittest.TestCase):
|
||||
def test_get_updates(self):
|
||||
kinds = [
|
||||
updater.get_format('video', 'high'),
|
||||
updater.get_format('video', 'low'),
|
||||
updater.get_format('audio', 'high'),
|
||||
updater.get_format('audio', 'low'),
|
||||
]
|
||||
for kind in kinds:
|
||||
with self.subTest(kind):
|
||||
items, last_id, _ = updater.get_updates(1, 1, TEST_URL, kind)
|
||||
self.assertIsNotNone(items)
|
||||
self.assertIsNotNone(last_id)
|
||||
|
||||
def test_get_change_list(self):
|
||||
items, last_id, _ = updater.get_updates(1, 5, TEST_URL, 'worst[ext=mp4]')
|
||||
|
||||
self.assertEqual(len(items), 5)
|
||||
self.assertEqual(items[0]['ID'], last_id)
|
||||
test_last_id = items[2]['ID']
|
||||
self.assertIsNotNone(test_last_id)
|
||||
|
||||
items, last_id, _ = updater.get_updates(1, 5, TEST_URL, 'worst[ext=mp4]', test_last_id)
|
||||
self.assertEqual(len(items), 2)
|
||||
self.assertEqual(items[0]['ID'], last_id)
|
||||
|
||||
def test_last_id(self):
|
||||
items, last_id, _ = updater.get_updates(1, 1, TEST_URL, 'worstaudio')
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]['ID'], last_id)
|
||||
|
||||
def test_get_title_issue33(self):
|
||||
url = 'https://youtube.com/channel/UC9-y-6csu5WGm29I7JiwpnA'
|
||||
items, _, _ = updater.get_updates(1, 1, url, 'best[ext=mp4]')
|
||||
for item in items:
|
||||
self.assertNotEqual('_', item.get('Title'))
|
||||
@@ -1,80 +0,0 @@
|
||||
package builders
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/lambda"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
const (
|
||||
functionName = "Updater"
|
||||
functionAlias = "PROD"
|
||||
)
|
||||
|
||||
type responsePayload struct {
|
||||
LastID string `json:"last_id"`
|
||||
Episodes []*model.Item `json:"episodes"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
}
|
||||
|
||||
// Lambda builder does incremental feed updates (see cmd/updater)
|
||||
type Lambda struct {
|
||||
client *lambda.Lambda
|
||||
}
|
||||
|
||||
func NewLambda(cfg ...*aws.Config) (*Lambda, error) {
|
||||
sess, err := session.NewSession(cfg...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := lambda.New(sess)
|
||||
return &Lambda{client: client}, nil
|
||||
}
|
||||
|
||||
func (l *Lambda) Build(feed *model.Feed) error {
|
||||
input := map[string]interface{}{
|
||||
"url": feed.ItemURL,
|
||||
"start": 1,
|
||||
"count": feed.PageSize,
|
||||
"last_id": feed.LastID,
|
||||
"format": feed.Format,
|
||||
"quality": feed.Quality,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to serialize payload")
|
||||
}
|
||||
|
||||
request := &lambda.InvokeInput{}
|
||||
request.SetPayload(payload)
|
||||
request.SetFunctionName(functionName)
|
||||
request.SetQualifier(functionAlias)
|
||||
|
||||
response, err := l.client.Invoke(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var out responsePayload
|
||||
if err := json.Unmarshal(response.Payload, &out); err != nil {
|
||||
return errors.Wrap(err, "failed to deserialize lambda response")
|
||||
}
|
||||
|
||||
if out.ErrorMessage != "" {
|
||||
return errors.New(out.ErrorMessage)
|
||||
}
|
||||
|
||||
feed.LastID = out.LastID
|
||||
feed.Episodes = append(out.Episodes, feed.Episodes...)
|
||||
feed.UpdatedAt = time.Now().UTC()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package builders
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
func TestLambda_Invoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping lambda test in short mode")
|
||||
}
|
||||
|
||||
lambda, err := NewLambda()
|
||||
assert.NoError(t, err)
|
||||
|
||||
feed := &model.Feed{
|
||||
ItemURL: "https://youtube.com/channel/UCupvZG-5ko_eiXAupbDfxWw",
|
||||
PageSize: 2,
|
||||
Format: "video",
|
||||
Quality: "high",
|
||||
Episodes: []*model.Item{
|
||||
{ID: "Test"},
|
||||
},
|
||||
}
|
||||
|
||||
err = lambda.Build(feed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, feed.Episodes, 3)
|
||||
assert.Equal(t, "Test", feed.Episodes[2].ID)
|
||||
assert.NotEmpty(t, feed.LastID)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package builders
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
type Remote struct {
|
||||
url string
|
||||
}
|
||||
|
||||
func NewRemote(url string) Remote {
|
||||
return Remote{url: url}
|
||||
}
|
||||
|
||||
func (r Remote) Build(feed *model.Feed) error {
|
||||
addr, err := r.makeURL(feed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
resp, err := client.Get(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to read error body (status: %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
return errors.Errorf("unexpected response (%d) from updater: %q", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var out responsePayload
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
feed.LastID = out.LastID
|
||||
feed.Episodes = append(out.Episodes, feed.Episodes...)
|
||||
feed.UpdatedAt = time.Now().UTC()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Remote) makeURL(feed *model.Feed) (string, error) {
|
||||
qs := url.Values{}
|
||||
qs.Add("url", feed.ItemURL)
|
||||
qs.Add("start", "1")
|
||||
qs.Add("count", strconv.Itoa(feed.PageSize))
|
||||
qs.Add("last_id", feed.LastID)
|
||||
qs.Add("format", string(feed.Format))
|
||||
qs.Add("quality", string(feed.Quality))
|
||||
|
||||
parsed, err := url.Parse(r.url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parsed.RawQuery = qs.Encode()
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package builders
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
func TestRemote_makeURL(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping remote test in short mode")
|
||||
}
|
||||
|
||||
feed := &model.Feed{
|
||||
ItemURL: "https://youtube.com/channel/UCupvZG-5ko_eiXAupbDfxWw",
|
||||
PageSize: 2,
|
||||
Format: "video",
|
||||
Quality: "high",
|
||||
}
|
||||
|
||||
out, err := Remote{url: "http://updater:8080/update"}.makeURL(feed)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "http://updater:8080/update?count=2&format=video&last_id=&quality=high&start=1&url=https%3A%2F%2Fyoutube.com%2Fchannel%2FUCupvZG-5ko_eiXAupbDfxWw", out)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
"github.com/mxpv/podsync/pkg/queue"
|
||||
)
|
||||
|
||||
type Builder interface {
|
||||
@@ -26,18 +25,13 @@ type storage interface {
|
||||
Downgrade(userID string, featureLevel int) ([]string, error)
|
||||
}
|
||||
|
||||
type Sender interface {
|
||||
Add(item *queue.Item)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
generator IDGen
|
||||
storage storage
|
||||
builders map[api.Provider]Builder
|
||||
sender Sender
|
||||
}
|
||||
|
||||
func NewFeedService(db storage, sender Sender, builders map[api.Provider]Builder) (*Service, error) {
|
||||
func NewFeedService(db storage, builders map[api.Provider]Builder) (*Service, error) {
|
||||
idGen, err := NewIDGen()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -47,7 +41,6 @@ func NewFeedService(db storage, sender Sender, builders map[api.Provider]Builder
|
||||
generator: idGen,
|
||||
storage: db,
|
||||
builders: builders,
|
||||
sender: sender,
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
@@ -151,20 +144,6 @@ func (s *Service) BuildFeed(hashID string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Submit to SQS for background update
|
||||
item := &queue.Item{
|
||||
ID: feed.HashID,
|
||||
URL: feed.ItemURL,
|
||||
Start: 1,
|
||||
Count: feed.PageSize,
|
||||
LastID: feed.LastID,
|
||||
LinkType: feed.LinkType,
|
||||
Format: string(feed.Format),
|
||||
Quality: string(feed.Quality),
|
||||
}
|
||||
|
||||
s.sender.Add(item)
|
||||
|
||||
// Output the feed
|
||||
|
||||
if feed.PageSize < len(feed.Episodes) {
|
||||
|
||||
@@ -7,7 +7,6 @@ package feeds
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
model "github.com/mxpv/podsync/pkg/model"
|
||||
queue "github.com/mxpv/podsync/pkg/queue"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
@@ -143,38 +142,3 @@ func (mr *MockstorageMockRecorder) Downgrade(userID, featureLevel interface{}) *
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Downgrade", reflect.TypeOf((*Mockstorage)(nil).Downgrade), userID, featureLevel)
|
||||
}
|
||||
|
||||
// MockSender is a mock of Sender interface
|
||||
type MockSender struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockSenderMockRecorder
|
||||
}
|
||||
|
||||
// MockSenderMockRecorder is the mock recorder for MockSender
|
||||
type MockSenderMockRecorder struct {
|
||||
mock *MockSender
|
||||
}
|
||||
|
||||
// NewMockSender creates a new mock instance
|
||||
func NewMockSender(ctrl *gomock.Controller) *MockSender {
|
||||
mock := &MockSender{ctrl: ctrl}
|
||||
mock.recorder = &MockSenderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockSender) EXPECT() *MockSenderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Add mocks base method
|
||||
func (m *MockSender) Add(item *queue.Item) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "Add", item)
|
||||
}
|
||||
|
||||
// Add indicates an expected call of Add
|
||||
func (mr *MockSenderMockRecorder) Add(item interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockSender)(nil).Add), item)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
"github.com/mxpv/podsync/pkg/queue"
|
||||
)
|
||||
|
||||
var feed = &model.Feed{
|
||||
@@ -109,19 +108,7 @@ func TestService_BuildFeed(t *testing.T) {
|
||||
stor := NewMockstorage(ctrl)
|
||||
stor.EXPECT().GetFeed(feed.HashID).Times(1).Return(feed, nil)
|
||||
|
||||
q := NewMockSender(ctrl)
|
||||
q.EXPECT().Add(gomock.Eq(&queue.Item{
|
||||
ID: feed.HashID,
|
||||
URL: feed.ItemURL,
|
||||
Start: 1,
|
||||
Count: feed.PageSize,
|
||||
LastID: feed.LastID,
|
||||
LinkType: feed.LinkType,
|
||||
Format: string(feed.Format),
|
||||
Quality: string(feed.Quality),
|
||||
})).Times(1)
|
||||
|
||||
s := Service{storage: stor, sender: q}
|
||||
s := Service{storage: stor}
|
||||
|
||||
_, err := s.BuildFeed(feed.HashID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache"
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mxpv/patreon-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/session"
|
||||
)
|
||||
|
||||
const (
|
||||
maxHashIDLength = 16
|
||||
)
|
||||
|
||||
type feedService interface {
|
||||
CreateFeed(req *api.CreateFeedRequest, identity *api.Identity) (string, error)
|
||||
BuildFeed(hashID string) ([]byte, error)
|
||||
GetMetadata(hashID string) (*api.Metadata, error)
|
||||
Downgrade(patronID string, featureLevel int) error
|
||||
}
|
||||
|
||||
type patreonService interface {
|
||||
Hook(pledge *patreon.Pledge, event string) error
|
||||
GetFeatureLevelByID(patronID string) int
|
||||
GetFeatureLevelFromAmount(amount int) int
|
||||
}
|
||||
|
||||
type Opts struct {
|
||||
CookieSecret string
|
||||
RedisHost string
|
||||
PatreonClientID string
|
||||
PatreonSecret string
|
||||
PatreonRedirectURL string
|
||||
PatreonWebhooksSecret string
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
feed feedService
|
||||
oauth2 oauth2.Config
|
||||
patreon patreonService
|
||||
PatreonWebhooksSecret string
|
||||
}
|
||||
|
||||
func New(feed feedService, support patreonService, opts Opts) http.Handler {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
|
||||
cacheStore := persistence.NewRedisCache(opts.RedisHost, "", time.Second)
|
||||
|
||||
store := sessions.NewCookieStore([]byte(opts.CookieSecret))
|
||||
r.Use(sessions.Sessions("podsync", store))
|
||||
|
||||
h := handler{
|
||||
feed: feed,
|
||||
patreon: support,
|
||||
PatreonWebhooksSecret: opts.PatreonWebhooksSecret,
|
||||
}
|
||||
|
||||
// OAuth 2 configuration
|
||||
|
||||
h.oauth2 = oauth2.Config{
|
||||
ClientID: opts.PatreonClientID,
|
||||
ClientSecret: opts.PatreonSecret,
|
||||
RedirectURL: opts.PatreonRedirectURL,
|
||||
Scopes: []string{"users", "pledges-to-me", "my-campaign"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: patreon.AuthorizationURL,
|
||||
TokenURL: patreon.AccessTokenURL,
|
||||
},
|
||||
}
|
||||
|
||||
// Handlers
|
||||
|
||||
r.GET("/user/login", h.login)
|
||||
r.GET("/user/logout", h.logout)
|
||||
r.GET("/user/patreon", h.patreonCallback)
|
||||
|
||||
r.GET("/api/ping", h.ping)
|
||||
r.GET("/api/user", h.user)
|
||||
r.POST("/api/create", h.create)
|
||||
r.GET("/api/metadata/:hashId", h.metadata)
|
||||
r.POST("/api/webhooks", h.webhook)
|
||||
|
||||
const feedTTL = 1 * time.Hour
|
||||
r.NoRoute(cache.CachePage(cacheStore, feedTTL, h.getFeed))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h handler) login(c *gin.Context) {
|
||||
state, err := session.SetState(c)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
authURL := h.oauth2.AuthCodeURL(state)
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
func (h handler) logout(c *gin.Context) {
|
||||
session.Clear(c)
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h handler) patreonCallback(c *gin.Context) {
|
||||
// Validate session state
|
||||
if session.GetSetate(c) != c.Query("state") {
|
||||
c.String(http.StatusUnauthorized, "invalid state")
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code with tokens
|
||||
token, err := h.oauth2.Exchange(c.Request.Context(), c.Query("code"))
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Create Patreon client
|
||||
tc := h.oauth2.Client(c.Request.Context(), token)
|
||||
client := patreon.NewClient(tc)
|
||||
|
||||
// Query user info from Patreon
|
||||
user, err := client.FetchUser()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Determine feature level
|
||||
level := h.patreon.GetFeatureLevelByID(user.Data.ID)
|
||||
|
||||
identity := &api.Identity{
|
||||
UserID: user.Data.ID,
|
||||
FullName: user.Data.Attributes.FullName,
|
||||
Email: user.Data.Attributes.Email,
|
||||
ProfileURL: user.Data.Attributes.URL,
|
||||
FeatureLevel: level,
|
||||
}
|
||||
|
||||
session.SetIdentity(c, identity)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h handler) ping(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func (h handler) user(c *gin.Context) {
|
||||
identity, err := session.GetIdentity(c)
|
||||
if err != nil || identity.UserID == "" {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": identity.UserID,
|
||||
"feature_level": identity.FeatureLevel,
|
||||
"full_name": identity.FullName,
|
||||
})
|
||||
}
|
||||
|
||||
func (h handler) create(c *gin.Context) {
|
||||
req := &api.CreateFeedRequest{}
|
||||
|
||||
if err := c.BindJSON(req); err != nil {
|
||||
c.JSON(badRequest(err))
|
||||
return
|
||||
}
|
||||
|
||||
identity, err := session.GetIdentity(c)
|
||||
if err != nil {
|
||||
c.JSON(internalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Check feature level again if user deleted pledge by still logged in
|
||||
identity.FeatureLevel = h.patreon.GetFeatureLevelByID(identity.UserID)
|
||||
|
||||
hashID, err := h.feed.CreateFeed(req, identity)
|
||||
if err != nil {
|
||||
c.JSON(internalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": hashID})
|
||||
}
|
||||
|
||||
func (h handler) getFeed(c *gin.Context) {
|
||||
hashID := c.Request.URL.Path[1:]
|
||||
if hashID == "" || len(hashID) > maxHashIDLength {
|
||||
c.String(http.StatusBadRequest, "invalid feed id")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(hashID, ".xml") {
|
||||
hashID = strings.TrimSuffix(hashID, ".xml")
|
||||
}
|
||||
|
||||
podcast, err := h.feed.BuildFeed(hashID)
|
||||
if err != nil {
|
||||
code := http.StatusInternalServerError
|
||||
if err == api.ErrNotFound {
|
||||
code = http.StatusNotFound
|
||||
} else if err == api.ErrQuotaExceeded {
|
||||
code = http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
c.String(code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
const feedContentType = "application/rss+xml; charset=UTF-8"
|
||||
c.Data(http.StatusOK, feedContentType, podcast)
|
||||
}
|
||||
|
||||
func (h handler) metadata(c *gin.Context) {
|
||||
hashID := c.Param("hashId")
|
||||
if hashID == "" || len(hashID) > maxHashIDLength {
|
||||
c.String(http.StatusBadRequest, "invalid feed id")
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := h.feed.GetMetadata(hashID)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, feed)
|
||||
}
|
||||
|
||||
func (h handler) webhook(c *gin.Context) {
|
||||
// Read body to byte array in order to verify signature first
|
||||
body, err := ioutil.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("failed to read webhook request")
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
signature := c.GetHeader(patreon.HeaderSignature)
|
||||
valid, err := patreon.VerifySignature(body, h.PatreonWebhooksSecret, signature)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("failed to verify signature")
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
log.Errorf("webhooks signatures are not equal (header: %s)", signature)
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get event name
|
||||
eventName := c.GetHeader(patreon.HeaderEventType)
|
||||
if eventName == "" {
|
||||
log.Error("event name header is empty")
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pledge := &patreon.WebhookPledge{}
|
||||
if err := json.Unmarshal(body, pledge); err != nil {
|
||||
log.WithError(err).Error("failed to unmarshal pledge")
|
||||
c.JSON(badRequest(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.patreon.Hook(&pledge.Data, eventName); err != nil {
|
||||
log.WithError(err).WithFields(log.Fields{
|
||||
"user_id": pledge.Data.Relationships.Patron.Data.ID,
|
||||
"pledge_id": pledge.Data.ID,
|
||||
"pledge_event": eventName,
|
||||
}).Error("failed to process patreon event")
|
||||
|
||||
// Don't return any errors to Patreon, otherwise subsequent notifications will be blocked.
|
||||
return
|
||||
}
|
||||
|
||||
patronID := pledge.Data.Relationships.Patron.Data.ID
|
||||
|
||||
if eventName == patreon.EventUpdatePledge {
|
||||
newLevel := h.patreon.GetFeatureLevelFromAmount(pledge.Data.Attributes.AmountCents)
|
||||
if err := h.feed.Downgrade(patronID, newLevel); err != nil {
|
||||
return
|
||||
}
|
||||
} else if eventName == patreon.EventDeletePledge {
|
||||
if err := h.feed.Downgrade(patronID, api.DefaultFeatures); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("successfully processed patreon event %s (%s)", pledge.Data.ID, eventName)
|
||||
}
|
||||
|
||||
func badRequest(err error) (int, interface{}) {
|
||||
return http.StatusBadRequest, gin.H{"error": err.Error()}
|
||||
}
|
||||
|
||||
func internalError(err error) (int, interface{}) {
|
||||
log.Printf("server error: %v", err)
|
||||
return http.StatusInternalServerError, gin.H{"error": err.Error()}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: handler.go
|
||||
|
||||
// Package handler is a generated GoMock package.
|
||||
package handler
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
patreon_go "github.com/mxpv/patreon-go"
|
||||
api "github.com/mxpv/podsync/pkg/api"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockfeedService is a mock of feedService interface
|
||||
type MockfeedService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockfeedServiceMockRecorder
|
||||
}
|
||||
|
||||
// MockfeedServiceMockRecorder is the mock recorder for MockfeedService
|
||||
type MockfeedServiceMockRecorder struct {
|
||||
mock *MockfeedService
|
||||
}
|
||||
|
||||
// NewMockfeedService creates a new mock instance
|
||||
func NewMockfeedService(ctrl *gomock.Controller) *MockfeedService {
|
||||
mock := &MockfeedService{ctrl: ctrl}
|
||||
mock.recorder = &MockfeedServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockfeedService) EXPECT() *MockfeedServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// CreateFeed mocks base method
|
||||
func (m *MockfeedService) CreateFeed(req *api.CreateFeedRequest, identity *api.Identity) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateFeed", req, identity)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateFeed indicates an expected call of CreateFeed
|
||||
func (mr *MockfeedServiceMockRecorder) CreateFeed(req, identity interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateFeed", reflect.TypeOf((*MockfeedService)(nil).CreateFeed), req, identity)
|
||||
}
|
||||
|
||||
// BuildFeed mocks base method
|
||||
func (m *MockfeedService) BuildFeed(hashID string) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "BuildFeed", hashID)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// BuildFeed indicates an expected call of BuildFeed
|
||||
func (mr *MockfeedServiceMockRecorder) BuildFeed(hashID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildFeed", reflect.TypeOf((*MockfeedService)(nil).BuildFeed), hashID)
|
||||
}
|
||||
|
||||
// GetMetadata mocks base method
|
||||
func (m *MockfeedService) GetMetadata(hashID string) (*api.Metadata, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetMetadata", hashID)
|
||||
ret0, _ := ret[0].(*api.Metadata)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetMetadata indicates an expected call of GetMetadata
|
||||
func (mr *MockfeedServiceMockRecorder) GetMetadata(hashID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMetadata", reflect.TypeOf((*MockfeedService)(nil).GetMetadata), hashID)
|
||||
}
|
||||
|
||||
// Downgrade mocks base method
|
||||
func (m *MockfeedService) Downgrade(patronID string, featureLevel int) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Downgrade", patronID, featureLevel)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Downgrade indicates an expected call of Downgrade
|
||||
func (mr *MockfeedServiceMockRecorder) Downgrade(patronID, featureLevel interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Downgrade", reflect.TypeOf((*MockfeedService)(nil).Downgrade), patronID, featureLevel)
|
||||
}
|
||||
|
||||
// MockpatreonService is a mock of patreonService interface
|
||||
type MockpatreonService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockpatreonServiceMockRecorder
|
||||
}
|
||||
|
||||
// MockpatreonServiceMockRecorder is the mock recorder for MockpatreonService
|
||||
type MockpatreonServiceMockRecorder struct {
|
||||
mock *MockpatreonService
|
||||
}
|
||||
|
||||
// NewMockpatreonService creates a new mock instance
|
||||
func NewMockpatreonService(ctrl *gomock.Controller) *MockpatreonService {
|
||||
mock := &MockpatreonService{ctrl: ctrl}
|
||||
mock.recorder = &MockpatreonServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockpatreonService) EXPECT() *MockpatreonServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Hook mocks base method
|
||||
func (m *MockpatreonService) Hook(pledge *patreon_go.Pledge, event string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Hook", pledge, event)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Hook indicates an expected call of Hook
|
||||
func (mr *MockpatreonServiceMockRecorder) Hook(pledge, event interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Hook", reflect.TypeOf((*MockpatreonService)(nil).Hook), pledge, event)
|
||||
}
|
||||
|
||||
// GetFeatureLevelByID mocks base method
|
||||
func (m *MockpatreonService) GetFeatureLevelByID(patronID string) int {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetFeatureLevelByID", patronID)
|
||||
ret0, _ := ret[0].(int)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetFeatureLevelByID indicates an expected call of GetFeatureLevelByID
|
||||
func (mr *MockpatreonServiceMockRecorder) GetFeatureLevelByID(patronID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureLevelByID", reflect.TypeOf((*MockpatreonService)(nil).GetFeatureLevelByID), patronID)
|
||||
}
|
||||
|
||||
// GetFeatureLevelFromAmount mocks base method
|
||||
func (m *MockpatreonService) GetFeatureLevelFromAmount(amount int) int {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetFeatureLevelFromAmount", amount)
|
||||
ret0, _ := ret[0].(int)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetFeatureLevelFromAmount indicates an expected call of GetFeatureLevelFromAmount
|
||||
func (mr *MockpatreonServiceMockRecorder) GetFeatureLevelFromAmount(amount interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureLevelFromAmount", reflect.TypeOf((*MockpatreonService)(nil).GetFeatureLevelFromAmount), amount)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
//go:generate mockgen -source=handler.go -destination=handler_mock_test.go -package=handler
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
)
|
||||
|
||||
func TestCreateFeed(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
req := &api.CreateFeedRequest{
|
||||
URL: "https://youtube.com/channel/123",
|
||||
PageSize: 55,
|
||||
Quality: api.QualityLow,
|
||||
Format: api.FormatAudio,
|
||||
}
|
||||
|
||||
feed := NewMockfeedService(ctrl)
|
||||
feed.EXPECT().CreateFeed(gomock.Eq(req), gomock.Any()).Times(1).Return("456", nil)
|
||||
|
||||
patreon := NewMockpatreonService(ctrl)
|
||||
patreon.EXPECT().GetFeatureLevelByID(gomock.Any()).Return(api.DefaultFeatures)
|
||||
|
||||
srv := httptest.NewServer(New(feed, patreon, Opts{}))
|
||||
defer srv.Close()
|
||||
|
||||
query := `{"url": "https://youtube.com/channel/123", "page_size": 55, "quality": "low", "format": "audio"}`
|
||||
resp, err := http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
require.JSONEq(t, `{"id": "456"}`, readBody(t, resp))
|
||||
}
|
||||
|
||||
func TestCreateInvalidFeed(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
srv := httptest.NewServer(New(NewMockfeedService(ctrl), nil, Opts{}))
|
||||
defer srv.Close()
|
||||
|
||||
query := `{}`
|
||||
resp, err := http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
query = `{"url": "not a url", "page_size": 55, "quality": "low", "format": "audio"}`
|
||||
resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
query = `{"url": "https://youtube.com/channel/123", "page_size": 1, "quality": "low", "format": "audio"}`
|
||||
resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
query = `{"url": "https://youtube.com/channel/123", "page_size": 1001, "quality": "low", "format": "audio"}`
|
||||
resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
query = `{"url": "https://youtube.com/channel/123", "page_size": 50, "quality": "xyz", "format": "audio"}`
|
||||
resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
query = `{"url": "https://youtube.com/channel/123", "page_size": 50, "quality": "low", "format": "xyz"}`
|
||||
resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
query = `{"url": "https://youtube.com/channel/123", "page_size": 50, "quality": "low", "format": ""}`
|
||||
resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
query = `{"url": "https://youtube.com/channel/123", "page_size": 50, "quality": "", "format": "audio"}`
|
||||
resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetFeed(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
feed := NewMockfeedService(ctrl)
|
||||
feed.EXPECT().BuildFeed("123").Return([]byte("Test"), nil)
|
||||
|
||||
srv := httptest.NewServer(New(feed, nil, Opts{}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/123")
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetMetadata(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
feed := NewMockfeedService(ctrl)
|
||||
feed.EXPECT().GetMetadata("123").Times(1).Return(&api.Metadata{}, nil)
|
||||
|
||||
srv := httptest.NewServer(New(feed, nil, Opts{}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/api/metadata/123")
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) string {
|
||||
buf, err := ioutil.ReadAll(resp.Body)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(buf)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/sqs"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
)
|
||||
|
||||
var (
|
||||
sess = session.Must(session.NewSession())
|
||||
queue = sqs.New(sess)
|
||||
)
|
||||
|
||||
const (
|
||||
chanSize = 1024
|
||||
maxElementPerBatch = 10 // SQS Batch limit is 10 items per request
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Start int `json:"start"`
|
||||
Count int `json:"count"`
|
||||
LastID string `json:"last_id"`
|
||||
LinkType api.LinkType `json:"link_type"`
|
||||
Format string `json:"format"`
|
||||
Quality string `json:"quality"`
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
url *string
|
||||
items chan *Item
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func New(ctx context.Context, url string) *Sender {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
items := make(chan *Item, chanSize)
|
||||
|
||||
sender := &Sender{
|
||||
url: aws.String(url),
|
||||
items: items,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
go sender.transmit(ctx)
|
||||
|
||||
return sender
|
||||
}
|
||||
|
||||
func (s *Sender) Add(item *Item) {
|
||||
s.items <- item
|
||||
}
|
||||
|
||||
func (s *Sender) Close() {
|
||||
s.cancel()
|
||||
close(s.items)
|
||||
}
|
||||
|
||||
func (s *Sender) transmit(ctx context.Context) error {
|
||||
var list = make([]*Item, 0, maxElementPerBatch)
|
||||
|
||||
flush := func(ctx context.Context) {
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.send(ctx, list); err != nil {
|
||||
log.WithError(err).Error("failed to send batch")
|
||||
}
|
||||
|
||||
list = make([]*Item, 0, maxElementPerBatch)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
// Flush list if not filled up entirely within 5 seconds
|
||||
flush(ctx)
|
||||
|
||||
case item := <-s.items:
|
||||
// Append an item to list and flush if filled up
|
||||
list = append(list, item)
|
||||
if len(list) == maxElementPerBatch {
|
||||
flush(ctx)
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
// Exiting, flush leftovers
|
||||
flush(context.Background())
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) send(ctx context.Context, list []*Item) error {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("sending a new batch")
|
||||
|
||||
sendInput := &sqs.SendMessageBatchInput{
|
||||
QueueUrl: s.url,
|
||||
}
|
||||
|
||||
for _, item := range list {
|
||||
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to marshal item %q", item.ID)
|
||||
}
|
||||
|
||||
body := string(data)
|
||||
|
||||
sendInput.Entries = append(sendInput.Entries, &sqs.SendMessageBatchRequestEntry{
|
||||
Id: aws.String(item.ID),
|
||||
MessageBody: aws.String(body),
|
||||
})
|
||||
|
||||
log.Debugf("sending batch: %+v", sendInput)
|
||||
}
|
||||
|
||||
_, err := queue.SendMessageBatchWithContext(ctx, sendInput)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to send message batch")
|
||||
}
|
||||
|
||||
log.Infof("sent %d item(s) to SQS", len(list))
|
||||
return nil
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
)
|
||||
|
||||
const (
|
||||
identitySessionKey = "identity"
|
||||
stateKey = "state"
|
||||
)
|
||||
|
||||
var errBrokenSession = errors.New("broken session, try to login again")
|
||||
|
||||
func Clear(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
s.Clear()
|
||||
s.Save()
|
||||
}
|
||||
|
||||
func GetIdentity(c *gin.Context) (*api.Identity, error) {
|
||||
s := sessions.Default(c)
|
||||
i := &api.Identity{}
|
||||
|
||||
buf, ok := s.Get(identitySessionKey).(string)
|
||||
if ok {
|
||||
// Deserialize string to Identity{}
|
||||
if err := json.Unmarshal([]byte(buf), i); err != nil {
|
||||
s.Clear()
|
||||
s.Save()
|
||||
|
||||
return nil, errBrokenSession
|
||||
}
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func SetIdentity(c *gin.Context, identity *api.Identity) error {
|
||||
buf, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s := sessions.Default(c)
|
||||
s.Clear()
|
||||
s.Set(identitySessionKey, string(buf))
|
||||
return s.Save()
|
||||
}
|
||||
|
||||
func SetState(c *gin.Context) (string, error) {
|
||||
s := sessions.Default(c)
|
||||
state := randToken()
|
||||
s.Set(stateKey, state)
|
||||
return state, s.Save()
|
||||
}
|
||||
|
||||
func GetSetate(c *gin.Context) interface{} {
|
||||
s := sessions.Default(c)
|
||||
return s.Get(stateKey)
|
||||
}
|
||||
|
||||
func randToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Compression helpers to reduce object size in DynamoDB table.
|
||||
// See https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-use-s3-too.html
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func compressObj(obj interface{}) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := gzip.NewWriter(&buf)
|
||||
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func decompressObj(data []byte, obj interface{}) error {
|
||||
r, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.NewDecoder(r).Decode(obj)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
func TestCompress(t *testing.T) {
|
||||
inData := []model.Item{
|
||||
{ID: "1", Title: "title1"},
|
||||
{ID: "2", Title: "title2"},
|
||||
}
|
||||
|
||||
data, err := compressObj(inData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var outData []model.Item
|
||||
err = decompressObj(data, &outData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.ObjectsAreEqual(inData, outData)
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/dynamodb"
|
||||
attr "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
|
||||
expr "github.com/aws/aws-sdk-go/service/dynamodb/expression"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
pingTimeout = 5 * time.Second
|
||||
pledgesPrimaryKey = "PatronID"
|
||||
feedsPrimaryKey = "HashID"
|
||||
anonymousUserName = "anonymous"
|
||||
|
||||
// Update LastAccess field every hour
|
||||
feedLastAccessUpdatePeriod = time.Hour * 24
|
||||
feedTimeToLive = time.Hour * 24 * 90
|
||||
)
|
||||
|
||||
var (
|
||||
feedTimeToLiveField = aws.String("ExpirationTime")
|
||||
feedDowngradeIndexName = aws.String("UserID-HashID-Index")
|
||||
)
|
||||
|
||||
/*
|
||||
Pledges:
|
||||
Table name: Pledges
|
||||
Primary key: PatronID (Number)
|
||||
RCU: 1 (used while creating a new feed)
|
||||
WCU: 1 (used when pledge changes)
|
||||
No secondary indexed needed
|
||||
Feeds:
|
||||
Table name: Feeds
|
||||
Primary key: HashID (String)
|
||||
RCU: 10
|
||||
WCU: 5
|
||||
Secondary index:
|
||||
Primary key: UserID (String)
|
||||
Sort key: HashID (String)
|
||||
Index name: UserID-HashID-Index
|
||||
Projected attr: Keys only
|
||||
RCU/WCU: 1/1
|
||||
TTL attr: ExpirationTime
|
||||
*/
|
||||
type Dynamo struct {
|
||||
dynamo *dynamodb.DynamoDB
|
||||
FeedsTableName *string
|
||||
PledgesTableName *string
|
||||
}
|
||||
|
||||
func NewDynamo(cfg ...*aws.Config) (Dynamo, error) {
|
||||
sess, err := session.NewSession(cfg...)
|
||||
if err != nil {
|
||||
return Dynamo{}, err
|
||||
}
|
||||
|
||||
db := dynamodb.New(sess)
|
||||
|
||||
// Verify connectivity
|
||||
ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err = db.ListTablesWithContext(ctx, &dynamodb.ListTablesInput{})
|
||||
if err != nil {
|
||||
return Dynamo{}, err
|
||||
}
|
||||
|
||||
return Dynamo{
|
||||
dynamo: db,
|
||||
FeedsTableName: aws.String("Feeds"),
|
||||
PledgesTableName: aws.String("Pledges"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d Dynamo) SaveFeed(feed *model.Feed) error {
|
||||
logger := log.WithFields(log.Fields{
|
||||
"hash_id": feed.HashID,
|
||||
"user_id": feed.UserID,
|
||||
})
|
||||
|
||||
// Secondary index uses UserID as primary key and used for downgrading feeds.
|
||||
// However it might be null if user is anonymous and DynamoDB doesn't support null PKs.
|
||||
// So use special user for bypass this. In general this practise is not recommended by
|
||||
// AWS (https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-uniform-load.html)
|
||||
// as it would blow up one of the partitions and decrease performance, however we are not
|
||||
// going to query items by anonymous user.
|
||||
if feed.UserID == "" {
|
||||
feed.UserID = anonymousUserName
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
now = time.Now().UTC()
|
||||
)
|
||||
|
||||
feed.LastAccess = now
|
||||
feed.ExpirationTime = now.Add(feedTimeToLive)
|
||||
|
||||
// Compress episodes
|
||||
|
||||
feed.EpisodesData, err = compressObj(feed.Episodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Marshal to DynamoDB's format
|
||||
|
||||
item, err := attr.MarshalMap(feed)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to marshal feed model")
|
||||
return err
|
||||
}
|
||||
|
||||
// Submit request
|
||||
|
||||
input := &dynamodb.PutItemInput{
|
||||
TableName: d.FeedsTableName,
|
||||
Item: item,
|
||||
ConditionExpression: aws.String("attribute_not_exists(HashID)"),
|
||||
}
|
||||
|
||||
if _, err := d.dynamo.PutItem(input); err != nil {
|
||||
logger.WithError(err).Error("failed to save feed item")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Dynamo) GetFeed(hashID string) (*model.Feed, error) {
|
||||
logger := log.WithField("hash_id", hashID)
|
||||
|
||||
// Submit get request
|
||||
|
||||
getInput := &dynamodb.GetItemInput{
|
||||
TableName: d.FeedsTableName,
|
||||
Key: map[string]*dynamodb.AttributeValue{
|
||||
"HashID": {S: aws.String(hashID)},
|
||||
},
|
||||
}
|
||||
|
||||
getOutput, err := d.dynamo.GetItem(getInput)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to get feed item")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if getOutput.Item == nil {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
|
||||
// Unmarshal data
|
||||
|
||||
var feed model.Feed
|
||||
if err := attr.UnmarshalMap(getOutput.Item, &feed); err != nil {
|
||||
// TODO: remove this
|
||||
delete(getOutput.Item, "UpdatedAt")
|
||||
delete(getOutput.Item, "PubDate")
|
||||
|
||||
err = attr.UnmarshalMap(getOutput.Item, &feed)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to unmarshal feed item")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to update LastAccess field (no more than once per hour)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if feed.LastAccess.Add(feedLastAccessUpdatePeriod).Before(now) {
|
||||
if err := d.updateLastAccess(getInput.Key); err != nil {
|
||||
logger.WithError(err).Error("failed to update feed's last access")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
feed.LastAccess = now
|
||||
}
|
||||
|
||||
// Decompress episodes
|
||||
|
||||
if len(feed.EpisodesData) > 0 {
|
||||
if err := decompressObj(feed.EpisodesData, &feed.Episodes); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to decompress episodes")
|
||||
}
|
||||
}
|
||||
|
||||
return &feed, nil
|
||||
}
|
||||
|
||||
func (d Dynamo) updateLastAccess(key map[string]*dynamodb.AttributeValue) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Set LastAccess field to now
|
||||
// Set ExpirationTime field to now + feedTimeToLive
|
||||
updateExpression, err := expr.
|
||||
NewBuilder().
|
||||
WithUpdate(expr.
|
||||
Set(expr.Name("LastAccess"), expr.Value(now.Unix())).
|
||||
Set(expr.Name("ExpirationTime"), expr.Value(now.Add(feedTimeToLive).Unix()))).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateInput := &dynamodb.UpdateItemInput{
|
||||
TableName: d.FeedsTableName,
|
||||
Key: key,
|
||||
UpdateExpression: updateExpression.Update(),
|
||||
ExpressionAttributeNames: updateExpression.Names(),
|
||||
ExpressionAttributeValues: updateExpression.Values(),
|
||||
}
|
||||
|
||||
_, err = d.dynamo.UpdateItem(updateInput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Dynamo) UpdateFeed(feed *model.Feed) error {
|
||||
var (
|
||||
pubDate = feed.PubDate.Unix()
|
||||
updatedAt = feed.LastAccess.Unix()
|
||||
)
|
||||
|
||||
episodesData, err := compressObj(feed.Episodes)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to compress episodes for update")
|
||||
}
|
||||
|
||||
update := expr.
|
||||
Set(expr.Name("Title"), expr.Value(feed.Title)).
|
||||
Set(expr.Name("Description"), expr.Value(feed.Description)).
|
||||
Set(expr.Name("PubDate"), expr.Value(pubDate)).
|
||||
Set(expr.Name("Author"), expr.Value(feed.Author)).
|
||||
Set(expr.Name("ItemURL"), expr.Value(feed.ItemURL)).
|
||||
Set(expr.Name("LastID"), expr.Value(feed.LastID)).
|
||||
Set(expr.Name("UpdatedAt"), expr.Value(updatedAt)).
|
||||
Set(expr.Name("EpisodesData"), expr.Value(episodesData)). // Serialized episodes
|
||||
Remove(expr.Name("Episodes")) // Remove old field to save space
|
||||
|
||||
expression, err := expr.NewBuilder().WithUpdate(update).Build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
input := &dynamodb.UpdateItemInput{
|
||||
TableName: d.FeedsTableName,
|
||||
Key: map[string]*dynamodb.AttributeValue{
|
||||
"HashID": {S: aws.String(feed.HashID)},
|
||||
},
|
||||
|
||||
UpdateExpression: expression.Update(),
|
||||
ExpressionAttributeNames: expression.Names(),
|
||||
ExpressionAttributeValues: expression.Values(),
|
||||
}
|
||||
|
||||
_, err = d.dynamo.UpdateItem(input)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d Dynamo) GetMetadata(hashID string) (*model.Feed, error) {
|
||||
logger := log.WithField("hash_id", hashID)
|
||||
|
||||
logger.Debug("getting metadata")
|
||||
|
||||
projectionExpression, err := expr.
|
||||
NewBuilder().
|
||||
WithProjection(
|
||||
expr.NamesList(
|
||||
expr.Name("FeedID"),
|
||||
expr.Name("HashID"),
|
||||
expr.Name("UserID"),
|
||||
expr.Name("Provider"),
|
||||
expr.Name("Format"),
|
||||
expr.Name("Quality"))).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to build projection expression")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input := &dynamodb.GetItemInput{
|
||||
TableName: d.FeedsTableName,
|
||||
Key: map[string]*dynamodb.AttributeValue{
|
||||
"HashID": {S: aws.String(hashID)},
|
||||
},
|
||||
ProjectionExpression: projectionExpression.Projection(),
|
||||
ExpressionAttributeNames: projectionExpression.Names(),
|
||||
}
|
||||
|
||||
output, err := d.dynamo.GetItem(input)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to get metadata item")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if output.Item == nil {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
|
||||
var feed model.Feed
|
||||
if err := attr.UnmarshalMap(output.Item, &feed); err != nil {
|
||||
logger.WithError(err).Error("failed to unmarshal metadata item")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &feed, nil
|
||||
}
|
||||
|
||||
func (d Dynamo) Downgrade(userID string, featureLevel int) ([]string, error) {
|
||||
logger := log.WithFields(log.Fields{
|
||||
"user_id": userID,
|
||||
"feature_level": featureLevel,
|
||||
})
|
||||
|
||||
logger.Debug("downgrading user's feeds")
|
||||
|
||||
if featureLevel > api.ExtendedFeatures {
|
||||
// Max page size: 600
|
||||
// Format: any
|
||||
// Quality: any
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
keyConditionExpression, err := expr.
|
||||
NewBuilder().
|
||||
WithKeyCondition(expr.KeyEqual(expr.Key("UserID"), expr.Value(userID))).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to build key condition")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Query all feed's hash ids for specified
|
||||
|
||||
logger.Debug("querying hash ids")
|
||||
|
||||
queryInput := &dynamodb.QueryInput{
|
||||
TableName: d.FeedsTableName,
|
||||
IndexName: feedDowngradeIndexName,
|
||||
KeyConditionExpression: keyConditionExpression.KeyCondition(),
|
||||
ExpressionAttributeNames: keyConditionExpression.Names(),
|
||||
ExpressionAttributeValues: keyConditionExpression.Values(),
|
||||
Select: aws.String(dynamodb.SelectAllProjectedAttributes),
|
||||
}
|
||||
|
||||
var keys []map[string]*dynamodb.AttributeValue
|
||||
err = d.dynamo.QueryPages(queryInput, func(output *dynamodb.QueryOutput, lastPage bool) bool {
|
||||
for _, item := range output.Items {
|
||||
keys = append(keys, map[string]*dynamodb.AttributeValue{
|
||||
feedsPrimaryKey: item[feedsPrimaryKey],
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("query failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Debugf("got %d key(s)", len(keys))
|
||||
if len(keys) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
if featureLevel == api.ExtendedFeatures {
|
||||
// Max page size: 150
|
||||
// Format: any
|
||||
// Quality: any
|
||||
updateExpression, err := expr.
|
||||
NewBuilder().
|
||||
WithUpdate(expr.
|
||||
Set(expr.Name("PageSize"), expr.Value(150)).
|
||||
Set(expr.Name("FeatureLevel"), expr.Value(api.ExtendedFeatures))).
|
||||
WithCondition(expr.
|
||||
Name("PageSize").GreaterThan(expr.Value(150))).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to build update expression")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
input := &dynamodb.UpdateItemInput{
|
||||
TableName: d.FeedsTableName,
|
||||
Key: key,
|
||||
ConditionExpression: updateExpression.Condition(),
|
||||
UpdateExpression: updateExpression.Update(),
|
||||
ExpressionAttributeNames: updateExpression.Names(),
|
||||
ExpressionAttributeValues: updateExpression.Values(),
|
||||
}
|
||||
|
||||
_, err := d.dynamo.UpdateItem(input)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to update item")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
} else if featureLevel == api.DefaultFeatures {
|
||||
// Page size: 50
|
||||
// Format: video
|
||||
// Quality: high
|
||||
updateExpression, err := expr.
|
||||
NewBuilder().
|
||||
WithUpdate(expr.
|
||||
Set(expr.Name("PageSize"), expr.Value(50)).
|
||||
Set(expr.Name("FeatureLevel"), expr.Value(api.DefaultFeatures)).
|
||||
Set(expr.Name("Format"), expr.Value(api.FormatVideo)).
|
||||
Set(expr.Name("Quality"), expr.Value(api.QualityHigh))).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
input := &dynamodb.UpdateItemInput{
|
||||
TableName: d.FeedsTableName,
|
||||
Key: key,
|
||||
UpdateExpression: updateExpression.Update(),
|
||||
ExpressionAttributeNames: updateExpression.Names(),
|
||||
ExpressionAttributeValues: updateExpression.Values(),
|
||||
}
|
||||
|
||||
_, err := d.dynamo.UpdateItem(input)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to update item")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hashIDs := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
hashIDs[i] = *key[feedsPrimaryKey].S
|
||||
}
|
||||
|
||||
logger.Info("successfully downgraded user's feeds")
|
||||
return hashIDs, nil
|
||||
}
|
||||
|
||||
func (d Dynamo) AddPledge(pledge *model.Pledge) error {
|
||||
logger := log.WithFields(log.Fields{
|
||||
"pledge_id": pledge.PledgeID,
|
||||
"user_id": pledge.PatronID,
|
||||
})
|
||||
|
||||
item, err := attr.MarshalMap(pledge)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to marshal pledge")
|
||||
return err
|
||||
}
|
||||
|
||||
input := &dynamodb.PutItemInput{
|
||||
TableName: d.PledgesTableName,
|
||||
Item: item,
|
||||
ConditionExpression: aws.String("attribute_not_exists(PatronID)"),
|
||||
}
|
||||
|
||||
if _, err := d.dynamo.PutItem(input); err != nil {
|
||||
logger.WithError(err).Error("failed to put item")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Dynamo) UpdatePledge(patronID string, pledge *model.Pledge) error {
|
||||
logger := log.WithFields(log.Fields{
|
||||
"pledge_id": pledge.PledgeID,
|
||||
"user_id": patronID,
|
||||
})
|
||||
|
||||
logger.Infof("updating pledge (new amount: %d)", pledge.AmountCents)
|
||||
|
||||
builder := expr.
|
||||
Set(expr.Name("DeclinedSince"), expr.Value(pledge.DeclinedSince)).
|
||||
Set(expr.Name("AmountCents"), expr.Value(pledge.AmountCents)).
|
||||
Set(expr.Name("TotalHistoricalAmountCents"), expr.Value(pledge.TotalHistoricalAmountCents)).
|
||||
Set(expr.Name("OutstandingPaymentAmountCents"), expr.Value(pledge.OutstandingPaymentAmountCents)).
|
||||
Set(expr.Name("IsPaused"), expr.Value(pledge.IsPaused))
|
||||
|
||||
updateExpression, err := expr.NewBuilder().WithUpdate(builder).Build()
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to build update expression")
|
||||
return err
|
||||
}
|
||||
|
||||
input := &dynamodb.UpdateItemInput{
|
||||
TableName: d.PledgesTableName,
|
||||
Key: map[string]*dynamodb.AttributeValue{
|
||||
pledgesPrimaryKey: {N: aws.String(patronID)},
|
||||
},
|
||||
UpdateExpression: updateExpression.Update(),
|
||||
ExpressionAttributeNames: updateExpression.Names(),
|
||||
ExpressionAttributeValues: updateExpression.Values(),
|
||||
}
|
||||
|
||||
if _, err := d.dynamo.UpdateItem(input); err != nil {
|
||||
logger.WithError(err).Error("failed to update pledge")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Dynamo) DeletePledge(pledge *model.Pledge) error {
|
||||
logger := log.WithFields(log.Fields{
|
||||
"pledge_id": pledge.PledgeID,
|
||||
"user_id": pledge.PatronID,
|
||||
})
|
||||
|
||||
pk := strconv.FormatInt(pledge.PatronID, 10)
|
||||
logger.Infof("deleting pledge %s", pk)
|
||||
|
||||
input := &dynamodb.DeleteItemInput{
|
||||
TableName: d.PledgesTableName,
|
||||
Key: map[string]*dynamodb.AttributeValue{
|
||||
pledgesPrimaryKey: {N: aws.String(pk)},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := d.dynamo.DeleteItem(input); err != nil {
|
||||
logger.WithError(err).Error("failed to delete pledge")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Dynamo) GetPledge(patronID string) (*model.Pledge, error) {
|
||||
logger := log.WithField("user_id", patronID)
|
||||
|
||||
logger.Debug("getting pledge")
|
||||
|
||||
input := &dynamodb.GetItemInput{
|
||||
TableName: d.PledgesTableName,
|
||||
Key: map[string]*dynamodb.AttributeValue{
|
||||
pledgesPrimaryKey: {N: aws.String(patronID)},
|
||||
},
|
||||
}
|
||||
|
||||
output, err := d.dynamo.GetItem(input)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to get pledge item")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if output.Item == nil {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
|
||||
var pledge model.Pledge
|
||||
if err := attr.UnmarshalMap(output.Item, &pledge); err != nil {
|
||||
logger.WithError(err).Error("failed to unmarshal pledge item")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pledge, nil
|
||||
}
|
||||
|
||||
func (d Dynamo) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/dynamodb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
var feed = &model.Feed{
|
||||
HashID: "123",
|
||||
ItemID: "xyz",
|
||||
Provider: api.ProviderVimeo,
|
||||
LinkType: api.LinkTypeChannel,
|
||||
PageSize: 50,
|
||||
Quality: api.QualityHigh,
|
||||
Format: api.FormatVideo,
|
||||
}
|
||||
|
||||
func TestDynamo(t *testing.T) {
|
||||
runStorageTests(t, func(t *testing.T) storage {
|
||||
return createDynamo(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDynamo_UpdateFeed(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping storage test in short mode")
|
||||
}
|
||||
|
||||
client := createDynamo(t)
|
||||
defer client.Close()
|
||||
|
||||
err := client.SaveFeed(feed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = client.GetFeed(feed.HashID)
|
||||
assert.NoError(t, err, "can't read feed after save")
|
||||
|
||||
err = client.UpdateFeed(feed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = client.GetFeed(feed.HashID)
|
||||
assert.NoError(t, err, "can't read feed after update")
|
||||
}
|
||||
|
||||
// docker run -it --rm -p 8000:8000 amazon/dynamodb-local
|
||||
// noinspection ALL
|
||||
func createDynamo(t *testing.T) Dynamo {
|
||||
d, err := NewDynamo(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String("http://localhost:8000/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
d.dynamo.DeleteTable(&dynamodb.DeleteTableInput{TableName: d.PledgesTableName})
|
||||
d.dynamo.DeleteTable(&dynamodb.DeleteTableInput{TableName: d.FeedsTableName})
|
||||
|
||||
// Create Pledges table
|
||||
_, err = d.dynamo.CreateTable(&dynamodb.CreateTableInput{
|
||||
TableName: d.PledgesTableName,
|
||||
AttributeDefinitions: []*dynamodb.AttributeDefinition{
|
||||
{
|
||||
AttributeName: aws.String(pledgesPrimaryKey),
|
||||
AttributeType: aws.String("N"),
|
||||
},
|
||||
},
|
||||
KeySchema: []*dynamodb.KeySchemaElement{
|
||||
{
|
||||
AttributeName: aws.String(pledgesPrimaryKey),
|
||||
KeyType: aws.String("HASH"),
|
||||
},
|
||||
},
|
||||
ProvisionedThroughput: &dynamodb.ProvisionedThroughput{
|
||||
ReadCapacityUnits: aws.Int64(1),
|
||||
WriteCapacityUnits: aws.Int64(1),
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create Feeds table
|
||||
_, err = d.dynamo.CreateTable(&dynamodb.CreateTableInput{
|
||||
TableName: d.FeedsTableName,
|
||||
AttributeDefinitions: []*dynamodb.AttributeDefinition{
|
||||
{
|
||||
AttributeName: aws.String(feedsPrimaryKey),
|
||||
AttributeType: aws.String("S"),
|
||||
},
|
||||
{
|
||||
AttributeName: aws.String("UserID"),
|
||||
AttributeType: aws.String("S"),
|
||||
},
|
||||
{
|
||||
AttributeName: aws.String("CreatedAt"),
|
||||
AttributeType: aws.String("N"),
|
||||
},
|
||||
},
|
||||
KeySchema: []*dynamodb.KeySchemaElement{
|
||||
{
|
||||
AttributeName: aws.String(feedsPrimaryKey),
|
||||
KeyType: aws.String("HASH"),
|
||||
},
|
||||
},
|
||||
GlobalSecondaryIndexes: []*dynamodb.GlobalSecondaryIndex{
|
||||
{
|
||||
IndexName: feedDowngradeIndexName,
|
||||
KeySchema: []*dynamodb.KeySchemaElement{
|
||||
{
|
||||
AttributeName: aws.String("UserID"),
|
||||
KeyType: aws.String("HASH"),
|
||||
},
|
||||
{
|
||||
AttributeName: aws.String("CreatedAt"),
|
||||
KeyType: aws.String("RANGE"),
|
||||
},
|
||||
},
|
||||
Projection: &dynamodb.Projection{
|
||||
ProjectionType: aws.String("KEYS_ONLY"),
|
||||
},
|
||||
ProvisionedThroughput: &dynamodb.ProvisionedThroughput{
|
||||
ReadCapacityUnits: aws.Int64(1),
|
||||
WriteCapacityUnits: aws.Int64(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
ProvisionedThroughput: &dynamodb.ProvisionedThroughput{
|
||||
ReadCapacityUnits: aws.Int64(1),
|
||||
WriteCapacityUnits: aws.Int64(1),
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
err = d.dynamo.WaitUntilTableExists(&dynamodb.DescribeTableInput{TableName: d.PledgesTableName})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = d.dynamo.WaitUntilTableExists(&dynamodb.DescribeTableInput{TableName: d.FeedsTableName})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = d.dynamo.UpdateTimeToLive(&dynamodb.UpdateTimeToLiveInput{
|
||||
TableName: d.FeedsTableName,
|
||||
TimeToLiveSpecification: &dynamodb.TimeToLiveSpecification{
|
||||
AttributeName: feedTimeToLiveField,
|
||||
Enabled: aws.Bool(true),
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/proxy"
|
||||
"github.com/go-pg/pg"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
type Postgres struct {
|
||||
db *pg.DB
|
||||
}
|
||||
|
||||
func NewPG(connectionURL string, ping bool) (Postgres, error) {
|
||||
opts, err := pg.ParseURL(connectionURL)
|
||||
if err != nil {
|
||||
return Postgres{}, err
|
||||
}
|
||||
|
||||
// If host format is "projection:region:host", than use Google SQL Proxy
|
||||
// See https://github.com/go-pg/pg/issues/576
|
||||
if strings.Count(opts.Addr, ":") == 2 {
|
||||
log.Print("using GCP SQL proxy")
|
||||
opts.Dialer = func(network, addr string) (net.Conn, error) {
|
||||
return proxy.Dial(addr)
|
||||
}
|
||||
}
|
||||
|
||||
db := pg.Connect(opts)
|
||||
|
||||
// Check database connectivity
|
||||
if ping {
|
||||
if _, err := db.ExecOne("SELECT 1"); err != nil {
|
||||
_ = db.Close()
|
||||
return Postgres{}, errors.Wrap(err, "failed to check database connectivity")
|
||||
}
|
||||
}
|
||||
|
||||
return Postgres{db: db}, nil
|
||||
}
|
||||
|
||||
func (p Postgres) SaveFeed(feed *model.Feed) error {
|
||||
_, err := p.db.Model(feed).Insert()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to save feed to database")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p Postgres) GetFeed(hashID string) (*model.Feed, error) {
|
||||
lastAccess := time.Now().UTC()
|
||||
|
||||
feed := &model.Feed{}
|
||||
res, err := p.db.Model(feed).
|
||||
Set("last_access = ?", lastAccess).
|
||||
Where("hash_id = ?", hashID).
|
||||
Returning("*").
|
||||
Update()
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to query feed: %s", hashID)
|
||||
}
|
||||
|
||||
if res.RowsAffected() != 1 {
|
||||
return nil, api.ErrNotFound
|
||||
}
|
||||
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
func (p Postgres) GetMetadata(hashID string) (*model.Feed, error) {
|
||||
feed := &model.Feed{}
|
||||
err := p.db.
|
||||
Model(feed).
|
||||
Where("hash_id = ?", hashID).
|
||||
Column("provider", "format", "quality", "user_id").
|
||||
Select()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
func (p Postgres) Downgrade(patronID string, featureLevel int) error {
|
||||
if featureLevel > api.ExtendedFeatures {
|
||||
return nil
|
||||
}
|
||||
|
||||
if featureLevel == api.ExtendedFeatures {
|
||||
const maxPages = 150
|
||||
_, err := p.db.
|
||||
Model(&model.Feed{}).
|
||||
Set("page_size = ?", maxPages).
|
||||
Where("user_id = ? AND page_size > ?", patronID, maxPages).
|
||||
Update()
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to reduce page sizes for patron '%s'", patronID)
|
||||
}
|
||||
|
||||
_, err = p.db.
|
||||
Model(&model.Feed{}).
|
||||
Set("feature_level = ?", api.ExtendedFeatures).
|
||||
Where("user_id = ?", patronID, maxPages).
|
||||
Update()
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to downgrade patron '%s' to feature level %d", patronID, featureLevel)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if featureLevel == api.DefaultFeatures {
|
||||
_, err := p.db.
|
||||
Model(&model.Feed{}).
|
||||
Set("page_size = ?", 50).
|
||||
Set("feature_level = ?", api.DefaultFeatures).
|
||||
Set("format = ?", api.FormatVideo).
|
||||
Set("quality = ?", api.QualityHigh).
|
||||
Where("user_id = ?", patronID).
|
||||
Update()
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to downgrade patron '%s' to feature level %d", patronID, featureLevel)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("unsupported downgrade type")
|
||||
}
|
||||
|
||||
func (p Postgres) AddPledge(pledge *model.Pledge) error {
|
||||
return p.db.Insert(pledge)
|
||||
}
|
||||
|
||||
func (p Postgres) UpdatePledge(patronID string, pledge *model.Pledge) error {
|
||||
updateColumns := []string{
|
||||
"declined_since",
|
||||
"amount_cents",
|
||||
"total_historical_amount_cents",
|
||||
"outstanding_payment_amount_cents",
|
||||
"is_paused",
|
||||
}
|
||||
|
||||
res, err := p.db.Model(pledge).Column(updateColumns...).Where("patron_id = ?", patronID).Update()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to update pledge %d for user %s: %v", pledge.PledgeID, patronID, err)
|
||||
}
|
||||
|
||||
if res.RowsAffected() != 1 {
|
||||
return errors.Wrapf(err, "unexpected number of updated rows: %d for user %s", res.RowsAffected(), patronID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Postgres) DeletePledge(pledge *model.Pledge) error {
|
||||
err := p.db.Delete(pledge)
|
||||
if err == pg.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (p Postgres) GetPledge(patronID string) (*model.Pledge, error) {
|
||||
pledge := &model.Pledge{}
|
||||
err := p.db.Model(pledge).Where("patron_id = ?", patronID).Limit(1).Select()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pledge, nil
|
||||
}
|
||||
|
||||
func (p Postgres) GetAllPledges() (list []*model.Pledge, err error) {
|
||||
err = p.db.Model(&list).Select()
|
||||
return
|
||||
}
|
||||
|
||||
func (p Postgres) GetAllFeeds() (list []*model.Feed, err error) {
|
||||
err = p.db.Model(&list).Select()
|
||||
return
|
||||
}
|
||||
|
||||
func (p Postgres) Close() error {
|
||||
return p.db.Close()
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package storage
|
||||
|
||||
//noinspection SpellCheckingInspection
|
||||
const pgsql = `
|
||||
BEGIN;
|
||||
|
||||
-- Pledges
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pledges (
|
||||
pledge_id BIGSERIAL PRIMARY KEY,
|
||||
patron_id BIGINT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
declined_since TIMESTAMPTZ NULL,
|
||||
amount_cents INT NOT NULL,
|
||||
total_historical_amount_cents INT,
|
||||
outstanding_payment_amount_cents INT,
|
||||
is_paused BOOLEAN
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS patron_id_idx ON pledges(patron_id);
|
||||
|
||||
-- Feeds
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'provider') THEN
|
||||
CREATE TYPE provider AS ENUM ('youtube', 'vimeo');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'link_type') THEN
|
||||
CREATE TYPE link_type AS ENUM ('channel', 'playlist', 'user', 'group');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'quality') THEN
|
||||
CREATE TYPE quality AS ENUM ('low', 'high');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'format') THEN
|
||||
CREATE TYPE format AS ENUM ('video', 'audio');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feeds (
|
||||
feed_id BIGSERIAL PRIMARY KEY,
|
||||
hash_id VARCHAR(12) NOT NULL UNIQUE,
|
||||
user_id VARCHAR(32) NULL,
|
||||
item_id VARCHAR(64) NOT NULL CHECK (item_id <> ''),
|
||||
provider provider NOT NULL,
|
||||
link_type link_type NOT NULL,
|
||||
page_size INT NOT NULL DEFAULT 50,
|
||||
format format NOT NULL DEFAULT 'video',
|
||||
quality quality NOT NULL DEFAULT 'high',
|
||||
feature_level INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_access TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS feeds_hash_id_idx ON feeds(hash_id);
|
||||
CREATE INDEX IF NOT EXISTS feeds_user_id_idx ON feeds(user_id);
|
||||
|
||||
COMMIT;
|
||||
END;
|
||||
`
|
||||
@@ -1,55 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
func TestPostgres_UpdateLastAccess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping storage test in short mode")
|
||||
}
|
||||
|
||||
stor := createPG(t)
|
||||
defer func() { _ = stor.Close() }()
|
||||
|
||||
err := stor.db.Insert(testFeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
feed1, err := stor.GetFeed(testFeed.HashID)
|
||||
require.NoError(t, err)
|
||||
|
||||
feed2, err := stor.GetFeed(testFeed.HashID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, feed2.LastAccess.After(feed1.LastAccess))
|
||||
}
|
||||
|
||||
func TestPostgres(t *testing.T) {
|
||||
runStorageTests(t, func(t *testing.T) storage {
|
||||
t.Skip()
|
||||
return nil
|
||||
//return createPG(t)
|
||||
})
|
||||
}
|
||||
|
||||
// docker run -it --rm -p 5432:5432 -e POSTGRES_DB=podsync postgres
|
||||
func createPG(t *testing.T) Postgres {
|
||||
const localConnectionString = "postgres://postgres:@localhost/podsync?sslmode=disable"
|
||||
|
||||
postgres, err := NewPG(localConnectionString, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = postgres.db.Exec(pgsql)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range []interface{}{&model.Pledge{}, &model.Feed{}} {
|
||||
_, err = postgres.db.Model(obj).Where("1=1").Delete()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return postgres
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
type storage interface {
|
||||
SaveFeed(feed *model.Feed) error
|
||||
GetFeed(hashID string) (*model.Feed, error)
|
||||
GetMetadata(hashID string) (*model.Feed, error)
|
||||
Downgrade(userID string, featureLevel int) ([]string, error)
|
||||
|
||||
// Patreon pledges
|
||||
AddPledge(pledge *model.Pledge) error
|
||||
UpdatePledge(patronID string, pledge *model.Pledge) error
|
||||
DeletePledge(pledge *model.Pledge) error
|
||||
GetPledge(patronID string) (*model.Pledge, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
var (
|
||||
testPledge = &model.Pledge{
|
||||
PledgeID: 12345,
|
||||
AmountCents: 400,
|
||||
PatronID: 1,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
TotalHistoricalAmountCents: 100,
|
||||
OutstandingPaymentAmountCents: 100,
|
||||
IsPaused: true,
|
||||
}
|
||||
|
||||
testFeed = &model.Feed{
|
||||
FeedID: 1,
|
||||
HashID: "3",
|
||||
UserID: "4",
|
||||
ItemID: "5",
|
||||
LinkType: api.LinkTypeChannel,
|
||||
Provider: api.ProviderVimeo,
|
||||
Format: api.FormatAudio,
|
||||
Quality: api.QualityLow,
|
||||
PageSize: 150,
|
||||
FeatureLevel: api.ExtendedFeatures,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
LastAccess: time.Now().UTC(),
|
||||
}
|
||||
|
||||
testDowngradePledge = &model.Feed{
|
||||
HashID: "123456",
|
||||
UserID: "123456",
|
||||
ItemID: "123456",
|
||||
Provider: api.ProviderVimeo,
|
||||
LinkType: api.LinkTypeGroup,
|
||||
PageSize: 200,
|
||||
Quality: api.QualityLow,
|
||||
Format: api.FormatAudio,
|
||||
FeatureLevel: api.ExtendedFeatures,
|
||||
}
|
||||
)
|
||||
|
||||
func runStorageTests(t *testing.T, createFn func(t *testing.T) storage) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping storage test in short mode")
|
||||
}
|
||||
|
||||
// Feeds
|
||||
t.Run("SaveFeed", makeTest(createFn, testSaveFeed))
|
||||
t.Run("LastAccess", makeTest(createFn, testLastAccess))
|
||||
t.Run("GetMetadata", makeTest(createFn, testGetMetadata))
|
||||
t.Run("Downgrade", func(t *testing.T) {
|
||||
t.Run("DefaultFeatures", makeTest(createFn, testDowngradeToDefaultFeatures))
|
||||
t.Run("ExtendedFeatures", makeTest(createFn, testDowngradeToExtendedFeatures))
|
||||
})
|
||||
|
||||
// Pledge tests
|
||||
t.Run("AddPledge", makeTest(createFn, testAddPledge))
|
||||
t.Run("GetPledge", makeTest(createFn, testGetPledge))
|
||||
t.Run("DeletePledge", makeTest(createFn, testDeletePledge))
|
||||
t.Run("UpdatePledge", makeTest(createFn, testUpdatePledge))
|
||||
}
|
||||
|
||||
func makeTest(createFn func(t *testing.T) storage, testFn func(t *testing.T, storage storage)) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
storage := createFn(t)
|
||||
|
||||
testFn(t, storage)
|
||||
|
||||
err := storage.Close()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testSaveFeed(t *testing.T, storage storage) {
|
||||
err := storage.SaveFeed(testFeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
find, err := storage.GetFeed(testFeed.HashID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, testFeed.HashID, find.HashID)
|
||||
require.Equal(t, testFeed.UserID, find.UserID)
|
||||
require.Equal(t, testFeed.ItemID, find.ItemID)
|
||||
require.Equal(t, testFeed.LinkType, find.LinkType)
|
||||
require.Equal(t, testFeed.Provider, find.Provider)
|
||||
}
|
||||
|
||||
func testGetMetadata(t *testing.T, storage storage) {
|
||||
err := storage.SaveFeed(testFeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
find, err := storage.GetMetadata(testFeed.HashID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, testFeed.UserID, find.UserID)
|
||||
require.Equal(t, testFeed.Provider, find.Provider)
|
||||
require.Equal(t, testFeed.Quality, find.Quality)
|
||||
require.Equal(t, testFeed.Format, find.Format)
|
||||
|
||||
require.Equal(t, 0, find.PageSize)
|
||||
require.Equal(t, time.Time{}.Unix(), find.CreatedAt.Unix())
|
||||
require.Equal(t, time.Time{}.Unix(), find.LastAccess.Unix())
|
||||
require.Equal(t, 0, find.FeatureLevel)
|
||||
}
|
||||
|
||||
func testDowngradeToDefaultFeatures(t *testing.T, storage storage) {
|
||||
err := storage.SaveFeed(testDowngradePledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = storage.Downgrade(testDowngradePledge.UserID, api.DefaultFeatures)
|
||||
require.NoError(t, err)
|
||||
|
||||
downgraded, err := storage.GetFeed(testDowngradePledge.HashID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 50, downgraded.PageSize)
|
||||
require.Equal(t, api.QualityHigh, downgraded.Quality)
|
||||
require.Equal(t, api.FormatVideo, downgraded.Format)
|
||||
require.Equal(t, api.DefaultFeatures, downgraded.FeatureLevel)
|
||||
}
|
||||
|
||||
func testDowngradeToExtendedFeatures(t *testing.T, storage storage) {
|
||||
err := storage.SaveFeed(testDowngradePledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = storage.Downgrade(testDowngradePledge.UserID, api.ExtendedFeatures)
|
||||
require.NoError(t, err)
|
||||
|
||||
downgraded, err := storage.GetFeed(testDowngradePledge.HashID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 150, downgraded.PageSize)
|
||||
require.Equal(t, testDowngradePledge.Quality, downgraded.Quality)
|
||||
require.Equal(t, testDowngradePledge.Format, downgraded.Format)
|
||||
require.Equal(t, api.ExtendedFeatures, downgraded.FeatureLevel)
|
||||
}
|
||||
|
||||
func testLastAccess(t *testing.T, storage storage) {
|
||||
date := time.Now().AddDate(-1, 0, 0).UTC()
|
||||
|
||||
feed := &model.Feed{
|
||||
FeedID: 1,
|
||||
HashID: "3",
|
||||
UserID: "4",
|
||||
ItemID: "5",
|
||||
LinkType: api.LinkTypeChannel,
|
||||
Provider: api.ProviderVimeo,
|
||||
Format: api.FormatAudio,
|
||||
Quality: api.QualityLow,
|
||||
PageSize: 150,
|
||||
FeatureLevel: api.ExtendedFeatures,
|
||||
CreatedAt: date,
|
||||
LastAccess: date,
|
||||
}
|
||||
|
||||
err := storage.SaveFeed(feed)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := storage.GetFeed(feed.HashID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, result.LastAccess.Sub(time.Now().UTC()) < 2*time.Second)
|
||||
}
|
||||
|
||||
func testAddPledge(t *testing.T, storage storage) {
|
||||
err := storage.AddPledge(testPledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
pledge, err := storage.GetPledge(strconv.FormatInt(testPledge.PatronID, 10))
|
||||
require.NoError(t, err)
|
||||
|
||||
compareWithTestPledge(t, pledge)
|
||||
}
|
||||
|
||||
func testGetPledge(t *testing.T, storage storage) {
|
||||
err := storage.AddPledge(testPledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
pledge, err := storage.GetPledge(strconv.FormatInt(testPledge.PatronID, 10))
|
||||
require.NoError(t, err)
|
||||
|
||||
compareWithTestPledge(t, pledge)
|
||||
}
|
||||
|
||||
func compareWithTestPledge(t *testing.T, pledge *model.Pledge) {
|
||||
require.Equal(t, testPledge.PledgeID, pledge.PledgeID)
|
||||
require.Equal(t, testPledge.PatronID, pledge.PatronID)
|
||||
require.Equal(t, testPledge.CreatedAt.Unix(), pledge.CreatedAt.Unix())
|
||||
require.Equal(t, testPledge.DeclinedSince.Unix(), pledge.DeclinedSince.Unix())
|
||||
require.Equal(t, testPledge.AmountCents, pledge.AmountCents)
|
||||
require.Equal(t, testPledge.TotalHistoricalAmountCents, pledge.TotalHistoricalAmountCents)
|
||||
require.Equal(t, testPledge.OutstandingPaymentAmountCents, pledge.OutstandingPaymentAmountCents)
|
||||
require.Equal(t, testPledge.IsPaused, pledge.IsPaused)
|
||||
}
|
||||
|
||||
func testDeletePledge(t *testing.T, storage storage) {
|
||||
err := storage.AddPledge(testPledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = storage.DeletePledge(testPledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
pledge, err := storage.GetPledge(strconv.FormatInt(testPledge.PatronID, 10))
|
||||
require.Error(t, err)
|
||||
require.Nil(t, pledge)
|
||||
}
|
||||
|
||||
func testUpdatePledge(t *testing.T, storage storage) {
|
||||
err := storage.AddPledge(testPledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
err = storage.UpdatePledge(strconv.FormatInt(testPledge.PatronID, 10), &model.Pledge{
|
||||
DeclinedSince: now,
|
||||
AmountCents: 400,
|
||||
TotalHistoricalAmountCents: 800,
|
||||
OutstandingPaymentAmountCents: 900,
|
||||
IsPaused: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
pledge, err := storage.GetPledge("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, testPledge.PledgeID, pledge.PledgeID)
|
||||
require.Equal(t, testPledge.PatronID, pledge.PatronID)
|
||||
require.Equal(t, testPledge.CreatedAt.Unix(), pledge.CreatedAt.Unix())
|
||||
require.Equal(t, now.Unix(), pledge.DeclinedSince.Unix())
|
||||
require.Equal(t, 400, pledge.AmountCents)
|
||||
require.Equal(t, 800, pledge.TotalHistoricalAmountCents)
|
||||
require.Equal(t, 900, pledge.OutstandingPaymentAmountCents)
|
||||
require.Equal(t, true, pledge.IsPaused)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package support
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
patreon "github.com/mxpv/patreon-go"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
creatorID = "2822191"
|
||||
)
|
||||
|
||||
type storage interface {
|
||||
AddPledge(pledge *model.Pledge) error
|
||||
UpdatePledge(patronID string, pledge *model.Pledge) error
|
||||
DeletePledge(pledge *model.Pledge) error
|
||||
GetPledge(patronID string) (*model.Pledge, error)
|
||||
}
|
||||
|
||||
type Patreon struct {
|
||||
db storage
|
||||
}
|
||||
|
||||
func ToModel(pledge *patreon.Pledge) (*model.Pledge, error) {
|
||||
pledgeID, err := strconv.ParseInt(pledge.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to parse pledge id: %s", pledge.ID)
|
||||
}
|
||||
|
||||
patronID, err := strconv.ParseInt(pledge.Relationships.Patron.Data.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to parse patron id: %s", pledge.Relationships.Patron.Data.ID)
|
||||
}
|
||||
|
||||
m := &model.Pledge{
|
||||
PledgeID: pledgeID,
|
||||
PatronID: patronID,
|
||||
AmountCents: pledge.Attributes.AmountCents,
|
||||
}
|
||||
|
||||
if pledge.Attributes.CreatedAt.Valid {
|
||||
m.CreatedAt = pledge.Attributes.CreatedAt.Time
|
||||
}
|
||||
|
||||
if pledge.Attributes.DeclinedSince.Valid {
|
||||
m.DeclinedSince = pledge.Attributes.DeclinedSince.Time
|
||||
}
|
||||
|
||||
// Read optional fields
|
||||
|
||||
if pledge.Attributes.TotalHistoricalAmountCents != nil {
|
||||
m.TotalHistoricalAmountCents = *pledge.Attributes.TotalHistoricalAmountCents
|
||||
}
|
||||
|
||||
if pledge.Attributes.OutstandingPaymentAmountCents != nil {
|
||||
m.OutstandingPaymentAmountCents = *pledge.Attributes.OutstandingPaymentAmountCents
|
||||
}
|
||||
|
||||
if pledge.Attributes.IsPaused != nil {
|
||||
m.IsPaused = *pledge.Attributes.IsPaused
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (h Patreon) Hook(pledge *patreon.Pledge, event string) error {
|
||||
logger := log.WithFields(log.Fields{
|
||||
"module": "hook",
|
||||
"pledge_id": pledge.ID,
|
||||
"pledge_event": event,
|
||||
})
|
||||
|
||||
obj, err := ToModel(pledge)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("failed to convert pledge to model")
|
||||
return err
|
||||
}
|
||||
|
||||
switch event {
|
||||
case patreon.EventCreatePledge:
|
||||
return h.db.AddPledge(obj)
|
||||
case patreon.EventUpdatePledge:
|
||||
// Update comes with different PledgeID from Patreon, so do update by user ID
|
||||
patronID := pledge.Relationships.Patron.Data.ID
|
||||
if err := h.db.UpdatePledge(patronID, obj); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
case patreon.EventDeletePledge:
|
||||
return h.db.DeletePledge(obj)
|
||||
default:
|
||||
return fmt.Errorf("unknown event: %s", event)
|
||||
}
|
||||
}
|
||||
|
||||
func (h Patreon) FindPledge(patronID string) (*model.Pledge, error) {
|
||||
return h.db.GetPledge(patronID)
|
||||
}
|
||||
|
||||
func (h Patreon) GetFeatureLevelByID(patronID string) (level int) {
|
||||
level = api.DefaultFeatures
|
||||
|
||||
if patronID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if patronID == creatorID {
|
||||
level = api.PodcasterFeatures
|
||||
return
|
||||
}
|
||||
|
||||
pledge, err := h.FindPledge(patronID)
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("user_id", patronID).Error("can't find pledge for user")
|
||||
return
|
||||
}
|
||||
|
||||
// Check pledge is valid
|
||||
if pledge.DeclinedSince.IsZero() && !pledge.IsPaused {
|
||||
level = h.GetFeatureLevelFromAmount(pledge.AmountCents)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (h Patreon) GetFeatureLevelFromAmount(amount int) int {
|
||||
// Check the amount of pledge
|
||||
if amount >= 300 {
|
||||
return api.ExtendedPagination
|
||||
}
|
||||
|
||||
if amount >= 100 {
|
||||
return api.ExtendedFeatures
|
||||
}
|
||||
|
||||
return api.DefaultFeatures
|
||||
}
|
||||
|
||||
func NewPatreon(db storage) *Patreon {
|
||||
return &Patreon{db: db}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: patreon.go
|
||||
|
||||
// Package support is a generated GoMock package.
|
||||
package support
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
model "github.com/mxpv/podsync/pkg/model"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// Mockstorage is a mock of storage interface
|
||||
type Mockstorage struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockstorageMockRecorder
|
||||
}
|
||||
|
||||
// MockstorageMockRecorder is the mock recorder for Mockstorage
|
||||
type MockstorageMockRecorder struct {
|
||||
mock *Mockstorage
|
||||
}
|
||||
|
||||
// NewMockstorage creates a new mock instance
|
||||
func NewMockstorage(ctrl *gomock.Controller) *Mockstorage {
|
||||
mock := &Mockstorage{ctrl: ctrl}
|
||||
mock.recorder = &MockstorageMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *Mockstorage) EXPECT() *MockstorageMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// AddPledge mocks base method
|
||||
func (m *Mockstorage) AddPledge(pledge *model.Pledge) error {
|
||||
ret := m.ctrl.Call(m, "AddPledge", pledge)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// AddPledge indicates an expected call of AddPledge
|
||||
func (mr *MockstorageMockRecorder) AddPledge(pledge interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPledge", reflect.TypeOf((*Mockstorage)(nil).AddPledge), pledge)
|
||||
}
|
||||
|
||||
// UpdatePledge mocks base method
|
||||
func (m *Mockstorage) UpdatePledge(patronID string, pledge *model.Pledge) error {
|
||||
ret := m.ctrl.Call(m, "UpdatePledge", patronID, pledge)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpdatePledge indicates an expected call of UpdatePledge
|
||||
func (mr *MockstorageMockRecorder) UpdatePledge(patronID, pledge interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePledge", reflect.TypeOf((*Mockstorage)(nil).UpdatePledge), patronID, pledge)
|
||||
}
|
||||
|
||||
// DeletePledge mocks base method
|
||||
func (m *Mockstorage) DeletePledge(pledge *model.Pledge) error {
|
||||
ret := m.ctrl.Call(m, "DeletePledge", pledge)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeletePledge indicates an expected call of DeletePledge
|
||||
func (mr *MockstorageMockRecorder) DeletePledge(pledge interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePledge", reflect.TypeOf((*Mockstorage)(nil).DeletePledge), pledge)
|
||||
}
|
||||
|
||||
// GetPledge mocks base method
|
||||
func (m *Mockstorage) GetPledge(patronID string) (*model.Pledge, error) {
|
||||
ret := m.ctrl.Call(m, "GetPledge", patronID)
|
||||
ret0, _ := ret[0].(*model.Pledge)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPledge indicates an expected call of GetPledge
|
||||
func (mr *MockstorageMockRecorder) GetPledge(patronID interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPledge", reflect.TypeOf((*Mockstorage)(nil).GetPledge), patronID)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//go:generate mockgen -source=patreon.go -destination=patreon_mock_test.go -package=support
|
||||
|
||||
package support
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
patreon "github.com/mxpv/patreon-go"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/model"
|
||||
)
|
||||
|
||||
func TestToModel(t *testing.T) {
|
||||
pledge := createPledge()
|
||||
|
||||
modelPledge, err := ToModel(pledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, modelPledge.PledgeID, int64(12345))
|
||||
require.Equal(t, modelPledge.AmountCents, 400)
|
||||
require.Equal(t, modelPledge.PatronID, int64(67890))
|
||||
require.NotNil(t, modelPledge.CreatedAt)
|
||||
}
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
pledge := createPledge()
|
||||
expected, _ := ToModel(pledge)
|
||||
|
||||
storage := NewMockstorage(ctrl)
|
||||
storage.EXPECT().AddPledge(gomock.Eq(expected)).Times(1).Return(nil)
|
||||
|
||||
hook := Patreon{db: storage}
|
||||
|
||||
err := hook.Hook(pledge, patreon.EventCreatePledge)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
pledge := createPledge()
|
||||
expected, _ := ToModel(pledge)
|
||||
|
||||
storage := NewMockstorage(ctrl)
|
||||
storage.EXPECT().UpdatePledge("67890", gomock.Eq(expected))
|
||||
|
||||
hook := Patreon{db: storage}
|
||||
err := hook.Hook(pledge, patreon.EventUpdatePledge)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
pledge := createPledge()
|
||||
expected, _ := ToModel(pledge)
|
||||
|
||||
storage := NewMockstorage(ctrl)
|
||||
storage.EXPECT().DeletePledge(expected)
|
||||
|
||||
hook := Patreon{db: storage}
|
||||
err := hook.Hook(pledge, patreon.EventDeletePledge)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestFindPledge(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
expected := &model.Pledge{}
|
||||
|
||||
storage := NewMockstorage(ctrl)
|
||||
storage.EXPECT().GetPledge("123").Times(1).Return(expected, nil)
|
||||
|
||||
hook := Patreon{db: storage}
|
||||
res, err := hook.FindPledge("123")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, res)
|
||||
}
|
||||
|
||||
func TestGetFeatureLevel(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
pledge := createPledge()
|
||||
storage := NewMockstorage(ctrl)
|
||||
|
||||
ret, err := ToModel(pledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
storage.EXPECT().GetPledge(pledge.Relationships.Patron.Data.ID).Return(ret, nil)
|
||||
storage.EXPECT().GetPledge("xyz").Return(nil, errors.New("not found"))
|
||||
|
||||
hook := Patreon{db: storage}
|
||||
|
||||
require.Equal(t, api.PodcasterFeatures, hook.GetFeatureLevelByID(creatorID))
|
||||
require.Equal(t, api.DefaultFeatures, hook.GetFeatureLevelByID("xyz"))
|
||||
require.Equal(t, api.ExtendedPagination, hook.GetFeatureLevelByID(pledge.Relationships.Patron.Data.ID))
|
||||
}
|
||||
|
||||
func createPledge() *patreon.Pledge {
|
||||
pledge := &patreon.Pledge{
|
||||
ID: "12345",
|
||||
Type: "pledge",
|
||||
}
|
||||
|
||||
pledge.Attributes.AmountCents = 400
|
||||
pledge.Attributes.CreatedAt = patreon.NullTime{Valid: true, Time: time.Now().UTC()}
|
||||
|
||||
pledge.Relationships.Patron = &patreon.PatronRelationship{}
|
||||
pledge.Relationships.Patron.Data.ID = "67890"
|
||||
|
||||
return pledge
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,46 +0,0 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
# Only exists if Bazel was run
|
||||
/bazel-out
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# profiling files
|
||||
chrome-profiler-events.json
|
||||
speed-measure-plugin.json
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,27 +0,0 @@
|
||||
# Ui
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.0.1.
|
||||
|
||||
## Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"ui": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/ui",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"aot": true,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "ui:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "ui:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "ui:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": [
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.spec.json",
|
||||
"e2e/tsconfig.json"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "ui:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "ui:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}},
|
||||
"defaultProject": "ui"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
|
||||
# You can see what browsers were selected by your queries by running:
|
||||
# npx browserslist
|
||||
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
not IE 9-11 # For IE 9-11 support, remove 'not'.
|
||||
@@ -1,32 +0,0 @@
|
||||
// @ts-check
|
||||
// Protractor configuration file, see link for more information
|
||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||
|
||||
const { SpecReporter } = require('jasmine-spec-reporter');
|
||||
|
||||
/**
|
||||
* @type { import("protractor").Config }
|
||||
*/
|
||||
exports.config = {
|
||||
allScriptsTimeout: 11000,
|
||||
specs: [
|
||||
'./src/**/*.e2e-spec.ts'
|
||||
],
|
||||
capabilities: {
|
||||
'browserName': 'chrome'
|
||||
},
|
||||
directConnect: true,
|
||||
baseUrl: 'http://localhost:4200/',
|
||||
framework: 'jasmine',
|
||||
jasmineNodeOpts: {
|
||||
showColors: true,
|
||||
defaultTimeoutInterval: 30000,
|
||||
print: function() {}
|
||||
},
|
||||
onPrepare() {
|
||||
require('ts-node').register({
|
||||
project: require('path').join(__dirname, './tsconfig.json')
|
||||
});
|
||||
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
|
||||
}
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { AppPage } from './app.po';
|
||||
import { browser, logging } from 'protractor';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getTitleText()).toEqual('Welcome to ui!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Assert that there are no errors emitted from the browser
|
||||
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
|
||||
expect(logs).not.toContain(jasmine.objectContaining({
|
||||
level: logging.Level.SEVERE,
|
||||
} as logging.Entry));
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo() {
|
||||
return browser.get(browser.baseUrl) as Promise<any>;
|
||||
}
|
||||
|
||||
getTitleText() {
|
||||
return element(by.css('app-root h1')).getText() as Promise<string>;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage-istanbul-reporter'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
coverageIstanbulReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/ui'),
|
||||
reports: ['html', 'lcovonly', 'text-summary'],
|
||||
fixWebpackSourcePaths: true
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "~8.0.0",
|
||||
"@angular/common": "~8.0.0",
|
||||
"@angular/compiler": "~8.0.0",
|
||||
"@angular/core": "~8.0.0",
|
||||
"@angular/forms": "~8.0.0",
|
||||
"@angular/platform-browser": "~8.0.0",
|
||||
"@angular/platform-browser-dynamic": "~8.0.0",
|
||||
"@angular/router": "~8.0.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
"rxjs": "~6.4.0",
|
||||
"toppy": "^2.3.3",
|
||||
"tslib": "^1.9.0",
|
||||
"zone.js": "~0.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "~0.800.0",
|
||||
"@angular/cli": "~8.0.1",
|
||||
"@angular/compiler-cli": "~8.0.0",
|
||||
"@angular/language-service": "~8.0.0",
|
||||
"@types/node": "~8.9.4",
|
||||
"@types/jasmine": "~3.3.8",
|
||||
"@types/jasminewd2": "~2.0.3",
|
||||
"codelyzer": "^5.0.0",
|
||||
"jasmine-core": "~3.4.0",
|
||||
"jasmine-spec-reporter": "~4.2.1",
|
||||
"karma": "~4.1.0",
|
||||
"karma-chrome-launcher": "~2.2.0",
|
||||
"karma-coverage-istanbul-reporter": "~2.0.1",
|
||||
"karma-jasmine": "~2.0.1",
|
||||
"karma-jasmine-html-reporter": "^1.4.0",
|
||||
"protractor": "~5.4.0",
|
||||
"ts-node": "~7.0.0",
|
||||
"tslint": "~5.15.0",
|
||||
"typescript": "~3.4.3"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APIService } from './api.service';
|
||||
|
||||
describe('APIService', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({}));
|
||||
|
||||
it('should be created', () => {
|
||||
const service: APIService = TestBed.get(APIService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {retry} from 'rxjs/operators';
|
||||
import {Observable} from 'rxjs';
|
||||
|
||||
export interface CreateRequest {
|
||||
url: string;
|
||||
format: string;
|
||||
quality: string;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface CreateResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
user_id: string;
|
||||
feature_level: number;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class APIService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
createFeed(request: CreateRequest): Observable<CreateResponse> {
|
||||
return this.http.post<CreateResponse>('/api/create', request);
|
||||
}
|
||||
|
||||
getUser(): Observable<UserResponse> {
|
||||
return this.http.get<UserResponse>('/api/user')
|
||||
.pipe(
|
||||
retry(3)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import {IndexComponent} from './index/index.component';
|
||||
import {FaqComponent} from './faq/faq.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: IndexComponent,
|
||||
},
|
||||
{
|
||||
path: 'faq.html',
|
||||
component: FaqComponent,
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -1,3 +0,0 @@
|
||||
<div class="background-image">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
.background-image {
|
||||
background-image: url('/assets/img/pc_bg.png');
|
||||
-ms-background-repeat: repeat-x;
|
||||
background-repeat: repeat-x;
|
||||
-ms-background-position: center bottom;
|
||||
background-position: center bottom;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-user-select: text;
|
||||
z-index: 0;
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
background-image: url('/assets/img/mobile_bg.png')
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { TestBed, async } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have as title 'ui'`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app.title).toEqual('ui');
|
||||
});
|
||||
|
||||
it('should render title in a h1 tag', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.debugElement.nativeElement;
|
||||
expect(compiled.querySelector('h1').textContent).toContain('Welcome to ui!');
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {Meta, Title} from '@angular/platform-browser';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor(private titleService: Title, metaService: Meta) {
|
||||
titleService.setTitle('Podsync - Turn YouTube channels into podcast feeds');
|
||||
|
||||
metaService.addTag({
|
||||
httpEquiv: 'content-type',
|
||||
content: 'text/html;charset=UTF-8',
|
||||
});
|
||||
|
||||
metaService.addTag({
|
||||
name: 'description',
|
||||
content: 'Simple and free service that lets you listen to any YouTube or Vimeo channels, playlists or user videos in podcast format',
|
||||
});
|
||||
|
||||
metaService.addTag({
|
||||
name: 'og:title',
|
||||
content: 'Podsync - turn YouTube channels into podcast feeds',
|
||||
});
|
||||
|
||||
metaService.addTag({
|
||||
name: 'og:description',
|
||||
content: 'Simple and free service that lets you listen to any YouTube or Vimeo channels, playlists or user videos in podcast format',
|
||||
});
|
||||
|
||||
metaService.addTag({
|
||||
name: 'og:locale',
|
||||
content: 'en_US',
|
||||
});
|
||||
|
||||
metaService.addTag({
|
||||
name: 'og:image',
|
||||
content: '/assets/img/og_image.png',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { IndexComponent } from './index/index.component';
|
||||
import { InputComponent } from './input/input.component';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {HttpClientModule} from '@angular/common/http';
|
||||
import { OutputComponent } from './output/output.component';
|
||||
import { FaqComponent } from './faq/faq.component';
|
||||
import { FooterComponent } from './widgets/footer/footer.component';
|
||||
import {ToppyModule} from 'toppy';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
IndexComponent,
|
||||
InputComponent,
|
||||
OutputComponent,
|
||||
FaqComponent,
|
||||
FooterComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
HttpClientModule,
|
||||
FormsModule,
|
||||
ToppyModule,
|
||||
AppRoutingModule
|
||||
],
|
||||
entryComponents: [
|
||||
OutputComponent,
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -1,75 +0,0 @@
|
||||
<div class="pad10">
|
||||
<div class="pad10 border">
|
||||
<h1>FAQ</h1>
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
<h3>
|
||||
Can I delete my feed?
|
||||
</h3>
|
||||
<p>
|
||||
Not manually. Feeds are automatically deleted if not used for more than 90 days.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>
|
||||
I am trying to turn the link below into a podcast form (to listen to on my phone) and it doesn't seem to be
|
||||
working?
|
||||
https://www.youtube.com/watch?v=XXxxxXXxXxX
|
||||
</h3>
|
||||
<p>
|
||||
Podsync can't build a feed from just one video. You have to provide a link either to a channel, user, or
|
||||
playlist.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>
|
||||
Audio podcast is double the length of the video, and the second half is silent
|
||||
</h3>
|
||||
<p>
|
||||
This is known issue. Unfortunately as of now I don't have any fix for this.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>
|
||||
I am trying to download some files, but it is giving the following error "Download failed, error code 403
|
||||
forbidden".
|
||||
Is there a solution?
|
||||
</h3>
|
||||
<p>
|
||||
That happens because you're in a different region than Podsync.
|
||||
More details on this <a href="https://www.patreon.com/posts/youtube-playlist-7874992">here</a>.
|
||||
I've been working on multi-region support, fix is coming soon.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>
|
||||
Does feeds auto-update as new videos show up on YouTube?
|
||||
Or do they just represent a snapshot of whenever you generated them?
|
||||
</h3>
|
||||
<p>
|
||||
Feeds are cached for 10 minutes to reduce server loads.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>
|
||||
Is it possible to get a podcast of all videos in "watch later" on YouTube?
|
||||
</h3>
|
||||
<p>
|
||||
No, that would require "login with your YouTube account" on Podsync.
|
||||
Instead you might want to create custom playlist and add there videos you want to watch.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-footer class="bottom-links">
|
||||
<a routerLink="/"><i class="fa fa-home" aria-hidden="true"></i></a>
|
||||
<a href="https://twitter.com/pod_sync"><i aria-hidden="true" class="fa fa-twitter"></i></a>
|
||||
</app-footer>
|
||||
@@ -1,5 +0,0 @@
|
||||
|
||||
.border {
|
||||
border: thin solid #a39c8e;
|
||||
background: #f4ecda;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FaqComponent } from './faq.component';
|
||||
|
||||
describe('FaqComponent', () => {
|
||||
let component: FaqComponent;
|
||||
let fixture: ComponentFixture<FaqComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ FaqComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FaqComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-faq',
|
||||
templateUrl: './faq.component.html',
|
||||
styleUrls: ['./faq.component.scss']
|
||||
})
|
||||
export class FaqComponent implements OnInit {
|
||||
constructor() {}
|
||||
ngOnInit() {}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<div class="title">
|
||||
<h1>Podsync</h1>
|
||||
|
||||
<h2>
|
||||
Simple and free service that lets you listen to any YouTube or
|
||||
Vimeo channels, playlists or user videos in podcast format.
|
||||
</h2>
|
||||
|
||||
<div class="login-block">
|
||||
<div class="login-border">
|
||||
|
||||
<ng-container *ngIf="!loggedIn">
|
||||
<a href="/user/login">
|
||||
<i aria-hidden="true" class="fa fa-unlock"></i>
|
||||
Login with Patreon to unlock features
|
||||
</a>
|
||||
<a href="https://www.patreon.com/bePatron?c=278915">
|
||||
<i aria-hidden="true" class="fa fa-question-circle-o"></i>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="loggedIn">
|
||||
<a>Hey, <i aria-hidden="true" class="fa fa-user-o"></i> {{ user?.full_name }}</a>
|
||||
<a href="/user/logout"><i aria-hidden="true" class="fa fa-power-off"></i></a>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-input [featureLevel]="user?.feature_level"
|
||||
[locked]="!loggedIn">
|
||||
</app-input>
|
||||
|
||||
<div class="man"></div>
|
||||
|
||||
<div>
|
||||
<a href="https://www.patreon.com/podsync" target="_blank" rel="noopener">
|
||||
<img alt="Become my patron on Patreon" class="patreon shadow" src="assets/img/become_patreon.png"
|
||||
width="400"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<app-footer class="bottom-links">
|
||||
<a routerLink="/faq.html"><i aria-hidden="true" class="fa fa-question-circle"></i></a>
|
||||
<a href="https://twitter.com/pod_sync"><i aria-hidden="true" class="fa fa-twitter"></i></a>
|
||||
</app-footer>
|
||||
@@ -1,89 +0,0 @@
|
||||
|
||||
/* Header */
|
||||
|
||||
.title {
|
||||
padding: 50px;
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 900px) {
|
||||
padding-top: 1em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
max-width: 500px;
|
||||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.login-block {
|
||||
color: #e18712;
|
||||
font-size: 1em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-border, .login-border a {
|
||||
float: left;
|
||||
background: orange;
|
||||
color: whitesmoke;
|
||||
border: 1px orange;
|
||||
padding: 3px;
|
||||
border-radius: 2px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Man image */
|
||||
.man {
|
||||
width: 210px;
|
||||
height: 333px;
|
||||
background-image: url('/assets/img/man.png');
|
||||
-ms-background-repeat: no-repeat;
|
||||
background-repeat: no-repeat;
|
||||
-ms-background-position: center bottom;
|
||||
background-position: center bottom;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
left: 30%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: -1;
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 640px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Patreon block */
|
||||
|
||||
.patreon {
|
||||
background: rgb(171, 163, 149);
|
||||
border: 1px rgb(240, 232, 214) solid;
|
||||
bottom: 6em;
|
||||
float: right;
|
||||
position: absolute;
|
||||
right: 2em;
|
||||
z-index: -1;
|
||||
|
||||
@media screen and (max-height: 640px), screen and (max-width: 500px) {
|
||||
width: 300px;
|
||||
bottom: 4em;
|
||||
right: 2em;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 400px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { IndexComponent } from './index.component';
|
||||
|
||||
describe('IndexComponent', () => {
|
||||
let component: IndexComponent;
|
||||
let fixture: ComponentFixture<IndexComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ IndexComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(IndexComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import {APIService, UserResponse} from '../api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-index',
|
||||
templateUrl: './index.component.html',
|
||||
styleUrls: ['./index.component.scss']
|
||||
})
|
||||
export class IndexComponent implements OnInit {
|
||||
loggedIn = false;
|
||||
user: UserResponse;
|
||||
|
||||
constructor(private apiService: APIService) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.apiService.getUser().subscribe(resp => {
|
||||
this.loggedIn = true;
|
||||
this.user = resp;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
<div class="main" [hidden]="popupOpened">
|
||||
<div class="main-border shadow">
|
||||
<div class="input-border">
|
||||
<input type="text"
|
||||
placeholder="paste your link here"
|
||||
spellcheck="false"
|
||||
autofocus
|
||||
[(ngModel)]="link"
|
||||
(keydown.enter)="submit()" />
|
||||
|
||||
<div class="arrow-button" [style.display]="link.length > 3 ? 'block' : 'none'">
|
||||
<a href="#" (click)="submit()">
|
||||
<i class="fa fa-arrow-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="controls-icon">
|
||||
<ng-container *ngIf="locked">
|
||||
<a href="https://www.patreon.com/bePatron?c=278915" class="black">
|
||||
<i class="fa fa-question-circle master-tooltip"
|
||||
aria-hidden="true"
|
||||
title="This features are available for patrons only. You may support us and unlock this features"></i>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!locked">
|
||||
<i class="fa fa-wrench" aria-hidden="true"></i>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div [class.locked]="locked">
|
||||
|
||||
<div class="switch-field">
|
||||
<div class="switch-title">format </div>
|
||||
|
||||
<input type="radio"
|
||||
id="type_video"
|
||||
name="episode_format"
|
||||
value="video"
|
||||
[disabled]="locked"
|
||||
[(ngModel)]="format" />
|
||||
<label for="type_video">video</label>
|
||||
|
||||
<input type="radio"
|
||||
id="type_audio"
|
||||
name="episode_format"
|
||||
value="audio"
|
||||
[disabled]="locked"
|
||||
[(ngModel)]="format" />
|
||||
<label for="type_audio">audio</label>
|
||||
</div>
|
||||
|
||||
<div class="switch-field" >
|
||||
<div class="switch-title">quality </div>
|
||||
|
||||
<input type="radio"
|
||||
id="type_low"
|
||||
name="episode_quality"
|
||||
value="low"
|
||||
[disabled]="locked"
|
||||
[(ngModel)]="quality" />
|
||||
<label for="type_low">low</label>
|
||||
|
||||
<input type="radio"
|
||||
id="type_high"
|
||||
name="episode_quality"
|
||||
value="high"
|
||||
[disabled]="locked"
|
||||
[(ngModel)]="quality" />
|
||||
<label for="type_high">high</label>
|
||||
</div>
|
||||
|
||||
<div class="switch-field">
|
||||
<div class="switch-title">episode count </div>
|
||||
|
||||
<input type="radio"
|
||||
id="type_50"
|
||||
name="page_count"
|
||||
[value]="50"
|
||||
[disabled]="locked"
|
||||
[(ngModel)]="pageSize" />
|
||||
<label for="type_50">50</label>
|
||||
|
||||
<input type="radio"
|
||||
id="type_100"
|
||||
name="page_count"
|
||||
[value]="100"
|
||||
[disabled]="locked"
|
||||
[(ngModel)]="pageSize" />
|
||||
<label for="type_100">100</label>
|
||||
|
||||
<input type="radio"
|
||||
id="type_150"
|
||||
name="page_count"
|
||||
[value]="150"
|
||||
[disabled]="locked"
|
||||
[(ngModel)]="pageSize" />
|
||||
<label for="type_150">150</label>
|
||||
|
||||
<input type="radio"
|
||||
id="type_600"
|
||||
name="page_count"
|
||||
[value]="600"
|
||||
[disabled]="!allow600()"
|
||||
[(ngModel)]="pageSize" />
|
||||
<label for="type_600" [class.locked]="!allow600()">600</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,124 +0,0 @@
|
||||
.main {
|
||||
text-align: center;
|
||||
padding: 5em 2em 10em;
|
||||
max-width: 860px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 3em 0;
|
||||
text-align: left;
|
||||
|
||||
@media screen and (max-width: 840px) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 840px) {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.main-border {
|
||||
border: thin solid #a39c8e;
|
||||
background: #f4ecda;
|
||||
|
||||
input {
|
||||
border: none;
|
||||
padding: 0.5em 0 0.5em 0.5em;
|
||||
width: calc(100% - 2em);
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
color: #616161;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.input-border {
|
||||
background-color: #e4dac8;
|
||||
border: none;
|
||||
border-bottom: 1px solid #a39c8e;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Input button (arrow) */
|
||||
|
||||
.arrow-button {
|
||||
float: right;
|
||||
font-size: 2em;
|
||||
padding-top: 0.5em;
|
||||
padding-right: 0.3em;
|
||||
|
||||
a {
|
||||
color: rgb(142, 134, 119);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: none;
|
||||
color: rgb(105, 98, 86);
|
||||
}
|
||||
}
|
||||
|
||||
/* Controls under input */
|
||||
|
||||
.controls {
|
||||
padding-left: 1em;
|
||||
word-spacing: 0.15em;
|
||||
margin: 12px 0 12px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls-icon {
|
||||
float: left;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.locked {
|
||||
-ms-opacity: 0.3;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* Switch */
|
||||
|
||||
.switch-field {
|
||||
float: left;
|
||||
font-size: 1em;
|
||||
padding-right: 7px;
|
||||
|
||||
input {
|
||||
position: absolute !important;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
label {
|
||||
float: left;
|
||||
display: inline-block;
|
||||
color: #EB8C11;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
label:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:checked + label {
|
||||
background: orange;
|
||||
color: whitesmoke;
|
||||
border: 1px orange;
|
||||
-ms-border-top-right-radius: 3px;
|
||||
-ms-border-top-left-radius: 3px;
|
||||
-ms-border-bottom-right-radius: 3px;
|
||||
-ms-border-bottom-left-radius: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-title {
|
||||
display: inline-block;
|
||||
float: left;
|
||||
padding-right: 5px;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { InputComponent } from './input.component';
|
||||
|
||||
describe('InputComponent', () => {
|
||||
let component: InputComponent;
|
||||
let fixture: ComponentFixture<InputComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ InputComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(InputComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import {Component, Input, OnInit} from '@angular/core';
|
||||
import {GlobalPosition, InsidePlacement, Toppy, ToppyControl} from 'toppy';
|
||||
import {APIService} from '../api.service';
|
||||
import {OutputComponent} from '../output/output.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-input',
|
||||
templateUrl: './input.component.html',
|
||||
styleUrls: ['./input.component.scss']
|
||||
})
|
||||
export class InputComponent implements OnInit {
|
||||
|
||||
constructor(private toppy: Toppy,
|
||||
private api: APIService) { }
|
||||
@Input() featureLevel = 0;
|
||||
@Input() locked = true;
|
||||
|
||||
popup: ToppyControl;
|
||||
popupOpened: boolean;
|
||||
format = 'video';
|
||||
quality = 'high';
|
||||
pageSize = 50;
|
||||
link = '';
|
||||
|
||||
static makeLink(id: string) {
|
||||
if (location.port === '80' || location.port === '443') {
|
||||
return location.protocol + '//' + location.hostname + '/' + id;
|
||||
} else {
|
||||
return location.protocol + '//' + location.host + '/' + id;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.popup = this.toppy
|
||||
.position(new GlobalPosition({
|
||||
placement: InsidePlacement.TOP,
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
offset: 150
|
||||
}))
|
||||
.config({
|
||||
backdrop: true,
|
||||
closeOnEsc: true,
|
||||
})
|
||||
.content(OutputComponent)
|
||||
.create();
|
||||
|
||||
this.popup.listen('t_close').subscribe(() => {
|
||||
this.popupOpened = false;
|
||||
this.link = '';
|
||||
});
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.api.createFeed({
|
||||
url: this.link,
|
||||
format: this.format,
|
||||
quality: this.quality,
|
||||
page_size: this.pageSize,
|
||||
}).subscribe(
|
||||
(resp) => {
|
||||
this.popup.content.props.address = InputComponent.makeLink(resp.id);
|
||||
this.popup.open();
|
||||
this.popupOpened = true;
|
||||
},
|
||||
err => {
|
||||
alert(err.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
allow600() {
|
||||
return !this.locked && this.featureLevel >= 2;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<div class="frame">
|
||||
<input type="url"
|
||||
readonly
|
||||
[value]="address"
|
||||
[size]="address.length"
|
||||
#input />
|
||||
|
||||
<div class="links">
|
||||
<span>
|
||||
<a href="#" (click)="copyToClipboard(input)" *ngIf="canCopy">
|
||||
<i class="fa fa-clone" aria-hidden="true"></i>
|
||||
Copy
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<a [href]="address" target="_blank">
|
||||
<i aria-hidden="true" class="fa fa-external-link"></i>
|
||||
Open
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<a href="#" (click)="close()">
|
||||
<i aria-hidden="true" class="fa fa-thumbs-up"></i>
|
||||
Close
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
.frame {
|
||||
background: #56524b;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
outline: 9999px solid rgba(0,0,0,0.8);
|
||||
padding: 5px;
|
||||
|
||||
input {
|
||||
width: auto;
|
||||
font-size: 3em;
|
||||
border: none;
|
||||
background: none;
|
||||
border-bottom: 1px dotted black;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
padding-top: 5px;
|
||||
|
||||
span {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #EB8C11;
|
||||
background: rgb(68, 65, 60);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { OutputComponent } from './output.component';
|
||||
|
||||
describe('OutputComponent', () => {
|
||||
let component: OutputComponent;
|
||||
let fixture: ComponentFixture<OutputComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ OutputComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(OutputComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import {ToppyControl} from 'toppy';
|
||||
|
||||
@Component({
|
||||
selector: 'app-output',
|
||||
templateUrl: './output.component.html',
|
||||
styleUrls: ['./output.component.scss']
|
||||
})
|
||||
export class OutputComponent implements OnInit {
|
||||
address: string;
|
||||
canCopy: boolean;
|
||||
|
||||
constructor(private overlay: ToppyControl) {
|
||||
this.address = overlay.content.props.address;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.canCopy = document.queryCommandSupported('copy');
|
||||
}
|
||||
|
||||
copyToClipboard(input) {
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
input.setSelectionRange(0, 0);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.overlay.close();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<div class="footer">
|
||||
<div class="footer-links">
|
||||
<!-- https://www.freecodecamp.org/news/everything-you-need-to-know-about-ng-template-ng-content-ng-container-and-ngtemplateoutlet-4b7b51223691/ -->
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +0,0 @@
|
||||
.footer {
|
||||
background-image: url('/assets/img/pc_footer.png');
|
||||
-ms-background-position: center bottom;
|
||||
background-position: center bottom;
|
||||
background-color: #aca496;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 69px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-user-select: text;
|
||||
z-index: -1;
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
background-image: url('/assets/img/mobile_footer.png');
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
position: absolute;
|
||||
right: 2em;
|
||||
bottom: 0;
|
||||
font-size: 3em;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FooterComponent } from './footer.component';
|
||||
|
||||
describe('FooterComponent', () => {
|
||||
let component: FooterComponent;
|
||||
let fixture: ComponentFixture<FooterComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ FooterComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FooterComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrls: ['./footer.component.scss']
|
||||
})
|
||||
export class FooterComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,3 +0,0 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
};
|
||||