Remove not needed packages

This commit is contained in:
Maksym Pavlenko
2019-10-26 22:55:19 -07:00
parent a547647667
commit 08a58e6a1b
111 changed files with 3 additions and 15225 deletions
+1 -11
View File
@@ -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
View File
@@ -1 +0,0 @@
api
-4
View File
@@ -1,4 +0,0 @@
FROM alpine
RUN apk --update --no-cache add ca-certificates
COPY ./api /api
ENTRYPOINT ["/api"]
-14
View File
@@ -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)
-141
View File
@@ -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")
}
-14
View File
@@ -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/
-4
View File
@@ -1,4 +0,0 @@
.PHONY: push
push:
docker build -t mxpv/nginx -f Dockerfile ../../
docker push mxpv/nginx
-95
View File
@@ -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;
}
}
}
-9
View File
@@ -1,9 +0,0 @@
User-agent: *
Allow: /$
Allow: /*.js
Allow: /*.css
Allow: /faq
Allow: /assets/*
Allow: /fontawesome-webfont*
Disallow: /
Host: www.podsync.net
-5
View File
@@ -1,5 +0,0 @@
.idea/
.DS_Store/
venv/
package/
__pycache__/
-6
View File
@@ -1,6 +0,0 @@
.idea/
venv/
package/
__pycache__/
function.zip
-14
View File
@@ -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"]
-31
View File
@@ -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
-12
View File
@@ -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,
}
-3
View File
@@ -1,3 +0,0 @@
youtube_dl==2019.07.16
sanic==18.12
boto3==1.9.151
-164
View File
@@ -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']
-56
View File
@@ -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')
-30
View File
@@ -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)
-2
View File
@@ -1,2 +0,0 @@
[install]
prefix=
-6
View File
@@ -1,6 +0,0 @@
.idea/
venv/
package/
__pycache__/
function.zip
-6
View File
@@ -1,6 +0,0 @@
.idea/
venv/
package/
__pycache__/
function.zip
-12
View File
@@ -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"]
-33
View File
@@ -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
-138
View File
@@ -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)))
-2
View File
@@ -1,2 +0,0 @@
boto3==1.9.129
youtube_dl==2019.08.02
-2
View File
@@ -1,2 +0,0 @@
[install]
prefix=
-130
View File
@@ -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
-42
View File
@@ -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'))
-80
View File
@@ -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
}
-35
View File
@@ -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)
}
-79
View File
@@ -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
}
-26
View File
@@ -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)
}
+1 -22
View File
@@ -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) {
-36
View File
@@ -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)
}
+1 -14
View File
@@ -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)
-321
View File
@@ -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()}
}
-159
View File
@@ -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)
}
-141
View File
@@ -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)
}
-139
View File
@@ -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
}
-73
View File
@@ -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)
}
-39
View File
@@ -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)
}
-25
View File
@@ -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)
}
-585
View File
@@ -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
}
-157
View File
@@ -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
}
-199
View File
@@ -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()
}
-64
View File
@@ -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;
`
-55
View File
@@ -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
}
-261
View File
@@ -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)
}
-150
View File
@@ -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}
}
-83
View File
@@ -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)
}
-124
View File
@@ -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
}
-13
View File
@@ -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
-46
View File
@@ -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
-27
View File
@@ -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).
-123
View File
@@ -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"
}
-12
View File
@@ -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'.
-32
View File
@@ -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 } }));
}
};
-23
View File
@@ -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));
});
});
-11
View File
@@ -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>;
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}
-32
View File
@@ -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
});
};
-9644
View File
File diff suppressed because it is too large Load Diff
-49
View File
@@ -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"
}
}
-12
View File
@@ -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();
});
});
-39
View File
@@ -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)
);
}
}
-21
View File
@@ -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 { }
-3
View File
@@ -1,3 +0,0 @@
<div class="background-image">
<router-outlet></router-outlet>
</div>
-20
View File
@@ -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')
}
}
-35
View File
@@ -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!');
});
});
-43
View File
@@ -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',
});
}
}
-37
View File
@@ -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 { }
-75
View File
@@ -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>
-5
View File
@@ -1,5 +0,0 @@
.border {
border: thin solid #a39c8e;
background: #f4ecda;
}
-25
View File
@@ -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();
});
});
-11
View File
@@ -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() {}
}
-46
View File
@@ -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>
-89
View File
@@ -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;
}
}
-25
View File
@@ -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();
});
});
-21
View File
@@ -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;
});
}
}
-114
View File
@@ -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>
-124
View File
@@ -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;
}
-25
View File
@@ -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();
});
});
-75
View File
@@ -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;
}
}
-31
View File
@@ -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>
-28
View File
@@ -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();
});
});
-30
View File
@@ -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() {
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

-3
View File
@@ -1,3 +0,0 @@
export const environment = {
production: true
};

Some files were not shown because too many files have changed in this diff Show More