figure out strapi with pnpm
This commit is contained in:
parent
11032ee83c
commit
f6ce2138b9
|
@ -16,4 +16,14 @@ app.json
|
|||
compose/
|
||||
docker-compose.*
|
||||
.vscode
|
||||
charts/**/charts
|
||||
charts/**/charts
|
||||
|
||||
|
||||
packages/strapi/.tmp/
|
||||
packages/strapi/.cache/
|
||||
packages/strapi/.git/
|
||||
packages/strapi/.env
|
||||
packages/strapi/build/
|
||||
packages/strapi/node_modules/
|
||||
packages/strapi/data/
|
||||
packages/strapi/backup
|
|
@ -1,3 +1,6 @@
|
|||
**/.env
|
||||
*~
|
||||
|
||||
charts/**/charts
|
||||
.envrc
|
||||
compose/
|
||||
|
|
|
@ -4,6 +4,8 @@ pnpm required for workspaces.
|
|||
|
||||
Kubernetes for Development using Tiltfile
|
||||
|
||||
kubefwd and entr for DNS in dev cluster
|
||||
|
||||
dokku for Production, deployed with `git push`.
|
||||
|
||||
(dokku is slowly being replaced by Kubernetes)
|
||||
|
|
43
Makefile
43
Makefile
|
@ -30,6 +30,21 @@ tilt:
|
|||
tilt up
|
||||
|
||||
secrets:
|
||||
|
||||
kubectl --namespace futureporn delete secret frp --ignore-not-found
|
||||
kubectl --namespace futureporn create secret generic frp \
|
||||
--from-literal=token=${FRP_TOKEN}
|
||||
|
||||
kubectl --namespace futureporn delete secret scout --ignore-not-found
|
||||
kubectl --namespace futureporn create secret generic scout \
|
||||
--from-literal=recentsToken=${SCOUT_RECENTS_TOKEN} \
|
||||
--from-literal=strapiApiKey=${SCOUT_STRAPI_API_KEY} \
|
||||
--from-literal=imapServer=${SCOUT_IMAP_SERVER} \
|
||||
--from-literal=imapPort=${SCOUT_IMAP_PORT} \
|
||||
--from-literal=imapUsername=${SCOUT_IMAP_USERNAME} \
|
||||
--from-literal=imapPassword=${SCOUT_IMAP_PASSWORD} \
|
||||
--from-literal=imapAccessToken=${SCOUT_IMAP_ACCESS_TOKEN} \
|
||||
|
||||
kubectl --namespace futureporn delete secret link2cid --ignore-not-found
|
||||
kubectl --namespace futureporn create secret generic link2cid \
|
||||
--from-literal=apiKey=${LINK2CID_API_KEY}
|
||||
|
@ -38,7 +53,6 @@ secrets:
|
|||
kubectl --namespace cert-manager create secret generic vultr \
|
||||
--from-literal=apiKey=${VULTR_API_KEY}
|
||||
|
||||
|
||||
kubectl --namespace futureporn delete secret vultr --ignore-not-found
|
||||
kubectl --namespace futureporn create secret generic vultr \
|
||||
--from-literal=containerRegistryUsername=${VULTR_CONTAINER_REGISTRY_USERNAME} \
|
||||
|
@ -58,7 +72,7 @@ secrets:
|
|||
--from-literal=adminJwtSecret=${STRAPI_ADMIN_JWT_SECRET} \
|
||||
--from-literal=apiTokenSalt=${STRAPI_API_TOKEN_SALT} \
|
||||
--from-literal=appKeys=${STRAPI_APP_KEYS} \
|
||||
--from-literal=databaseUrl=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} \
|
||||
--from-literal=databaseUrl=postgres.futureporn.svc.cluster.local://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} \
|
||||
--from-literal=jwtSecret=${STRAPI_JWT_SECRET} \
|
||||
--from-literal=muxPlaybackRestrictionId=${MUX_PLAYBACK_RESTRICTION_ID} \
|
||||
--from-literal=muxSigningKeyPrivateKey=${MUX_SIGNING_KEY_PRIVATE_KEY} \
|
||||
|
@ -73,15 +87,9 @@ secrets:
|
|||
--from-literal=cdnBucketUscUrl=${CDN_BUCKET_USC_URL} \
|
||||
--from-literal=transferTokenSalt=${TRANSFER_TOKEN_SALT}
|
||||
|
||||
|
||||
|
||||
|
||||
# --from-literal=sessionSecret=$(SESSION_SECRET) \
|
||||
# --from-literal=twitchClientId=$(TWITCH_CLIENT_ID) \
|
||||
# --from-literal=twitchClientSecret=$(TWITCH_CLIENT_SECRET) \
|
||||
# --from-literal=gumroadClientId=$(GUMROAD_CLIENT_ID) \
|
||||
# --from-literal=gumroadClientSecret=$(GUMROAD_CLIENT_SECRET)
|
||||
|
||||
kubectl --namespace futureporn delete secret realtime --ignore-not-found
|
||||
kubectl --namespace futureporn create secret generic realtime \
|
||||
--from-literal=postgresRealtimeConnectionString=${POSTGRES_REALTIME_CONNECTION_STRING}
|
||||
|
||||
define _script
|
||||
cat <<'EOF' | ctlptl apply -f -
|
||||
|
@ -100,6 +108,19 @@ minikube:
|
|||
minikube addons enable metrics-server
|
||||
|
||||
|
||||
kind:
|
||||
bash -x ./scripts/kind-with-local-registry.sh
|
||||
|
||||
deps:
|
||||
sudo pamac install make entr nvm minikube kubectl docker helm
|
||||
curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
echo "go to https://github.com/txn2/kubefwd/releases/latest to get kubefwd"
|
||||
echo "go to https://github.com/tilt-dev/ctlptl/releases/latest to get ctlptl"
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
usermod -aG docker cj
|
||||
newgrp docker
|
||||
|
||||
# A gitea act runner which runs locally
|
||||
# https://docs.gitea.com/next/usage/actions/overview
|
||||
|
|
|
@ -24,7 +24,7 @@ spec:
|
|||
kind: HelmRepository
|
||||
name: bitnami
|
||||
values:
|
||||
fullnameOverride: windmill-postgresql-cool
|
||||
fullnameOverride: windmill-postgresql-uncool
|
||||
postgresql:
|
||||
auth:
|
||||
postgresPassword: windmill
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
||||
kind: GitRepository
|
||||
metadata:
|
||||
name: chisel-operator
|
||||
namespace: futureporn
|
||||
spec:
|
||||
interval: 5m
|
||||
url: https://github.com/FyraLabs/chisel-operator
|
||||
ref:
|
||||
branch: master
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: chisel-operator
|
||||
namespace: futureporn
|
||||
spec:
|
||||
interval: 10m
|
||||
targetNamespace: futureporn
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: chisel-operator
|
||||
path: "./kustomize"
|
||||
prune: true
|
|
@ -0,0 +1,5 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: futureporn
|
||||
resources:
|
||||
- chisel.yaml
|
|
@ -0,0 +1,37 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: capture
|
||||
namespace: futureporn
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: capture
|
||||
ports:
|
||||
- name: capture
|
||||
port: 80
|
||||
targetPort: 5566
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: capture
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app: capture
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: capture
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: capture
|
||||
spec:
|
||||
containers:
|
||||
- name: capture
|
||||
image: "{{ .Values.capture.containerName }}"
|
||||
ports:
|
||||
- containerPort: 5566
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: echo-deployment
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app.kubernetes.io/name: echo
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: echo-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: echo-server
|
||||
spec:
|
||||
containers:
|
||||
- name: echo-server
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
image: jmalloc/echo-server
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: echo
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app.kubernetes.io/name: echo
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
selector:
|
||||
app: echo-server
|
||||
|
||||
# ---
|
||||
# apiVersion: networking.k8s.io/v1
|
||||
# kind: Ingress
|
||||
# metadata:
|
||||
# name: ngrok
|
||||
# namespace: futureporn
|
||||
# annotations:
|
||||
# kubernetes.io/ingress.class: ngrok
|
||||
# k8s.ngrok.com/namespace: futureporn
|
||||
# k8s.ngrok.com/service: ngrok
|
||||
# spec:
|
||||
# ingressClassName: ngrok
|
||||
# tls:
|
||||
# - secretName: ngrok-tls
|
||||
# hosts:
|
||||
# - "{{ .Values.ngrok.hostname }}"
|
||||
# rules:
|
||||
# - host: "{{ .Values.ngrok.hostname }}"
|
||||
# http:
|
||||
# paths:
|
||||
# - path: /echo
|
||||
# pathType: Prefix
|
||||
# backend:
|
||||
# service:
|
||||
# name: echo
|
||||
# port:
|
||||
# number: 8080
|
||||
# - path: /game
|
||||
# pathType: Prefix
|
||||
# backend:
|
||||
# service:
|
||||
# name: game-2048
|
||||
# port:
|
||||
# number: 8080
|
||||
# - path: /strapi
|
||||
# pathType: Prefix
|
||||
# backend:
|
||||
# service:
|
||||
# name: strapi
|
||||
# port:
|
||||
# number: 1337
|
||||
# - path: /next
|
||||
# pathType: Prefix
|
||||
# backend:
|
||||
# service:
|
||||
# name: next
|
||||
# port:
|
||||
# number: 3000
|
||||
|
||||
# ---
|
||||
# apiVersion: networking.k8s.io/v1
|
||||
# kind: Ingress
|
||||
# metadata:
|
||||
# name: echo-ing
|
||||
# namespace: futureporn
|
||||
# annotations:
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||
# spec:
|
||||
# backend:
|
||||
# serviceName: echo-service
|
||||
# servicePort: 8080
|
||||
# tls:
|
||||
# - secretName: next-tls
|
||||
# hosts:
|
||||
# - echo.test
|
||||
# rules:
|
||||
# - host: echo.test
|
||||
# http:
|
||||
# paths:
|
||||
# - path: /
|
||||
# pathType: Prefix
|
||||
# backend:
|
||||
# service:
|
||||
# name: echo-service
|
||||
# port:
|
||||
# number: 8080
|
|
@ -1,3 +1,6 @@
|
|||
{{ if eq .Values.managedBy "Helm" }}
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
|
@ -29,7 +32,8 @@ roleRef:
|
|||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: external-dns
|
||||
namespace: default
|
||||
namespace: futureporn
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
|
@ -60,3 +64,5 @@ spec:
|
|||
secretKeyRef:
|
||||
name: vultr
|
||||
key: apiKey
|
||||
|
||||
{{ end }}
|
|
@ -0,0 +1,34 @@
|
|||
{{ if eq .Values.managedBy "tilt" }}
|
||||
|
||||
---
|
||||
apiVersion: frp.zufardhiyaulhaq.com/v1alpha1
|
||||
kind: Client
|
||||
metadata:
|
||||
name: client-01
|
||||
namespace: futureporn
|
||||
spec:
|
||||
server:
|
||||
host: 155.138.254.201
|
||||
port: 7000
|
||||
authentication:
|
||||
token:
|
||||
secret:
|
||||
name: frp
|
||||
key: token
|
||||
|
||||
---
|
||||
apiVersion: frp.zufardhiyaulhaq.com/v1alpha1
|
||||
kind: Upstream
|
||||
metadata:
|
||||
name: echo
|
||||
namespace: futureporn
|
||||
spec:
|
||||
client: client-01
|
||||
tcp:
|
||||
host: echo.futureporn.svc.cluster.local
|
||||
port: 8080
|
||||
server:
|
||||
port: 8080
|
||||
proxyProtocol: v2
|
||||
|
||||
{{ end }}
|
|
@ -0,0 +1,34 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: game-2048
|
||||
namespace: futureporn
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
selector:
|
||||
app: game-2048
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: game-2048
|
||||
namespace: futureporn
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: game-2048
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: game-2048
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: mendhak/http-https-echo
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
kind: ClusterRole
|
||||
apiVersion: rbac.authorization.k8s.io/v1beta1
|
||||
metadata:
|
||||
name: traefik-ingress-controller
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- services
|
||||
- endpoints
|
||||
- secrets
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- extensions
|
||||
resources:
|
||||
- ingresses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- extensions
|
||||
resources:
|
||||
- ingresses/status
|
||||
verbs:
|
||||
- update
|
||||
---
|
||||
kind: ClusterRoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1beta1
|
||||
metadata:
|
||||
name: traefik-ingress-controller
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: traefik-ingress-controller
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: traefik-ingress-controller
|
||||
namespace: kube-system
|
||||
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: traefik-ingress-controller
|
||||
namespace: kube-system
|
||||
---
|
||||
kind: DaemonSet
|
||||
apiVersion: apps/v1
|
||||
metadata:
|
||||
name: traefik-ingress-controller
|
||||
namespace: kube-system
|
||||
labels:
|
||||
k8s-app: traefik-ingress-lb
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
k8s-app: traefik-ingress-lb
|
||||
name: traefik-ingress-lb
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
k8s-app: traefik-ingress-lb
|
||||
name: traefik-ingress-lb
|
||||
spec:
|
||||
serviceAccountName: traefik-ingress-controller
|
||||
terminationGracePeriodSeconds: 60
|
||||
containers:
|
||||
- image: traefik:v1.7
|
||||
name: traefik-ingress-lb
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
hostPort: 80
|
||||
- name: admin
|
||||
containerPort: 8080
|
||||
hostPort: 8080
|
||||
securityContext:
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- NET_BIND_SERVICE
|
||||
args:
|
||||
- --api
|
||||
- --kubernetes
|
||||
- --logLevel=INFO
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: traefik-ingress-service
|
||||
namespace: kube-system
|
||||
spec:
|
||||
selector:
|
||||
k8s-app: traefik-ingress-lb
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
name: web
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
name: admin
|
|
@ -0,0 +1,70 @@
|
|||
{{ if eq .Values.managedBy "Helm" }}
|
||||
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: ipfs-pod
|
||||
namespace: default
|
||||
labels:
|
||||
app.kubernetes.io/name: ipfs
|
||||
spec:
|
||||
containers:
|
||||
- name: ipfs
|
||||
image: ipfs/kubo
|
||||
ports:
|
||||
- containerPort: 5001
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: ipfs-pvc
|
||||
mountPath: /data/ipfs
|
||||
restartPolicy: OnFailure
|
||||
volumes:
|
||||
- name: ipfs-pvc
|
||||
persistentVolumeClaim:
|
||||
claimName: ipfs-pvc
|
||||
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ipfs
|
||||
namespace: default
|
||||
annotations:
|
||||
meta.helm.sh/release-name: fp
|
||||
meta.helm.sh/release-namespace: default
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: {{ .Values.managedBy }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 40Gi
|
||||
storageClassName: {{ .Values.storageClassName }}
|
||||
|
||||
|
||||
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ipfs-service
|
||||
namespace: default
|
||||
annotations:
|
||||
meta.helm.sh/release-name: fp
|
||||
meta.helm.sh/release-namespace: default
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: {{ .Values.managedBy }}
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ipfs
|
||||
ports:
|
||||
- name: gateway
|
||||
protocol: TCP
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
- name: api
|
||||
protocol: TCP
|
||||
port: 5001
|
||||
targetPort: 5001
|
||||
|
||||
{{ end }}
|
|
@ -2,7 +2,7 @@ apiVersion: v1
|
|||
kind: Service
|
||||
metadata:
|
||||
name: link2cid
|
||||
namespace: default
|
||||
namespace: futureporn
|
||||
spec:
|
||||
selector:
|
||||
app: link2cid
|
||||
|
@ -22,7 +22,7 @@ apiVersion: apps/v1
|
|||
kind: Deployment
|
||||
metadata:
|
||||
name: link2cid
|
||||
namespace: default
|
||||
namespace: futureporn
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
|
@ -54,10 +54,10 @@ apiVersion: v1
|
|||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: link2cid
|
||||
namespace: default
|
||||
namespace: futureporn
|
||||
annotations:
|
||||
meta.helm.sh/release-name: fp
|
||||
meta.helm.sh/release-namespace: default
|
||||
meta.helm.sh/release-namespace: futureporn
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: {{ .Values.managedBy }}
|
||||
spec:
|
||||
|
@ -75,7 +75,7 @@ apiVersion: networking.k8s.io/v1
|
|||
kind: Ingress
|
||||
metadata:
|
||||
name: link2cid-ingress
|
||||
namespace: default
|
||||
namespace: futureporn
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "nginx"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
|
@ -0,0 +1,47 @@
|
|||
{{ if eq .Values.managedBy "tilt" }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ngrok
|
||||
namespace: futureporn
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: ngrok
|
||||
k8s.ngrok.com/namespace: futureporn
|
||||
k8s.ngrok.com/service: ngrok
|
||||
spec:
|
||||
ingressClassName: ngrok
|
||||
rules:
|
||||
- host: "{{ .Values.ngrok.hostname }}"
|
||||
http:
|
||||
paths:
|
||||
- path: /echo
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: echo
|
||||
port:
|
||||
number: 8080
|
||||
- path: /next
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: next
|
||||
port:
|
||||
number: 3000
|
||||
- path: /strapi
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: strapi
|
||||
port:
|
||||
number: 1337
|
||||
# - path: /snake
|
||||
# pathType: Prefix
|
||||
# backend:
|
||||
# service:
|
||||
# name: snake
|
||||
# port:
|
||||
# number: 8080
|
||||
{{ end }}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: realtime
|
||||
namespace: futureporn
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: realtime
|
||||
ports:
|
||||
- name: realtime
|
||||
port: 80
|
||||
targetPort: 5535
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: realtime
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app: realtime
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: realtime
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: realtime
|
||||
spec:
|
||||
containers:
|
||||
- name: realtime
|
||||
image: "{{ .Values.realtime.containerName }}"
|
||||
ports:
|
||||
- containerPort: 5535
|
||||
|
||||
|
||||
{{ if eq .Values.managedBy "Helm" }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: realtime
|
||||
namespace: futureporn
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||
spec:
|
||||
tls:
|
||||
- secretName: realtime-tls
|
||||
hosts:
|
||||
- realtime.futureporn.net
|
||||
rules:
|
||||
- host: realtime.futureporn.net
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: realtime
|
||||
port:
|
||||
number: 5535
|
||||
{{ end }}
|
|
@ -0,0 +1,35 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: snake
|
||||
namespace: futureporn
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
selector:
|
||||
app: snake
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: snake
|
||||
namespace: futureporn
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: snake
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: snake
|
||||
spec:
|
||||
containers:
|
||||
- name: snake
|
||||
image: thoschu/de.schulte360.web.snake
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
|
@ -0,0 +1 @@
|
|||
strapi-app.yaml
|
|
@ -1,22 +0,0 @@
|
|||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: ipfs-pod
|
||||
namespace: default
|
||||
labels:
|
||||
app.kubernetes.io/name: ipfs
|
||||
spec:
|
||||
containers:
|
||||
- name: ipfs
|
||||
image: ipfs/kubo
|
||||
ports:
|
||||
- containerPort: 5001
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: ipfs-pvc
|
||||
mountPath: /data/ipfs
|
||||
restartPolicy: OnFailure
|
||||
volumes:
|
||||
- name: ipfs-pvc
|
||||
persistentVolumeClaim:
|
||||
claimName: ipfs-pvc
|
|
@ -1,18 +0,0 @@
|
|||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ipfs-pvc
|
||||
namespace: default
|
||||
annotations:
|
||||
meta.helm.sh/release-name: fp
|
||||
meta.helm.sh/release-namespace: default
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: {{ .Values.managedBy }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 40Gi
|
||||
storageClassName: {{ .Values.storageClassName }}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ipfs-service
|
||||
namespace: default
|
||||
annotations:
|
||||
meta.helm.sh/release-name: fp
|
||||
meta.helm.sh/release-namespace: default
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: {{ .Values.managedBy }}
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ipfs
|
||||
ports:
|
||||
- name: gateway
|
||||
protocol: TCP
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
- name: api
|
||||
protocol: TCP
|
||||
port: 5001
|
||||
targetPort: 5001
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
# server: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: {{ .Values.adminEmail }}
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod
|
||||
solvers:
|
||||
- dns01:
|
||||
webhook:
|
||||
groupName: acme.vultr.com
|
||||
solverName: vultr
|
||||
config:
|
||||
apiKeySecretRef:
|
||||
key: apiKey
|
||||
name: vultr
|
|
@ -1,3 +1,4 @@
|
|||
{{ if eq .Values.managedBy "Helm" }}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
|
@ -21,3 +22,28 @@ spec:
|
|||
apiKeySecretRef:
|
||||
key: apiKey
|
||||
name: vultr-credentials
|
||||
|
||||
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
# server: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: {{ .Values.adminEmail }}
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod
|
||||
solvers:
|
||||
- dns01:
|
||||
webhook:
|
||||
groupName: acme.vultr.com
|
||||
solverName: vultr
|
||||
config:
|
||||
apiKeySecretRef:
|
||||
key: apiKey
|
||||
name: vultr
|
||||
|
||||
{{ end }}
|
|
@ -0,0 +1,65 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: next
|
||||
namespace: futureporn
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: next
|
||||
ports:
|
||||
- name: web
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: next
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app.kubernetes.io/name: next
|
||||
spec:
|
||||
containers:
|
||||
- name: next
|
||||
image: "{{ .Values.next.containerName }}"
|
||||
env:
|
||||
- name: HOSTNAME
|
||||
value: 0.0.0.0
|
||||
ports:
|
||||
- name: web
|
||||
containerPort: 3000
|
||||
resources: {}
|
||||
restartPolicy: OnFailure
|
||||
|
||||
|
||||
{{ if eq .Values.managedBy "Helm" }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: next
|
||||
namespace: futureporn
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: "{{ .Values.next.certIssuer }}"
|
||||
spec:
|
||||
backend:
|
||||
serviceName: next
|
||||
servicePort: 3000
|
||||
tls:
|
||||
- secretName: next-tls
|
||||
hosts:
|
||||
- "{{ .Values.next.hostname }}"
|
||||
rules:
|
||||
- host: "{{ .Values.next.hostname }}"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: next
|
||||
port:
|
||||
number: 3000
|
||||
{{ end }}
|
|
@ -0,0 +1,53 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: pgadmin
|
||||
namespace: futureporn
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: pgadmin
|
||||
ports:
|
||||
- name: web
|
||||
protocol: TCP
|
||||
port: 5050
|
||||
targetPort: 5050
|
||||
status:
|
||||
loadBalancer: {}
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: pgadmin
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app.kubernetes.io/name: pgadmin
|
||||
spec:
|
||||
containers:
|
||||
- name: pgadmin
|
||||
image: dpage/pgadmin4
|
||||
ports:
|
||||
- containerPort: 5050
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
env:
|
||||
- name: PGADMIN_LISTEN_PORT
|
||||
value: '5050'
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres
|
||||
key: password
|
||||
- name: PGADMIN_DEFAULT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pgadmin
|
||||
key: defaultPassword
|
||||
- name: PGADMIN_DEFAULT_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pgadmin
|
||||
key: defaultEmail
|
||||
restartPolicy: OnFailure
|
|
@ -0,0 +1,101 @@
|
|||
|
||||
# In development, we need a piko agent
|
||||
{{ if eq .Values.managedBy "tilt" }}
|
||||
|
||||
{{ end }}
|
||||
|
||||
|
||||
# In production, we need a piko server
|
||||
{{ if eq .Values.managedBy "Helm" }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: piko
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app: piko
|
||||
spec:
|
||||
ports:
|
||||
- port: 8000
|
||||
name: proxy
|
||||
- port: 8001
|
||||
name: upstream
|
||||
- port: 8002
|
||||
name: admin
|
||||
- port: 8003
|
||||
name: gossip
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: piko
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: server-config
|
||||
data:
|
||||
server.yaml: |
|
||||
cluster:
|
||||
node_id_prefix: ${POD_NAME}-
|
||||
join:
|
||||
- piko
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: piko
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: piko
|
||||
serviceName: "piko"
|
||||
replicas: 3
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: piko
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 60
|
||||
containers:
|
||||
- name: piko
|
||||
image: my-repo/piko:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
name: proxy
|
||||
- containerPort: 8001
|
||||
name: upstream
|
||||
- containerPort: 8002
|
||||
name: admin
|
||||
- containerPort: 8003
|
||||
name: gossip
|
||||
args:
|
||||
- server
|
||||
- --config.path
|
||||
- /config/server.yaml
|
||||
- --config.expand-env
|
||||
resources:
|
||||
limits:
|
||||
cpu: 250m
|
||||
ephemeral-storage: 1Gi
|
||||
memory: 1Gi
|
||||
requests:
|
||||
cpu: 250m
|
||||
ephemeral-storage: 1Gi
|
||||
memory: 1Gi
|
||||
env:
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: "/config"
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: server-config
|
||||
items:
|
||||
- key: "server.yaml"
|
||||
path: "server.yaml"
|
||||
{{ end }}
|
|
@ -0,0 +1,70 @@
|
|||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
namespace: futureporn
|
||||
name: postgres
|
||||
annotations:
|
||||
tilt.dev/down-policy: keep
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
resources:
|
||||
requests:
|
||||
storage: 40Gi
|
||||
storageClassName: {{ .Values.storageClassName }}
|
||||
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
namespace: futureporn
|
||||
name: postgres
|
||||
annotations:
|
||||
tilt.dev/down-policy: keep
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: postgres
|
||||
ports:
|
||||
- name: db
|
||||
protocol: TCP
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
status:
|
||||
loadBalancer: {}
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
namespace: futureporn
|
||||
name: postgres
|
||||
labels:
|
||||
app.kubernetes.io/name: postgres
|
||||
annotations:
|
||||
tilt.dev/down-policy: keep
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16.0
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres
|
||||
key: password
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
volumeMounts:
|
||||
- name: postgres
|
||||
mountPath: /data/postgres
|
||||
restartPolicy: OnFailure
|
||||
volumes:
|
||||
- name: postgres
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres
|
|
@ -1,3 +1,5 @@
|
|||
{{ if eq .Values.managedBy "Helm" }}
|
||||
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
|
@ -23,3 +25,4 @@ roleRef:
|
|||
name: cert-manager-webhook-vultr-secret-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
||||
{{ end }}
|
|
@ -0,0 +1,108 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: scout
|
||||
namespace: futureporn
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: scout
|
||||
ports:
|
||||
- name: web
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: scout
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app: scout
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: scout
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: scout
|
||||
spec:
|
||||
containers:
|
||||
- name: scout
|
||||
image: "{{ .Values.scout.containerName }}"
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
env:
|
||||
- name: POSTGRES_REALTIME_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: realtime
|
||||
key: postgresRealtimeConnectionString
|
||||
- name: STRAPI_URL
|
||||
value: https://strapi.futureporn.svc.cluster.local
|
||||
- name: SCOUT_RECENTS_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: scout
|
||||
key: recentsToken
|
||||
- name: SCOUT_IMAP_SERVER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: scout
|
||||
key: imapServer
|
||||
- name: SCOUT_IMAP_PORT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: scout
|
||||
key: imapPort
|
||||
- name: SCOUT_IMAP_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: scout
|
||||
key: imapUsername
|
||||
- name: SCOUT_IMAP_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: scout
|
||||
key: imapPassword
|
||||
- name: SCOUT_IMAP_ACCESS_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: scout
|
||||
key: imapAccessToken
|
||||
- name: SCOUT_STRAPI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: scout
|
||||
key: strapiApiKey
|
||||
|
||||
|
||||
{{ if eq .Values.managedBy "Helm" }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: scout
|
||||
namespace: futureporn
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||
spec:
|
||||
tls:
|
||||
- secretName: scout-tls
|
||||
hosts:
|
||||
- scout.sbtp.xyz
|
||||
rules:
|
||||
- host: scout.sbtp.xyz
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: scout
|
||||
port:
|
||||
number: 3000
|
||||
{{ end }}
|
|
@ -0,0 +1,182 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: strapi
|
||||
namespace: futureporn
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: strapi
|
||||
ports:
|
||||
- name: http
|
||||
port: 1339
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: strapi
|
||||
namespace: futureporn
|
||||
labels:
|
||||
app.kubernetes.io/name: strapi
|
||||
spec:
|
||||
containers:
|
||||
- name: strapi
|
||||
image: "{{ .Values.strapi.containerName }}"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 1339
|
||||
env:
|
||||
- name: ADMIN_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: adminJwtSecret
|
||||
- name: API_TOKEN_SALT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: apiTokenSalt
|
||||
- name: APP_KEYS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: appKeys
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: databaseUrl
|
||||
- name: CDN_BUCKET_USC_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: cdnBucketUscUrl
|
||||
- name: DATABASE_CLIENT
|
||||
value: postgres
|
||||
- name: DATABASE_HOST
|
||||
value: postgres.futureporn.svc.cluster.local
|
||||
- name: DATABASE_NAME
|
||||
value: futureporn-strapi
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: jwtSecret
|
||||
- name: MUX_PLAYBACK_RESTRICTION_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: muxPlaybackRestrictionId
|
||||
- name: MUX_SIGNING_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: muxSigningKeyId
|
||||
- name: MUX_SIGNING_KEY_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: muxSigningKeyPrivateKey
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: S3_USC_BUCKET_APPLICATION_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: s3UscBucketApplicationKey
|
||||
- name: S3_USC_BUCKET_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: s3UscBucketEndpoint
|
||||
- name: S3_USC_BUCKET_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: s3UscBucketKeyId
|
||||
- name: S3_USC_BUCKET_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: s3UscBucketName
|
||||
- name: S3_USC_BUCKET_REGION
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: s3UscBucketRegion
|
||||
- name: SENDGRID_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: sendgridApiKey
|
||||
- name: STRAPI_URL
|
||||
value: "{{ .Values.strapi.url }}"
|
||||
- name: TRANSFER_TOKEN_SALT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: strapi
|
||||
key: transferTokenSalt
|
||||
- name: PORT
|
||||
value: "{{ .Values.strapi.port }}"
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 2Gi
|
||||
restartPolicy: Always
|
||||
|
||||
|
||||
# ---
|
||||
# apiVersion: v1
|
||||
# kind: PersistentVolumeClaim
|
||||
# metadata:
|
||||
# name: strapi
|
||||
# namespace: futureporn
|
||||
# annotations:
|
||||
# meta.helm.sh/release-name: fp
|
||||
# meta.helm.sh/release-namespace: futureporn
|
||||
# labels:
|
||||
# app.kubernetes.io/managed-by: {{ .Values.managedBy }}
|
||||
# spec:
|
||||
# accessModes:
|
||||
# - ReadWriteOnce
|
||||
# resources:
|
||||
# requests:
|
||||
# storage: 100Gi
|
||||
# storageClassName: {{ .Values.storageClassName }}
|
||||
|
||||
|
||||
{{ if eq .Values.managedBy "Helm" }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: strapi
|
||||
namespace: futureporn
|
||||
annotations:
|
||||
sbtp.xyz/managed-by: "{{ .Values.managedBy }}"
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: "{{ .Values.strapi.certIssuer }}"
|
||||
spec:
|
||||
ingressClassName: "{{ .Values.strapi.ingressClassName }}"
|
||||
backend:
|
||||
serviceName: strapi
|
||||
servicePort: 1339
|
||||
tls:
|
||||
- secretName: strapi-tls
|
||||
hosts:
|
||||
- "{{ .Values.strapi.hostname }}"
|
||||
rules:
|
||||
- host: "{{ .Values.strapi.hostname }}"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: strapi
|
||||
port:
|
||||
number: 1339
|
||||
|
||||
{{ end }}
|
|
@ -1,3 +1,5 @@
|
|||
{{ if eq .Values.managedBy "Helm" }}
|
||||
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
|
@ -25,4 +27,6 @@ spec:
|
|||
tls:
|
||||
- hosts:
|
||||
- windmill2.sbtp.xyz
|
||||
secretName: windmill-tls
|
||||
secretName: windmill-tls
|
||||
|
||||
{{ end }}
|
|
@ -1,12 +1,26 @@
|
|||
# storageClassName: csi-hostpath-sc # used by minikube
|
||||
storageClassName: standard # used by Kind
|
||||
managedBy: tilt
|
||||
link2cid:
|
||||
containerName: fp/link2cid
|
||||
next:
|
||||
containerName: fp/next
|
||||
certIssuer: letsencrypt-staging
|
||||
hostname: next.futureporn.svc.cluster.local
|
||||
capture:
|
||||
containerName: fp/capture
|
||||
scout:
|
||||
containerName: fp/scout
|
||||
pubsubServerUrl: https://realtime.futureporn.svc.cluster.local/faye
|
||||
strapi:
|
||||
containerName: fp/strapi
|
||||
port: 1337
|
||||
url: http://localhost:1337
|
||||
managedBy: Dildo
|
||||
port: 1339
|
||||
url: http://localhost:1339
|
||||
certIssuer: letsencrypt-staging
|
||||
hostname: strapi.futureporn.svc.cluster.local
|
||||
ingressClassName: ngrok
|
||||
ngrok:
|
||||
hostname: grateful-engaging-cicada.ngrok-free.app
|
||||
realtime:
|
||||
containerName: fp/realtime
|
||||
adminEmail: cj@futureporn.net
|
|
@ -1,12 +1,22 @@
|
|||
storageClassName: vultr-block-storage-hdd
|
||||
link2cid:
|
||||
containerName: gitea.futureporn.net/futureporn/link2cid:latest
|
||||
scout:
|
||||
containerName: gitea.futureporn.net/futureporn/scout:latest
|
||||
pubsubServerUrl: https://realtime.futureporn.net/faye
|
||||
next:
|
||||
containerName: sjc.vultrcr.com/fpcontainers/next
|
||||
containerName: gitea.futureporn.net/futureporn/next:latest
|
||||
certIssuer: letsencrypt-staging
|
||||
hostname: next.sbtp.xyz
|
||||
capture:
|
||||
containerName: gitea.futureporn.net/futureporn/capture:latest
|
||||
strapi:
|
||||
containerName: sjc.vultrcr.com/fpcontainers/strapi
|
||||
port: 1337
|
||||
port: 1339
|
||||
url: https://portal.futureporn.net
|
||||
certIssuer: letsencrypt-prod
|
||||
hostname: strapi.sbtp.xyz
|
||||
ingressClassName: nginx
|
||||
managedBy: Helm
|
||||
adminEmail: cj@futureporn.net
|
||||
extraArgs:
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
FROM node:18-alpine
|
||||
# Install dependencies only when needed
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Enable `pnpm add --global` on Alpine Linux by setting
|
||||
# home location environment variable to a location already in $PATH
|
||||
# https://github.com/pnpm/pnpm/issues/784#issuecomment-1518582235
|
||||
ENV PNPM_HOME=/usr/local/bin
|
||||
|
||||
# update and install latest dependencies, add dumb-init package
|
||||
# add a non root user
|
||||
RUN apk update && apk upgrade && apk add dumb-init ffmpeg make gcc g++ python3
|
||||
|
||||
WORKDIR /app
|
||||
# Copy and install the dependencies for the project
|
||||
COPY ./packages/capture/package.json ./packages/capture/pnpm-lock.yaml ./
|
||||
|
||||
# Copy all other project files to working directory
|
||||
COPY ./packages/capture .
|
||||
# Run the next build process and generate the artifacts
|
||||
RUN pnpm i;
|
||||
|
||||
|
||||
# expose 3000 on container
|
||||
EXPOSE 3000
|
||||
|
||||
# set app host ,port and node env
|
||||
ENV HOSTNAME=0.0.0.0 PORT=3000 NODE_ENV=production
|
||||
# start the app with dumb init to spawn the Node.js runtime process
|
||||
# with signal support
|
||||
CMD [ "dumb-init", "node", "index.js" ]
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN apk update
|
||||
|
||||
ENV NODE_ENV=production
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN pnpm fetch
|
||||
COPY ./packages/realtime /app
|
||||
|
||||
ENTRYPOINT ["pnpm"]
|
||||
CMD ["run", "start"]
|
|
@ -0,0 +1,26 @@
|
|||
FROM node:20-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN apk update
|
||||
|
||||
|
||||
FROM base AS build
|
||||
ENV NODE_ENV=production
|
||||
COPY . /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
RUN mkdir -p /prod/scout
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN ls -la ./packages
|
||||
RUN pnpm deploy --filter=@futureporn/scout --prod /prod/scout
|
||||
# COPY pnpm-lock.yaml ./
|
||||
# RUN pnpm fetch
|
||||
# COPY ./packages/scout /app
|
||||
|
||||
|
||||
FROM base AS scout
|
||||
COPY --from=build /prod/scout /app
|
||||
WORKDIR /app
|
||||
RUN ls -la
|
||||
ENTRYPOINT ["pnpm"]
|
||||
CMD ["run", "start"]
|
|
@ -0,0 +1,19 @@
|
|||
FROM node:18-alpine3.18
|
||||
# Installing libvips-dev for sharp Compatibility
|
||||
RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev nasm bash vips-dev git
|
||||
ARG NODE_ENV=development
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
WORKDIR /opt/
|
||||
COPY ./packages/strapi-app/package.json ./packages/strapi-app/yarn.lock ./
|
||||
RUN yarn global add node-gyp
|
||||
RUN yarn config set network-timeout 600000 -g && yarn install
|
||||
ENV PATH /opt/node_modules/.bin:$PATH
|
||||
|
||||
WORKDIR /opt/app
|
||||
COPY ./packages/strapi-app/ .
|
||||
RUN chown -R node:node /opt/app
|
||||
USER node
|
||||
RUN ["yarn", "build"]
|
||||
EXPOSE 1338
|
||||
CMD ["yarn", "develop", "--debug"]
|
|
@ -0,0 +1,24 @@
|
|||
FROM node:18-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
# Installing libvips-dev for sharp Compatibility
|
||||
RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev nasm bash vips-dev git
|
||||
ARG NODE_ENV=development
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /usr/src/app/
|
||||
COPY ./packages/strapi/package.json ./packages/strapi/pnpm-lock.yaml .
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
COPY ./packages/strapi/ .
|
||||
|
||||
FROM build AS dev
|
||||
WORKDIR /app
|
||||
ENV PATH /app/node_modules/.bin:$PATH
|
||||
COPY --from=build /usr/src/app/ .
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
RUN ["pnpm", "run", "build"]
|
||||
EXPOSE 1339
|
||||
CMD ["pnpm", "run", "dev"]
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,153 @@
|
|||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node
|
|
@ -0,0 +1,25 @@
|
|||
# Capture
|
||||
|
||||
## Dev notes
|
||||
|
||||
### youtube-dl end of stream output
|
||||
|
||||
```
|
||||
[https @ 0x5646887f1580] Opening 'https://edge11-lax.live.mmcdn.com/live-hls/amlst:hotfallingdevil-sd-fdf87e5b6c880e52d38e8c94f8ebf8728c980a91d56fb4ace13748ba59012336_trns_h264/chunklist_w881713853_b5128000_t64RlBTOjMwLjA=.m3u8' for reading
|
||||
[hls @ 0x564687dd0980] Skip ('#EXT-X-VERSION:4')
|
||||
[hls @ 0x564687dd0980] Skip ('#EXT-X-DISCONTINUITY-SEQUENCE:0')
|
||||
[hls @ 0x564687dd0980] Skip ('#EXT-X-PROGRAM-DATE-TIME:2023-01-31T17:48:45.947+00:00')
|
||||
[https @ 0x5646880bf880] Opening 'https://edge11-lax.live.mmcdn.com/live-hls/amlst:hotfallingdevil-sd-fdf87e5b6c880e52d38e8c94f8ebf8728c980a91d56fb4ace13748ba59012336_trns_h264/media_w881713853_b5128000_t64RlBTOjMwLjA=_18912.ts' for reading
|
||||
[https @ 0x564688097d00] Opening 'https://edge11-lax.live.mmcdn.com/live-hls/amlst:hotfallingdevil-sd-fdf87e5b6c880e52d38e8c94f8ebf8728c980a91d56fb4ace13748ba59012336_trns_h264/media_w881713853_b5128000_t64RlBTOjMwLjA=_18913.ts' for reading
|
||||
[https @ 0x5646887f1580] Opening 'https://edge11-lax.live.mmcdn.com/live-hls/amlst:hotfallingdevil-sd-fdf87e5b6c880e52d38e8c94f8ebf8728c980a91d56fb4ace13748ba59012336_trns_h264/chunklist_w881713853_b5128000_t64RlBTOjMwLjA=.m3u8' for reading
|
||||
[https @ 0x5646886e8580] HTTP error 403 Forbidden
|
||||
[hls @ 0x564687dd0980] keepalive request failed for 'https://edge11-lax.live.mmcdn.com/live-hls/amlst:hotfallingdevil-sd-fdf87e5b6c880e52d38e8c94f8ebf8728c980a91d56fb4ace13748ba59012336_trns_h264/chunklist_w881713853_b5128000_t64RlBTOjMwLjA=.m3u8' with error: 'Server returned 403 Forbidden (access denied)' when parsing playlist
|
||||
[https @ 0x5646886ccfc0] HTTP error 403 Forbidden
|
||||
[hls @ 0x564687dd0980] Failed to reload playlist 0
|
||||
[https @ 0x5646886bf680] HTTP error 403 Forbidden
|
||||
[hls @ 0x564687dd0980] Failed to reload playlist 0
|
||||
frame= 5355 fps= 31 q=-1.0 Lsize= 71404kB time=00:02:58.50 bitrate=3277.0kbits/s speed=1.02x
|
||||
video:68484kB audio:2790kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.181873%
|
||||
[ffmpeg] Downloaded 73117881 bytes
|
||||
[download] 100% of 69.73MiB in 02:57
|
||||
```
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// import Capture from './src/Capture.js'
|
||||
// import Video from './src/Video.js'
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
dotenv.config()
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import os from 'os'
|
||||
import fs from 'node:fs'
|
||||
import { loggerFactory } from "./src/logger.js"
|
||||
import { verifyStorage } from './src/disk.js'
|
||||
import faye from 'faye'
|
||||
import { record, assertDependencyDirectory, checkFFmpeg } from './src/record.js'
|
||||
import fastq from 'fastq'
|
||||
import pRetry from 'p-retry';
|
||||
import Fastify from 'fastify';
|
||||
|
||||
|
||||
|
||||
// Create a map to store the work queues
|
||||
const workQueues = new Map();
|
||||
|
||||
|
||||
|
||||
async function captureTask (args, cb) {
|
||||
const { appContext, playlistUrl, roomName } = args;
|
||||
|
||||
try {
|
||||
const downloadStream = async () => {
|
||||
const rc = await record(appContext, playlistUrl, roomName)
|
||||
if (rc !== 0) throw new Error('ffmpeg exited irregularly (return code was other than zero)')
|
||||
}
|
||||
await pRetry(downloadStream, {
|
||||
retries: 3,
|
||||
onFailedAttempt: error => {
|
||||
appContext.logger.log({ level: 'error', message: `downloadStream attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left.` });
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
// we can get here if all retries are exhausted.
|
||||
// this could be that the stream is over, the playlistUrl might be different, etc.
|
||||
// we might have queued tasks so we don't want to crash.
|
||||
appContext.logger.log({ level: 'error', message: `downloadStream exhausted all retries.` })
|
||||
appContext.logger.log({ level: 'error', message: e })
|
||||
}
|
||||
|
||||
verifyStorage(appContext)
|
||||
|
||||
appContext.logger.log({ level: 'info', message: 'Capture task complete'})
|
||||
cb(null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Fastify is used to facilitate Docker health checks
|
||||
*
|
||||
*/
|
||||
async function initFastify(appContext) {
|
||||
appContext.fastify = Fastify({
|
||||
logger: true
|
||||
})
|
||||
|
||||
// Declare a route
|
||||
appContext.fastify.get('/health', function (_, reply) {
|
||||
reply.send({ message: 'futureporn-capture sneed' });
|
||||
})
|
||||
|
||||
// Run the server!
|
||||
appContext.fastify.listen({ port: appContext.env.PORT }, function (err, address) {
|
||||
if (err) {
|
||||
appContext.fastify.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function init () {
|
||||
|
||||
const appEnv = new Array(
|
||||
'FUTUREPORN_WORKDIR',
|
||||
'DOWNLOADER_UA',
|
||||
'PORT'
|
||||
)
|
||||
|
||||
const logger = loggerFactory({
|
||||
service: 'futureporn/capture'
|
||||
})
|
||||
|
||||
|
||||
const appContext = {
|
||||
env: appEnv.reduce((acc, ev) => {
|
||||
if (typeof process.env[ev] === 'undefined') throw new Error(`${ev} is undefined in env`);
|
||||
acc[ev] = process.env[ev];
|
||||
return acc;
|
||||
}, {}),
|
||||
logger,
|
||||
pkg: JSON.parse(fs.readFileSync('./package.json', { encoding: 'utf-8'})),
|
||||
workerId: `${os.hostname}-${createId()}`,
|
||||
};
|
||||
|
||||
await initFastify(appContext);
|
||||
|
||||
assertDependencyDirectory(appContext)
|
||||
await checkFFmpeg(appContext)
|
||||
verifyStorage(appContext)
|
||||
|
||||
return appContext
|
||||
}
|
||||
|
||||
|
||||
async function main () {
|
||||
|
||||
const appContext = await init()
|
||||
|
||||
|
||||
|
||||
appContext.logger.log({ level: 'info', message: `capture version: ${appContext.pkg.version}` })
|
||||
appContext.logger.log({ level: 'info', message: `my capture directory is ${appContext.env.FUTUREPORN_WORKDIR}` })
|
||||
|
||||
|
||||
// connect to realtime server
|
||||
appContext.pubsub.subscribe('/signals', (message) => {
|
||||
appContext.logger.log({ level: 'debug', message: JSON.stringify(message) })
|
||||
|
||||
if (
|
||||
(message?.signal === 'start') &&
|
||||
(message?.room) &&
|
||||
(message?.url.startsWith('https://'))
|
||||
) {
|
||||
|
||||
const roomName = message.room;
|
||||
const playlistUrl = message.url;
|
||||
|
||||
// Check if a work queue for the room already exists, otherwise create a new one
|
||||
if (!workQueues.has(roomName)) {
|
||||
workQueues.set(roomName, fastq(captureTask, 1));
|
||||
}
|
||||
|
||||
|
||||
// Push the task to the corresponding work queue
|
||||
workQueues.get(roomName).push({ appContext, playlistUrl, roomName });
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "futureporn-capture",
|
||||
"version": "0.1.12",
|
||||
"main": "index.js",
|
||||
"license": "Unlicense",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node --trace-warnings index",
|
||||
"test": "FUTUREPORN_WORKDIR=/home/chris/Downloads mocha",
|
||||
"integration": "FUTUREPORN_WORKDIR=/home/chris/Downloads mocha ./integration/**/*.test.js",
|
||||
"dev": "FUTUREPORN_WORKDIR=/home/chris/Downloads nodemon index"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.1.8",
|
||||
"diskusage": "^1.1.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"execa": "^6.1.0",
|
||||
"fastify": "^4.12.0",
|
||||
"fastq": "^1.15.0",
|
||||
"faye": "^1.4.0",
|
||||
"faye-websocket": "^0.11.4",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"https": "^1.0.0",
|
||||
"ioredis": "^5.2.4",
|
||||
"minimatch": "^5.1.1",
|
||||
"p-retry": "^5.1.2",
|
||||
"postgres": "^3.3.3",
|
||||
"rxjs": "^7.8.0",
|
||||
"sql": "^0.78.0",
|
||||
"winston": "^3.9.0",
|
||||
"youtube-dl-wrap": "git+https://github.com/insanity54/youtube-dl-wrap.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.7",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"mocha": "^10.2.0",
|
||||
"multiformats": "^11.0.1",
|
||||
"node-abort-controller": "^3.0.1",
|
||||
"node-fetch": "^3.3.0",
|
||||
"nodemon": "^2.0.20",
|
||||
"sinon": "^15.0.1",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"sinon-test": "^3.1.5"
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,125 @@
|
|||
|
||||
import Voddo from './Voddo.js'
|
||||
import {loggerFactory} from 'common/logger'
|
||||
|
||||
const logger = loggerFactory({
|
||||
service: 'futureporn/capture'
|
||||
})
|
||||
|
||||
export default class Capture {
|
||||
|
||||
constructor(opts) {
|
||||
this.date = opts?.date
|
||||
this.sql = opts.sql
|
||||
this.ipfs = opts.ipfs
|
||||
this.idleTimeout = opts.idleTimeout || 1000*60*15
|
||||
this.video = opts.video
|
||||
this.voddo = opts.voddo
|
||||
this.workerId = opts.workerId
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* upload VOD to ipfs
|
||||
*
|
||||
* @return {Promise}
|
||||
* @resolves {String} cid
|
||||
*/
|
||||
async upload (filename) {
|
||||
const cid = await this.ipfs.upload(filename)
|
||||
return cid
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* save Vod data to db
|
||||
*/
|
||||
async save (cid, timestamp) {
|
||||
logger.log({ level: 'debug', message: `saving ${cid} \n w/ captureDate ${timestamp}` })
|
||||
this.date = timestamp
|
||||
return await this.sql`INSERT INTO vod ( "videoSrcHash", "captureDate" ) values (${cid}, ${timestamp}) returning *`
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* advertise the vod segment(s) we captured.
|
||||
* futureporn/commander uses this data to elect one worker to upload the VOD
|
||||
*/
|
||||
async advertise () {
|
||||
const segments = await this.voddo.getRecordedSegments()
|
||||
const streams = Voddo.groupStreamSegments(segments)
|
||||
const workerId = this.workerId
|
||||
logger.log({ level: 'debug', message: `Advertising our VOD streams(s) ${JSON.stringify({segments, streams, workerId})}` })
|
||||
this.sql.notify('capture/vod/advertisement', JSON.stringify({segments, streams, workerId}))
|
||||
}
|
||||
|
||||
|
||||
listen () {
|
||||
this.sql.listen('scout/stream/stop', (data) => {
|
||||
logger.log({ level: 'debug', message: 'Scout said the stream has stopped. I will advertise the vod segment(s) I have.' })
|
||||
this.advertise()
|
||||
})
|
||||
|
||||
this.sql.listen('commander/vod/election', async (data) => {
|
||||
if (data.workerId === this.workerId) {
|
||||
logger.log({ level: 'debug', message: 'Commander elected me to process/upload' })
|
||||
this.process(await this.voddo.getFilenames())
|
||||
} else {
|
||||
logger.log({ level: 'debug', message: `Commander elected ${data.workerId} to process/upload their vod segment(s)` })
|
||||
}
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* process video(s) after end of stream
|
||||
*
|
||||
* @param {String[]} filenames
|
||||
* @returns {void}
|
||||
*/
|
||||
async process (filenames) {
|
||||
this.date = filenames[0].timestamp
|
||||
|
||||
logger.log({ level: 'debug', message: 'concatenation in progress...' })
|
||||
const file = await this.video.concat(filenames)
|
||||
|
||||
logger.log({ level: 'debug', message: `uploading ${file}` })
|
||||
const cid = await this.ipfs.upload(file)
|
||||
|
||||
logger.log({ level: 'debug', message: 'db save in progress' })
|
||||
await this.save(cid, this.date)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* download a livestream
|
||||
*
|
||||
* - initializes Voddo
|
||||
* - invokes this.process() as side effect
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
async download () {
|
||||
this.voddo.on('start', (data) => {
|
||||
logger.log({ level: 'debug', message: 'voddo started' })
|
||||
logger.log({ level: 'debug', message: data })
|
||||
this.sql.notify('capture/file', JSON.stringify(data))
|
||||
})
|
||||
this.voddo.on('stop', (report) => {
|
||||
logger.log({ level: 'debug', message: `Got a stop event from Voddo` })
|
||||
})
|
||||
logger.log({ level: 'debug', message: 'starting voddo' })
|
||||
this.voddo.start()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
|
||||
import {execa} from 'execa'
|
||||
import {loggerFactory} from 'common/logger'
|
||||
|
||||
const logger = loggerFactory({
|
||||
service: 'futureporn/capture'
|
||||
})
|
||||
|
||||
export default class Ipfs {
|
||||
constructor(opts) {
|
||||
this.multiaddr = opts?.IPFS_CLUSTER_HTTP_API_MULTIADDR
|
||||
this.username = opts?.IPFS_CLUSTER_HTTP_API_USERNAME
|
||||
this.password = opts?.IPFS_CLUSTER_HTTP_API_PASSWORD
|
||||
this.ctlExecutable = opts?.ctlExecutable || '/usr/local/bin/ipfs-cluster-ctl'
|
||||
this.ipfsExecutable = opts?.ipfsExecutable || '/usr/local/bin/ipfs'
|
||||
}
|
||||
getArgs () {
|
||||
let args = [
|
||||
'--no-check-certificate',
|
||||
'--host', this.multiaddr,
|
||||
'--basic-auth', `${this.username}:${this.password}`
|
||||
]
|
||||
return args
|
||||
}
|
||||
async upload (filename, expiryDuration = false) {
|
||||
try {
|
||||
let args = getArgs()
|
||||
|
||||
args = args.concat([
|
||||
'add',
|
||||
'--quieter',
|
||||
'--cid-version', 1
|
||||
])
|
||||
|
||||
if (expiryDuration) {
|
||||
args = args.concat(['--expire-in', expiryDuration])
|
||||
}
|
||||
|
||||
args.push(filename)
|
||||
|
||||
const { stdout } = await execa(this.ctlExecutable, args)
|
||||
return stdout
|
||||
} catch (e) {
|
||||
logger.log({ level: 'error', message: 'Error while adding file to ipfs' })
|
||||
logger.log({ level: 'error', message: e })
|
||||
}
|
||||
}
|
||||
async hash (filename) {
|
||||
try {
|
||||
const { stdout } = await execa(this.ipfsExecutable, ['add', '--quiet', '--cid-version=1', '--only-hash', filename])
|
||||
return stdout
|
||||
} catch (e) {
|
||||
logger.log({ level: 'error', message: 'Error while hashing file' })
|
||||
logger.log({ level: 'error', message: e })
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
|
||||
import { execa } from 'execa'
|
||||
import { tmpdir } from 'os'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
|
||||
export class VideoConcatError extends Error {
|
||||
constructor (msg) {
|
||||
super(msg || 'Failed to concatenate video')
|
||||
this.name = 'VideoConcatError'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default class Video {
|
||||
constructor (opts) {
|
||||
if (typeof opts.filePaths === 'undefined') throw new Error('Video must be called with opts.filePaths');
|
||||
if (typeof opts.cwd === 'undefined') throw new Error('Video must be called with opts.cwd');
|
||||
this.filePaths = opts.filePaths
|
||||
this.cwd = opts.cwd
|
||||
this.room = opts.room || 'projektmelody'
|
||||
this.execa = opts.execa || execa
|
||||
}
|
||||
|
||||
|
||||
|
||||
getFilesTxt () {
|
||||
return this.filePaths
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.map((d) => `file '${d.file}'`)
|
||||
.join('\n')
|
||||
.concat('\n')
|
||||
}
|
||||
|
||||
|
||||
getFilesFile () {
|
||||
const p = path.join(this.cwd, 'files.txt')
|
||||
fs.writeFileSync(
|
||||
p,
|
||||
this.getFilesTxt(this.filePaths),
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
return p
|
||||
}
|
||||
|
||||
async concat () {
|
||||
const target = path.join(this.cwd, `${this.room}-chaturbate-${new Date().valueOf()}.mp4`)
|
||||
|
||||
const { exitCode, killed, stdout, stderr } = await this.execa('ffmpeg', [
|
||||
'-y',
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', this.getFilesFile(this.filePaths),
|
||||
'-c', 'copy',
|
||||
target
|
||||
], {
|
||||
cwd: this.cwd
|
||||
});
|
||||
|
||||
if (exitCode !== 0 || killed !== false) {
|
||||
throw new VideoConcatError(`exitCode:${exitCode}, killed:${killed}, stdout:${stdout}, stderr:${stderr}`);
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
}
|
|
@ -0,0 +1,243 @@
|
|||
import 'dotenv/config'
|
||||
import YoutubeDlWrap from "youtube-dl-wrap";
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { AbortController } from "node-abort-controller";
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path'
|
||||
import ffmpeg from 'fluent-ffmpeg'
|
||||
import { loggerFactory } from 'common/logger'
|
||||
|
||||
const logger = loggerFactory({
|
||||
service: 'futureporn/capture'
|
||||
})
|
||||
const defaultStats = {segments:[],lastUpdatedAt:null}
|
||||
|
||||
export default class Voddo extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super()
|
||||
this.courtesyTimer = setTimeout(() => {}, 0);
|
||||
this.retryCount = 0;
|
||||
this.url = opts.url;
|
||||
this.format = opts.format || 'best';
|
||||
this.cwd = opts.cwd;
|
||||
this.ytdlee; // event emitter for ytdlwrap
|
||||
this.stats = Object.assign({}, defaultStats);
|
||||
this.abortController = new AbortController();
|
||||
this.ytdl = opts.ytdl || new YoutubeDlWrap();
|
||||
if (process.env.YOUTUBE_DL_BINARY) this.ytdl.setBinaryPath(process.env.YOUTUBE_DL_BINARY);
|
||||
}
|
||||
|
||||
static async getVideoLength (filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(filePath, function(err, metadata) {
|
||||
if (err) reject(err)
|
||||
resolve(Math.floor(metadata.format.duration*1000))
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// greets ChatGPT
|
||||
static groupStreamSegments(segments, threshold = 1000*60*60) {
|
||||
segments.sort((a, b) => a.startTime - b.startTime);
|
||||
const streams = [];
|
||||
let currentStream = [];
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const currentSegment = segments[i];
|
||||
const previousSegment = currentStream[currentStream.length - 1];
|
||||
|
||||
if (!previousSegment || currentSegment.startTime - previousSegment.endTime <= threshold) {
|
||||
currentStream.push(currentSegment);
|
||||
} else {
|
||||
streams.push(currentStream);
|
||||
currentStream = [currentSegment];
|
||||
}
|
||||
}
|
||||
|
||||
streams.push(currentStream);
|
||||
return streams;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* getRecordedStreams
|
||||
*
|
||||
* get the metadata of the videos captured
|
||||
*/
|
||||
async getRecordedSegments() {
|
||||
let f = []
|
||||
const files = await readdir(this.cwd).then((raw) => raw.filter((f) => /\.mp4$/.test(f) ))
|
||||
for (const file of files) {
|
||||
const filePath = join(this.cwd, file)
|
||||
const s = await stat(filePath)
|
||||
const videoDuration = await Voddo.getVideoLength(filePath)
|
||||
const startTime = parseInt(s.ctimeMs)
|
||||
const endTime = startTime+videoDuration
|
||||
const size = s.size
|
||||
f.push({
|
||||
startTime,
|
||||
endTime,
|
||||
file,
|
||||
size
|
||||
})
|
||||
}
|
||||
this.stats.segments = f
|
||||
|
||||
|
||||
return this.stats.segments
|
||||
}
|
||||
|
||||
isDownloading() {
|
||||
// if there are event emitter listeners for the progress event,
|
||||
// we are probably downloading.
|
||||
return (
|
||||
this.ytdlee?.listeners('progress').length !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
delayedStart() {
|
||||
// only for testing
|
||||
this.retryCount = 500
|
||||
this.courtesyTimer = this.getCourtesyTimer(() => this.download())
|
||||
}
|
||||
|
||||
|
||||
start() {
|
||||
// if download is in progress, do nothing
|
||||
if (this.isDownloading()) {
|
||||
logger.log({ level: 'debug', message: 'Doing nothing because a download is in progress.' })
|
||||
return;
|
||||
}
|
||||
|
||||
// if download is not in progress, start download immediately
|
||||
// reset the retryCount so the backoff timer resets to 1s between attempts
|
||||
this.retryCount = 0
|
||||
clearTimeout(this.courtesyTimer)
|
||||
|
||||
// create new abort controller
|
||||
//this.abortController = new AbortController() // @todo do i need this? Can't I reuse the existing this.abortController?
|
||||
|
||||
this.download()
|
||||
}
|
||||
|
||||
stop() {
|
||||
logger.log({ level: 'info', message: 'Received stop(). Stopping.' })
|
||||
clearTimeout(this.courtesyTimer)
|
||||
this.abortController.abort()
|
||||
}
|
||||
|
||||
/** generate a report **/
|
||||
getReport(errorMessage) {
|
||||
let report = {}
|
||||
report.stats = Object.assign({}, this.stats)
|
||||
report.error = errorMessage
|
||||
report.reason = (() => {
|
||||
if (errorMessage) return 'error';
|
||||
else if (this.abortController.signal.aborted) return 'aborted';
|
||||
else return 'close';
|
||||
})()
|
||||
// clear stats to prepare for next run
|
||||
this.stats = Object.assign({}, defaultStats)
|
||||
return report
|
||||
}
|
||||
|
||||
emitReport(report) {
|
||||
logger.log({ level: 'debug', message: 'EMITTING REPORT' })
|
||||
this.emit('stop', report)
|
||||
}
|
||||
|
||||
getCourtesyTimer(callback) {
|
||||
// 600000ms = 10m
|
||||
const waitTime = Math.min(600000, (Math.pow(2, this.retryCount) * 1000));
|
||||
this.retryCount += 1;
|
||||
logger.log({ level: 'debug', message: `courtesyWait for ${waitTime/1000} seconds. (retryCount: ${this.retryCount})` })
|
||||
return setTimeout(callback, waitTime)
|
||||
}
|
||||
|
||||
download() {
|
||||
const handleProgress = (progress) => {
|
||||
logger.log({ level: 'debug', message:` [*] progress event` })
|
||||
this.stats.lastUpdatedAt = Date.now(),
|
||||
this.stats.totalSize = progress.totalSize
|
||||
}
|
||||
|
||||
const handleError = (error) => {
|
||||
if (error?.message !== undefined && error.message.includes('Room is currently offline')) {
|
||||
logger.log({ level: 'debug', message: 'Handled an expected \'Room is offline\' error' })
|
||||
|
||||
} else {
|
||||
logger.log({ level: 'error', message: 'ytdl error' })
|
||||
logger.log({ level: 'error', message: error.message })
|
||||
}
|
||||
this.ytdlee.off('progress', handleProgress)
|
||||
this.ytdlee.off('handleYtdlEvent', handleYtdlEvent)
|
||||
|
||||
// restart the download after the courtesyTimeout
|
||||
this.courtesyTimer = this.getCourtesyTimer(() => this.download())
|
||||
this.emitReport(this.getReport(error.message))
|
||||
}
|
||||
|
||||
|
||||
const handleYtdlEvent = (type, data) => {
|
||||
logger.log({ level: 'debug', message: `handleYtdlEvent type: ${type}, data: ${data}` })
|
||||
logger.log({ level: 'debug', message: `handleYtdlEvent type: ${type}, data: ${data}` })
|
||||
if (type === 'download' && data.includes('Destination:')) {
|
||||
let filePath = /Destination:\s(.*)$/.exec(data)[1]
|
||||
logger.log({ level: 'debug', message: `Destination file detected: ${filePath}` })
|
||||
let datum = { file: filePath, timestamp: new Date().valueOf() }
|
||||
let segments = this.stats.segments
|
||||
segments.push(datum) && segments.length > 64 && segments.shift(); // limit the size of the segments array
|
||||
this.emit('start', datum)
|
||||
} else if (type === 'ffmpeg' && data.includes('bytes')) {
|
||||
const bytes = /(\d*)\sbytes/.exec(data)[1]
|
||||
logger.log({ level: 'debug', message: `ffmpeg reports ${bytes}`})
|
||||
let mostRecentFile = this.stats.segments[this.stats.segments.length-1]
|
||||
mostRecentFile['size'] = bytes
|
||||
logger.log({ level: 'debug', message: mostRecentFile })
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
logger.log({ level: 'debug', message: 'got a close event. handling!' });
|
||||
|
||||
this.ytdlee.off('progress', handleProgress)
|
||||
this.ytdlee.off('handleYtdlEvent', handleYtdlEvent)
|
||||
|
||||
// restart Voddo only if the close was not due to stop()
|
||||
if (!this.abortController.signal.aborted) {
|
||||
// restart the download after the courtesyTimeout
|
||||
this.courtesyTimer = this.getCourtesyTimer(() => this.download())
|
||||
}
|
||||
|
||||
this.emitReport(this.getReport())
|
||||
}
|
||||
|
||||
logger.log({ level: 'debug', message: `Downloading url:${this.url} format:${this.format}` })
|
||||
logger.log({ level: 'debug', message: JSON.stringify(this.ytdl) })
|
||||
|
||||
// sanity check. ensure cwd exists
|
||||
stat(this.cwd, (err) => {
|
||||
if (err) logger.log({ level: 'error', message: `Error while getting cwd stats of ${this.cwd} Does it exist?` })
|
||||
})
|
||||
|
||||
this.ytdlee = this.ytdl.exec(
|
||||
[this.url, '-f', this.format],
|
||||
{
|
||||
cwd: this.cwd
|
||||
},
|
||||
this.abortController.signal
|
||||
);
|
||||
this.ytdlee.on('progress', handleProgress);
|
||||
this.ytdlee.on('youtubeDlEvent', handleYtdlEvent);
|
||||
this.ytdlee.once('error', handleError);
|
||||
this.ytdlee.once('close', handleClose);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import cheerio from 'cheerio'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
export async function getRandomRoom () {
|
||||
const res = await fetch('https://chaturbate.com/')
|
||||
const body = await res.text()
|
||||
const $ = cheerio.load(body)
|
||||
let roomsRaw = $('a[data-room]')
|
||||
let rooms = []
|
||||
$(roomsRaw).each((_, e) => {
|
||||
rooms.push($(e).attr('href'))
|
||||
})
|
||||
|
||||
// greets https://stackoverflow.com/a/4435017/1004931
|
||||
var randomIndex = Math.floor(Math.random() * rooms.length);
|
||||
return rooms[randomIndex].replaceAll('/', '')
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import disk from 'diskusage';
|
||||
|
||||
|
||||
export function verifyStorage (appContext) {
|
||||
const mountPath = appContext.env.FUTUREPORN_WORKDIR
|
||||
disk.check(mountPath, (err, info) => {
|
||||
if (err) {
|
||||
appContext.logger.log({ level: 'error', message: `Error retrieving disk usage for ${mountPath}: ${err}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSize = info.total;
|
||||
const availableSize = info.available;
|
||||
const freeSize = info.free;
|
||||
|
||||
appContext.logger.log({ 'level': 'info', message: `${mountPath} Disk Usage:` });
|
||||
appContext.logger.log({ 'level': 'info', message: `Total: ${bytesToSize(totalSize)}` });
|
||||
appContext.logger.log({ 'level': 'info', message: `Free: ${bytesToSize(freeSize)}` });
|
||||
appContext.logger.log({ 'level': 'info', message: `Available: ${bytesToSize(availableSize)}` });
|
||||
|
||||
if (availableSize < 85899345920) appContext.logger.log({ 'level': 'warn', message: `⚠️ Available disk is getting low! ${bytesToSize(availableSize)}` });
|
||||
else if (availableSize < 42949672960) appContext.logger.log({ 'level': 'error', message: `⚠️☠️ AVAILABLE DISK IS TOO LOW! ${bytesToSize(availableSize)}` });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Helper function to convert bytes to human-readable format
|
||||
export function bytesToSize(bytes) {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const i = Math.floor(Math.log2(bytes) / 10);
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import winston from 'winston'
|
||||
|
||||
export const loggerFactory = (options) => {
|
||||
const mergedOptions = Object.assign({}, {
|
||||
level: 'info',
|
||||
defaultMeta: { service: 'futureporn' },
|
||||
format: winston.format.timestamp()
|
||||
}, options)
|
||||
const logger = winston.createLogger(mergedOptions);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
level: 'debug',
|
||||
format: winston.format.simple()
|
||||
}))
|
||||
} else {
|
||||
logger.add(new winston.transports.Console({
|
||||
level: 'info',
|
||||
format: winston.format.json()
|
||||
}))
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { join } from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export const getFilename = (appContext, roomName) => {
|
||||
const name = `${roomName}_${new Date().toISOString()}.ts`
|
||||
return join(appContext.env.FUTUREPORN_WORKDIR, 'recordings', name);
|
||||
}
|
||||
|
||||
|
||||
export const assertDirectory = (directoryPath) => {
|
||||
if (fs.statSync(directoryPath, { throwIfNoEntry: false }) === undefined) fs.mkdirSync(directoryPath);
|
||||
}
|
||||
|
||||
export const checkFFmpeg = async (appContext) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess = spawn('ffmpeg', ['-version']);
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
appContext.logger.log({
|
||||
level: 'error',
|
||||
message: `ffmpeg -version failed, which likely means ffmpeg is not installed or not on $PATH`,
|
||||
});
|
||||
throw new Error('ffmpeg is missing')
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
if (code !== 0) reject(`'ffmpeg -version' exited with code ${code}`)
|
||||
if (code === 0) {
|
||||
appContext.logger.log({ level: 'info', message: `ffmpeg PRESENT.` });
|
||||
resolve()
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
export const assertDependencyDirectory = (appContext) => {
|
||||
// Extract the directory path from the filename
|
||||
const directoryPath = join(appContext.env.FUTUREPORN_WORKDIR, 'recordings');
|
||||
console.log(`asserting ${directoryPath} exists`)
|
||||
|
||||
// Check if the directory exists, and create it if it doesn't
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
console.log(`Created directory: ${directoryPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const record = async (appContext, playlistUrl, roomName) => {
|
||||
if (appContext === undefined) throw new Error('appContext undef');
|
||||
if (playlistUrl === undefined) throw new Error('playlistUrl undef');
|
||||
if (roomName === undefined) throw new Error('roomName undef');
|
||||
|
||||
const filename = getFilename(appContext, roomName);
|
||||
console.log(`downloading to ${filename}`)
|
||||
|
||||
// example: `ffmpeg -headers "User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0"
|
||||
// -i ${chunkPlaylist}
|
||||
// -c:v copy
|
||||
// -c:a copy
|
||||
// -movflags faststart
|
||||
// -y
|
||||
// -f mpegts
|
||||
// ./my-recording.ts`
|
||||
const ffmpegProcess = spawn('ffmpeg', [
|
||||
'-headers', `"User-Agent: ${appContext.env.DOWNLOADER_UA}"`,
|
||||
'-i', playlistUrl,
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'copy',
|
||||
'-movflags', 'faststart',
|
||||
'-y',
|
||||
'-f', 'mpegts',
|
||||
filename
|
||||
], {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpegProcess.once('exit', (code) => {
|
||||
resolve(code)
|
||||
})
|
||||
})
|
||||
|
||||
// ffmpegProcess.on('data', (data) => {
|
||||
// console.log(data.toString());
|
||||
// });
|
||||
|
||||
|
||||
// Optional: Handle other events such as 'error', 'close', etc.
|
||||
// @todo this needs to be handled outside this function
|
||||
// otherwise this function is not testable
|
||||
// ffmpegProcess.on('exit', (code, signal) => {
|
||||
// // Retry the download using exponential backoff if the process exits for any reason
|
||||
// console.log(`ffmpeg exited with code ${code} and signal ${signal}`)
|
||||
// retryDownload(appContext, playlistUrl, roomName);
|
||||
// });
|
||||
|
||||
// return ffmpegProcess;
|
||||
}
|
||||
|
||||
|
||||
const calculateExponentialBackoffDelay = (attemptNumber) => {
|
||||
return Math.pow(2, attemptNumber) * 1000;
|
||||
};
|
||||
|
||||
const retryDownload = (appContext, playlistUrl, roomName, attemptNumber = 1, maxAttempts = 3) => {
|
||||
const delay = calculateExponentialBackoffDelay(attemptNumber);
|
||||
|
||||
appContext.logger.log({ level: 'debug', message: `Retrying download in ${delay / 1000} seconds...` });
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Retrying download...');
|
||||
record(appContext, playlistUrl, roomName, attemptNumber + 1);
|
||||
}, delay);
|
||||
};
|
|
@ -0,0 +1,147 @@
|
|||
|
||||
import Video from '../src/Video.js'
|
||||
import Capture from '../src/Capture.js'
|
||||
import Ipfs from '../src/Ipfs.js'
|
||||
import chai, { expect } from 'chai'
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'node:path'
|
||||
import sinon from 'sinon'
|
||||
import sinonChai from 'sinon-chai'
|
||||
import { CID } from 'multiformats/cid'
|
||||
import Voddo from '../src/Voddo.js'
|
||||
import EventEmitter from 'node:events'
|
||||
import postgres from 'postgres'
|
||||
|
||||
chai.use(sinonChai)
|
||||
|
||||
const Timer = setTimeout(()=>{},0).constructor
|
||||
const fixtureDate = 1581117660000
|
||||
const cidFixture = 'bafybeid3mg5lzrvnmpfi5ftwhiupp7i5bgkmdo7dnlwrvklbv33telrrry'
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('Capture', function () {
|
||||
|
||||
let clock
|
||||
|
||||
const sandbox = sinon.createSandbox()
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
clock = sandbox.useFakeTimers({
|
||||
toFake: ["setTimeout", "setInterval"],
|
||||
shouldAdvanceTime: false
|
||||
});
|
||||
|
||||
// // const sql = postgres({
|
||||
// // idle_timeout: 1
|
||||
// // })
|
||||
|
||||
// let pgStub = (opts) => {
|
||||
// let sql = (args) => {}
|
||||
// return sql
|
||||
// }
|
||||
const sqlRaw = postgres()
|
||||
const sql = sandbox.stub(sqlRaw)
|
||||
// sql.listen.resolves(fixtureDate)
|
||||
// sql.notify.resolves(92834)
|
||||
// sinon.stub(postgres, 'notify')
|
||||
// sinon.createStubInstance(postgres)
|
||||
// sql
|
||||
// .withArgs('INSERT INTO vod ( videoSrcHash, captureDate ) values (bafybeid3mg5lzrvnmpfi5ftwhiupp7i5bgkmdo7dnlwrvklbv33telrrry, 1581117660000) returning *')
|
||||
// .resolves({ msg: 'idk' })
|
||||
// sinon.stub(sql, 'notify').returns()
|
||||
|
||||
|
||||
|
||||
|
||||
// const ipfs = sandbox.createStubInstance(Ipfs)
|
||||
// ipfs.upload.withArgs('/tmp/mycoolfile.mp4').resolves(cidFixture)
|
||||
// capture = new Capture({
|
||||
// sql,
|
||||
// ipfs,
|
||||
// video,
|
||||
// voddo
|
||||
// })
|
||||
// sandbox.stub(capture, 'process').resolves()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
|
||||
|
||||
describe('upload', function () {
|
||||
it('should upload a video to ipfs', async function () {
|
||||
|
||||
const sqlRaw = postgres()
|
||||
const sql = sandbox.stub(sqlRaw)
|
||||
|
||||
const video = sandbox.stub()
|
||||
const voddo = sandbox.createStubInstance(Voddo)
|
||||
voddo.on.callThrough()
|
||||
voddo.emit.callThrough()
|
||||
voddo.listeners.callThrough()
|
||||
voddo.listenerCount.callThrough()
|
||||
|
||||
|
||||
voddo.start.callsFake(() => {
|
||||
voddo.emit('start', { file: '/tmp/burrito.mp4', timestamp: 1 })
|
||||
})
|
||||
|
||||
const ipfs = sandbox.createStubInstance(Ipfs)
|
||||
ipfs.upload.withArgs('/tmp/mycoolfile.mp4').resolves(cidFixture)
|
||||
const capture = new Capture({
|
||||
sql,
|
||||
ipfs,
|
||||
video,
|
||||
voddo
|
||||
})
|
||||
|
||||
const cid = await capture.upload('/tmp/mycoolfile.mp4')
|
||||
expect(() => CID.parse(cid), `The IPFS CID '${cid}' is invalid.`).to.not.throw()
|
||||
expect(capture.ipfs.upload).calledOnce
|
||||
})
|
||||
})
|
||||
describe('save', function () {
|
||||
it('should save to db', async function () {
|
||||
|
||||
const sqlRaw = postgres()
|
||||
const sql = sandbox.stub(sqlRaw)
|
||||
|
||||
const video = sandbox.stub()
|
||||
const voddo = sandbox.createStubInstance(Voddo)
|
||||
voddo.on.callThrough()
|
||||
voddo.emit.callThrough()
|
||||
voddo.listeners.callThrough()
|
||||
voddo.listenerCount.callThrough()
|
||||
|
||||
|
||||
voddo.start.callsFake(() => {
|
||||
voddo.emit('start', { file: '/tmp/burrito.mp4', timestamp: 1 })
|
||||
})
|
||||
|
||||
const ipfs = sandbox.createStubInstance(Ipfs)
|
||||
ipfs.upload.withArgs('/tmp/mycoolfile.mp4').resolves(cidFixture)
|
||||
const capture = new Capture({
|
||||
sql,
|
||||
ipfs,
|
||||
video,
|
||||
voddo
|
||||
})
|
||||
|
||||
// I can't stub sql`` because of that template string override so i'm just stubbing capture.save
|
||||
// I think this is an evergreen test ¯\_(ツ)_/¯
|
||||
sandbox.stub(capture, 'save').resolves([
|
||||
{ id: 1, cid: cidFixture, captureDate: fixtureDate }
|
||||
])
|
||||
const vod = await capture.save(cidFixture, fixtureDate)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
|
@ -0,0 +1,86 @@
|
|||
|
||||
import 'dotenv/config'
|
||||
import Video from '../src/Video.js'
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import fs from 'node:fs'
|
||||
import sinon from 'sinon'
|
||||
import sinonChai from 'sinon-chai'
|
||||
import chai, { expect } from 'chai'
|
||||
|
||||
chai.use(sinonChai);
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
||||
|
||||
const dataFixture = [
|
||||
{
|
||||
timestamp: 1,
|
||||
file: 'mock-stream0.mp4'
|
||||
}, {
|
||||
timestamp: 2,
|
||||
file: 'mock-stream1.mp4'
|
||||
}, {
|
||||
timestamp: 3,
|
||||
file: 'mock-stream2.mp4'
|
||||
}
|
||||
]
|
||||
|
||||
describe('Video', function () {
|
||||
|
||||
let video
|
||||
|
||||
before(() => {
|
||||
// copy files to /tmp so we dont clutter the fixtures dir
|
||||
// and simulate cwd being process.env.FUTUREPORN_TMP
|
||||
dataFixture.forEach((d) => {
|
||||
fs.copyFileSync(
|
||||
path.join(__dirname, 'fixtures', d.file),
|
||||
path.join(os.tmpdir(), d.file)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
video = new Video({
|
||||
cwd: os.tmpdir(),
|
||||
filePaths: dataFixture,
|
||||
execa: sinon.fake.resolves({ exitCode: 0, killed: false, stdout: "i am so horni rn", stderr: null })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
console.log('>> sinon.restore! (afterEach)')
|
||||
sinon.restore();
|
||||
})
|
||||
|
||||
|
||||
describe('getFilesTxt', function () {
|
||||
it('should generate contents suitable for input to `ffmpeg -f concat`', function () {
|
||||
const txt = video.getFilesTxt()
|
||||
expect(txt).to.deep.equal("file 'mock-stream0.mp4'\nfile 'mock-stream1.mp4'\nfile 'mock-stream2.mp4'\n")
|
||||
})
|
||||
})
|
||||
|
||||
describe('concat', function () {
|
||||
it('should join multiple videos into one', async function () {
|
||||
const file = await video.concat()
|
||||
expect(typeof file === 'string').to.be.true
|
||||
expect(video.execa).calledOnce
|
||||
expect(file).to.match(/\.mp4$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilesFile', function () {
|
||||
it('should create a files.txt and return the path', async function () {
|
||||
const file = await video.getFilesFile()
|
||||
expect(typeof file === 'string').to.be.true
|
||||
expect(file).to.equal(path.join(os.tmpdir(), 'files.txt'))
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,491 @@
|
|||
import 'dotenv/config'
|
||||
import Voddo from '../src/Voddo.js'
|
||||
import chai, { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import YoutubeDlWrap from 'youtube-dl-wrap'
|
||||
import {
|
||||
AbortController
|
||||
} from "node-abort-controller";
|
||||
import {
|
||||
EventEmitter
|
||||
} from 'events'
|
||||
import debugFactory from 'debug'
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import sinonChai from 'sinon-chai'
|
||||
import sinonTest from "sinon-test";
|
||||
import path from 'path'
|
||||
|
||||
chai.use(sinonChai);
|
||||
|
||||
const test = sinonTest(sinon, {
|
||||
toFake: ["setTimeout", "setInterval"],
|
||||
shouldAdvanceTime: false
|
||||
});
|
||||
const debug = debugFactory('voddo')
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
describe('Voddo', function() {
|
||||
|
||||
|
||||
describe('groupStreamSegments', function () {
|
||||
it('should separate two stream data objects', function () {
|
||||
const fixture = [{
|
||||
"startTime": 1675386000000,
|
||||
"file": "projektmelody 2023-02-02 17_00-projektmelody.mp4",
|
||||
"size": 550799038,
|
||||
"endTime": 1675391400000,
|
||||
}, {
|
||||
"startTime": 1675391405000,
|
||||
"file": "projektmelody 2023-02-02 18_30-projektmelody.mp4",
|
||||
"size": 6556534941,
|
||||
"endTime": 1675396800000
|
||||
}, {
|
||||
"startTime": 1675368000000,
|
||||
"file": "projektmelody 2023-02-02 12_00-projektmelody.mp4",
|
||||
"size": 6556534941,
|
||||
"endTime": 1675378800000
|
||||
}]
|
||||
|
||||
const streams = Voddo.groupStreamSegments(fixture)
|
||||
expect(streams).to.deep.equal([
|
||||
[
|
||||
{
|
||||
"startTime": 1675368000000,
|
||||
"file": "projektmelody 2023-02-02 12_00-projektmelody.mp4",
|
||||
"size": 6556534941,
|
||||
"endTime": 1675378800000
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"startTime": 1675386000000,
|
||||
"file": "projektmelody 2023-02-02 17_00-projektmelody.mp4",
|
||||
"size": 550799038,
|
||||
"endTime": 1675391400000,
|
||||
}, {
|
||||
"startTime": 1675391405000,
|
||||
"file": "projektmelody 2023-02-02 18_30-projektmelody.mp4",
|
||||
"size": 6556534941,
|
||||
"endTime": 1675396800000
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// let clock;
|
||||
|
||||
// beforeEach(function() {
|
||||
// clock = sinon.useFakeTimers({
|
||||
// toFake: ["setTimeout", "setInterval"],
|
||||
// shouldAdvanceTime: false
|
||||
// });
|
||||
// })
|
||||
|
||||
// afterEach(() => {
|
||||
// sinon.restore()
|
||||
// })
|
||||
|
||||
|
||||
|
||||
// Something faulty with Voddo or sinon or mocha, not sure.
|
||||
// When running by itself, test succeeds. When running with 'should start and stop stream download',
|
||||
// voddo.stats gets set to whatever that test sets it to. So bizarre, it's like the same Voddo class instance
|
||||
// exists in two different tests even though they are named differently.
|
||||
// Even though they are not in global scope. Even though each was called with `new Voddo(...)`
|
||||
// Doesn't matter if I wrap both in sinon-test. Same leaky problem.
|
||||
// Doesn't matter if I sinon.restore() afterEach. Same leaky problem.
|
||||
// Doesn't matter if I manually set up a sinon sandbox. Same leaky problem.
|
||||
// Fuck event emitters. I love their utility but I don't know how the fuck they are supposed to be tested.
|
||||
// Solution might just call for a rewrite of Voddo, or perhaps deleting Voddo in favor of Capture
|
||||
// For now, I'm moving forward because Voddo works even though this test does not.
|
||||
describe('getRecordedSegments', function() {
|
||||
xit('should populate it\'s log if log is empty', async function () {
|
||||
const voddo = new Voddo({
|
||||
url: 'https://example.com',
|
||||
cwd: join(__dirname, 'fixtures')
|
||||
})
|
||||
const streams = await voddo.getRecordedSegments()
|
||||
console.log(streams)
|
||||
expect(streams.length).to.equal(3)
|
||||
expect(streams[0]).to.have.property('startTime')
|
||||
expect(streams[0]).to.have.property('file')
|
||||
expect(streams[0]).to.have.property('size')
|
||||
})
|
||||
xit('should use Voddo\'s stats history to get filenames of only the most recent stream', async function() {
|
||||
const sb = sinon.createSandbox()
|
||||
const viddo = new Voddo({
|
||||
url: 'https://example.com',
|
||||
cwd: '~/Downloads'
|
||||
})
|
||||
sb.stub(viddo, 'stats').value({
|
||||
segments: [{
|
||||
startTime: 1674147647000,
|
||||
size: 192627,
|
||||
file: 'projektmelody 2023-01-19 17_00-projektmelody.mp4'
|
||||
}, {
|
||||
startTime: 1674151247000,
|
||||
size: 192627,
|
||||
file: 'projektmelody 2023-01-19 18_00-projektmelody.mp4'
|
||||
}, {
|
||||
startTime: 1674154847000,
|
||||
size: 192627,
|
||||
file: 'projektmelody 2023-01-19 19_00-projektmelody.mp4'
|
||||
}, {
|
||||
file: 'projektmelody 2023-01-20 20_10-projektmelody.mp4',
|
||||
size: 192627,
|
||||
startTime: 1674245400000,
|
||||
}, {
|
||||
file: 'projektmelody 2023-01-20 21_10-projektmelody.mp4',
|
||||
size: 192627,
|
||||
startTime: 1674249000000,
|
||||
}, {
|
||||
file: 'projektmelody 2023-01-20 22_10-projektmelody.mp4',
|
||||
size: 192627,
|
||||
startTime: 1674252600000,
|
||||
}]
|
||||
})
|
||||
|
||||
const filenames = await viddo.getRecordedSegments()
|
||||
sb.restore()
|
||||
expect(filenames).to.have.lengthOf(3)
|
||||
expect(filenames).to.deep.equal([{
|
||||
file: 'projektmelody 2023-01-20 20_10-projektmelody.mp4',
|
||||
size: 192627,
|
||||
startTime: 1674245400000,
|
||||
}, {
|
||||
file: 'projektmelody 2023-01-20 21_10-projektmelody.mp4',
|
||||
size: 192627,
|
||||
startTime: 1674249000000,
|
||||
}, {
|
||||
file: 'projektmelody 2023-01-20 22_10-projektmelody.mp4',
|
||||
size: 192627,
|
||||
startTime: 1674252600000,
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
xit('should keep a log of the files downloaded', function(done) {
|
||||
const ee = new EventEmitter()
|
||||
|
||||
|
||||
const ytdl = sinon.createStubInstance(YoutubeDlWrap)
|
||||
ytdl.exec.returns(ee)
|
||||
|
||||
|
||||
const times = [
|
||||
1000, // start
|
||||
1000 * 60 * 60 * 1, // stop
|
||||
1000 * 60 * 60 * 1 + 1, // start
|
||||
1000 * 60 * 60 * 2, // stop
|
||||
1000 * 60 * 60 * 3 + 1, // start
|
||||
1000 * 60 * 60 * 4 // stop
|
||||
]
|
||||
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-18 21_10-projektmelody.mp4')
|
||||
}, times[0])
|
||||
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('close')
|
||||
}, times[1])
|
||||
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-18 22_10-projektmelody.mp4')
|
||||
}, times[2])
|
||||
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('close')
|
||||
}, times[3])
|
||||
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-18 23_10-projektmelody.mp4')
|
||||
}, times[4])
|
||||
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('close')
|
||||
}, times[5])
|
||||
|
||||
|
||||
let url = `https://chaturbate.com/projektmelody`
|
||||
let cwd = process.env.FUTUREPORN_WORKDIR || '/tmp'
|
||||
const voddo = new Voddo({
|
||||
url: url,
|
||||
format: 'best',
|
||||
cwd: cwd,
|
||||
ytdl
|
||||
})
|
||||
|
||||
voddo.once('start', (data) => {
|
||||
expect(data).to.have.property('file')
|
||||
expect(data).to.have.property('timestamp')
|
||||
|
||||
voddo.once('start', (data) => {
|
||||
expect(data).to.have.property('file')
|
||||
expect(data).to.have.property('timestamp')
|
||||
|
||||
voddo.once('start', (data) => {
|
||||
expect(data).to.have.property('file')
|
||||
expect(data).to.have.property('timestamp')
|
||||
|
||||
voddo.once('stop', function(report) {
|
||||
debug(report)
|
||||
expect(report).to.have.property('stats')
|
||||
expect(report.stats).to.have.property('files')
|
||||
expect(report.stats.files).to.have.lengthOf(3)
|
||||
debug(report.stats.files)
|
||||
expect(report.stats.files[0]).to.include({
|
||||
file: 'projektmelody 2023-01-18 21_10-projektmelody.mp4'
|
||||
})
|
||||
|
||||
expect(ytdl.exec).calledThrice
|
||||
|
||||
console.log('>>WE ARE DONE')
|
||||
expect(this.clock.countTimers()).to.equal(0)
|
||||
done()
|
||||
})
|
||||
clock.tick(times[5]) // stop
|
||||
|
||||
})
|
||||
clock.tick(times[3]) // stop
|
||||
clock.tick(times[4]) // start
|
||||
|
||||
})
|
||||
clock.tick(times[1]) // stop
|
||||
clock.tick(times[2]) // start
|
||||
|
||||
})
|
||||
|
||||
|
||||
voddo.start()
|
||||
expect(ytdl.exec).calledOnce
|
||||
|
||||
clock.tick(times[0])
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
||||
|
||||
xit('should keep a log of the files downloaded', function(done) {
|
||||
this.timeout(5000)
|
||||
// https://github.com/insanity54/futureporn/issues/13
|
||||
const ytdlStub = sinon.createStubInstance(YoutubeDlWrap)
|
||||
ytdlStub.exec
|
||||
.onCall(0)
|
||||
.callsFake(function(args, opts, aborter) {
|
||||
let ee = new EventEmitter()
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-18 21_10-projektmelody.mp4')
|
||||
}, 50)
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('close')
|
||||
}, 100)
|
||||
return ee
|
||||
})
|
||||
.onCall(1)
|
||||
.callsFake(function(args, opts, aborter) {
|
||||
let ee = new EventEmitter()
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-18 22_10-projektmelody.mp4')
|
||||
}, 50)
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('close')
|
||||
}, 100)
|
||||
return ee
|
||||
})
|
||||
.onCall(2)
|
||||
.callsFake(function(args, opts, aborter) {
|
||||
let ee = new EventEmitter()
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-18 23_10-projektmelody.mp4')
|
||||
}, 50)
|
||||
clock.setTimeout(() => {
|
||||
ee.emit('close')
|
||||
}, 100)
|
||||
return ee
|
||||
})
|
||||
let url = `https://chaturbate.com/projektmelody`
|
||||
let cwd = process.env.FUTUREPORN_WORKDIR || '/tmp'
|
||||
|
||||
const voddo = new Voddo({
|
||||
url: url,
|
||||
format: 'best',
|
||||
cwd: cwd,
|
||||
ytdl: ytdlStub
|
||||
})
|
||||
|
||||
// expect(clock.countTimers()).to.equal(0)
|
||||
voddo.once('start', function(data) {
|
||||
expect(data).to.have.property('file')
|
||||
expect(data).to.have.property('timestamp')
|
||||
|
||||
clock.next()
|
||||
clock.next()
|
||||
voddo.once('start', function(data) {
|
||||
expect(data).to.have.property('file')
|
||||
expect(data).to.have.property('timestamp')
|
||||
|
||||
voddo.once('start', function(data) {
|
||||
debug('fake start?')
|
||||
expect(data).to.have.property('file')
|
||||
expect(data).to.have.property('timestamp')
|
||||
|
||||
voddo.once('stop', function(report) {
|
||||
debug(report)
|
||||
expect(report).to.have.property('stats')
|
||||
expect(report.stats).to.have.property('files')
|
||||
expect(report.stats.files).to.have.lengthOf(3)
|
||||
debug(report.stats.files)
|
||||
expect(report.stats.files[0]).to.include({
|
||||
file: 'projektmelody 2023-01-18 21_10-projektmelody.mp4'
|
||||
})
|
||||
|
||||
sinon.assert.calledThrice(ytdlStub.exec)
|
||||
expect(this.clock.countTimers()).to.equal(0)
|
||||
done()
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
voddo.start()
|
||||
})
|
||||
|
||||
|
||||
it('should start and stop stream download', test(function(done) {
|
||||
|
||||
const sandbox = this
|
||||
|
||||
const ee = new EventEmitter()
|
||||
|
||||
const ytdl = this.createStubInstance(YoutubeDlWrap);
|
||||
ytdl.exec.returns(ee)
|
||||
|
||||
|
||||
const url = 'https://chaturbate.com/projektmelody'
|
||||
const format = 'best'
|
||||
const cwd = '/tmp'
|
||||
const v = new Voddo({
|
||||
url,
|
||||
format,
|
||||
cwd,
|
||||
ytdl
|
||||
})
|
||||
console.log(v.stats)
|
||||
|
||||
v.once('stop', function(data) {
|
||||
console.log('ffffff')
|
||||
console.log(this)
|
||||
expect(this.abortController.signal.aborted, 'abortController did not abort').to.be.true
|
||||
expect(sandbox.clock.countTimers()).to.equal(0)
|
||||
done()
|
||||
})
|
||||
v.once('start', function(data) {
|
||||
console.log('STARRRRRT')
|
||||
expect(data).to.have.property('file')
|
||||
expect(data).to.have.property('timestamp')
|
||||
expect(this).to.have.property('abortController')
|
||||
console.log('ey cool, voddo started')
|
||||
})
|
||||
v.start()
|
||||
|
||||
const times = [
|
||||
500,
|
||||
1000,
|
||||
2000
|
||||
]
|
||||
|
||||
this.clock.setTimeout(() => {
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-18 21_10-projektmelody.mp4')
|
||||
}, times[0])
|
||||
|
||||
this.clock.setTimeout(() => {
|
||||
v.stop()
|
||||
}, times[1])
|
||||
|
||||
this.clock.setTimeout(() => {
|
||||
ee.emit('close')
|
||||
}, times[2])
|
||||
|
||||
this.clock.tick(times[0]) // start
|
||||
this.clock.tick(times[1]) // stop
|
||||
this.clock.tick(times[2]) // close
|
||||
|
||||
}))
|
||||
|
||||
|
||||
xit('should retry when a stream closes', function(done) {
|
||||
|
||||
const ytdlStub = sinon.createStubInstance(YoutubeDlWrap);
|
||||
ytdlStub.exec
|
||||
.onCall(0)
|
||||
.callsFake(function(args, opts, aborter) {
|
||||
debug(' [test] callsFake 0')
|
||||
let ee = new EventEmitter()
|
||||
setTimeout(() => {
|
||||
console.log('should retry when a stream closes -- emission')
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-17 19_39-projektmelody.mp4')
|
||||
}, 100)
|
||||
setTimeout(() => {
|
||||
console.log('should retry when a stream closes -- emission')
|
||||
// this simulates youtube-dl closing
|
||||
// (NOT Voddo closing)
|
||||
ee.emit('close')
|
||||
}, 550)
|
||||
return ee
|
||||
})
|
||||
.onCall(1)
|
||||
.callsFake(function(args, opts, aborter) {
|
||||
debug(' [test] callsFake 1')
|
||||
let ee = new EventEmitter()
|
||||
setTimeout(() => {
|
||||
ee.emit('youtubeDlEvent', 'download', ' Destination: projektmelody 2023-01-17 19_45-projektmelody.mp4')
|
||||
}, 100)
|
||||
return ee
|
||||
})
|
||||
let url = `https://chaturbate.com/projektmelody`
|
||||
let cwd = process.env.FUTUREPORN_WORKDIR || '/tmp'
|
||||
let abortController = new AbortController()
|
||||
|
||||
const voddo = new Voddo({
|
||||
url: url,
|
||||
format: 'best',
|
||||
cwd: cwd,
|
||||
ytdl: ytdlStub
|
||||
})
|
||||
|
||||
voddo.once('start', function(data) {
|
||||
debug(' [test] voddo <<<<<-----')
|
||||
expect(data).to.have.property('file')
|
||||
expect(data).to.have.property('timestamp')
|
||||
|
||||
voddo.once('start', function(data) {
|
||||
debug(' [test] restarted after dl close! (expected) <<<<<-----')
|
||||
|
||||
sinon.assert.calledTwice(ytdlStub.exec)
|
||||
expect(this.clock.countTimers()).to.equal(0)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
voddo.start()
|
||||
|
||||
clock.next()
|
||||
clock.next()
|
||||
clock.next()
|
||||
clock.next()
|
||||
clock.next()
|
||||
|
||||
})
|
||||
|
||||
})
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,123 @@
|
|||
import 'dotenv/config'
|
||||
import chai, { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import sinonChai from 'sinon-chai'
|
||||
import { CID } from 'multiformats/cid'
|
||||
import EventEmitter from 'node:events'
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'node:path'
|
||||
import postgres from 'postgres'
|
||||
import Capture from '../src/Capture.js'
|
||||
import Voddo from '../src/Voddo.js'
|
||||
import Video from '../src/Video.js'
|
||||
|
||||
chai.use(sinonChai)
|
||||
|
||||
if (typeof process.env.POSTGRES_PASSWORD === 'undefined') throw new Error('missing POSTGRES_PASSWORD');
|
||||
if (typeof process.env.POSTGRES_USERNAME === 'undefined') throw new Error('missing POSTGRES_USERNAME');
|
||||
|
||||
|
||||
const cidFixture = 'bafybeid3mg5lzrvnmpfi5ftwhiupp7i5bgkmdo7dnlwrvklbv33telrrry'
|
||||
const inputFixture = 'projektmelody 3021-10-16 06-16.mp4'
|
||||
const outputFixture = 'projektmelody-chaturbate-30211016T000000Z.mp4'
|
||||
const timestampFixture = 33191316900000
|
||||
|
||||
describe('Capture integration', function () {
|
||||
|
||||
let clock
|
||||
|
||||
beforeEach(() => {
|
||||
clock = sinon.useFakeTimers({
|
||||
toFake: ["setTimeout", "setInterval"],
|
||||
shouldAdvanceTime: false
|
||||
});
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it('end of stream behavior', async function() {
|
||||
const ipfsClusterUpload = sinon.mock()
|
||||
.withExactArgs(outputFixture)
|
||||
.resolves(cidFixture)
|
||||
|
||||
const sql = postgres({
|
||||
username: process.env.POSTGRES_USERNAME,
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
host: process.env.POSTGRES_HOST,
|
||||
database: 'futureporn',
|
||||
idle_timeout: 1
|
||||
})
|
||||
|
||||
|
||||
const voddo = sinon.createStubInstance(Voddo)
|
||||
voddo.on.callThrough()
|
||||
voddo.off.callThrough()
|
||||
voddo.emit.callThrough()
|
||||
voddo.listeners.callThrough()
|
||||
voddo.listenerCount.callThrough()
|
||||
voddo.getFilenames.returns([{
|
||||
timestamp: timestampFixture,
|
||||
filename: inputFixture
|
||||
}])
|
||||
|
||||
const video = sinon.createStubInstance(Video)
|
||||
video.concat.resolves(outputFixture)
|
||||
|
||||
const capture = new Capture({
|
||||
voddo,
|
||||
sql,
|
||||
ipfsClusterUpload,
|
||||
video
|
||||
})
|
||||
|
||||
capture.download()
|
||||
voddo.emit('stop', {
|
||||
reason: 'close',
|
||||
stats: {
|
||||
filenames: [
|
||||
inputFixture
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
clock.next() // actionTimer elapse
|
||||
|
||||
|
||||
expect(clock.countTimers()).to.equal(0)
|
||||
clock.restore()
|
||||
|
||||
// gotta wait to verify otherwise verification
|
||||
// occurs before ipfsClusterUpload has a chance
|
||||
// to be invoked.
|
||||
//
|
||||
// (not sure why)
|
||||
//
|
||||
// maybe we're waiting for the
|
||||
// concat promise to resolve?
|
||||
|
||||
await Promise.resolve(() => {
|
||||
expect(ipfsClusterUpload).calledOnce
|
||||
})
|
||||
|
||||
// Capture.save is called as a side effect
|
||||
// of Capture.process
|
||||
// which is called as a side effect of Capture.download
|
||||
// so we have to wait for it to complete
|
||||
// this is not ideal because there is potential
|
||||
// to not wait long enough
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
|
||||
const rows = await sql`SELECT "videoSrcHash" FROM vod WHERE "videoSrcHash" = ${cidFixture}`
|
||||
|
||||
expect(rows[0]).to.exist
|
||||
expect(rows[0]).to.have.property('videoSrcHash', cidFixture)
|
||||
|
||||
|
||||
|
||||
})
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
|
||||
import Ipfs from '../src/Ipfs.js'
|
||||
import { expect } from 'chai'
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ipfsExecutable = '/home/chris/.local/bin/ipfs'
|
||||
|
||||
describe('Ipfs', function() {
|
||||
describe('hash', function () {
|
||||
it('should hash a file and return the v1 CID', async function () {
|
||||
const ipfs = new Ipfs({ ipfsExecutable })
|
||||
const cid = await ipfs.hash(path.join(__dirname, '../test/fixtures/mock-stream0.mp4'))
|
||||
expect(cid).to.equal('bafkreihfbftehabfrakhr6tmbx72inewwpayw6cypwgm6lbhbf7mxm7wni')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,62 @@
|
|||
import 'dotenv/config'
|
||||
import Voddo from '../src/Voddo.js'
|
||||
import {
|
||||
expect
|
||||
} from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import YoutubeDlWrap from 'youtube-dl-wrap'
|
||||
import {
|
||||
EventEmitter
|
||||
} from 'events'
|
||||
import { getRandomRoom } from '../src/cb.js'
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
||||
describe('voddo', function() {
|
||||
|
||||
|
||||
describe('getVideoLength', function () {
|
||||
it('should return the video length in ms', async function () {
|
||||
const fixtureFile = path.join(__dirname, '..', 'test', 'fixtures', 'mock-stream0.mp4')
|
||||
const length = await Voddo.getVideoLength(fixtureFile)
|
||||
expect(length).to.equal(3819)
|
||||
})
|
||||
})
|
||||
|
||||
it('aborted stream', function(done) {
|
||||
this.timeout(10000)
|
||||
|
||||
getRandomRoom().then((room) => {
|
||||
console.log(room)
|
||||
const abortController = new AbortController()
|
||||
|
||||
const url = `https://chaturbate.com/${room}`
|
||||
const format = 'best'
|
||||
const cwd = '/tmp'
|
||||
const voddo = new Voddo({
|
||||
url,
|
||||
format,
|
||||
cwd
|
||||
})
|
||||
|
||||
|
||||
voddo.once('stop', function(data) {
|
||||
console.log('f in chat')
|
||||
expect(voddo.stats.files[0]).to.have.property('size')
|
||||
done()
|
||||
})
|
||||
|
||||
voddo.start()
|
||||
|
||||
setTimeout(() => {
|
||||
voddo.stop()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
})
|
|
@ -0,0 +1,35 @@
|
|||
import { record, assertDependencyDirectory } from '../../src/record.js'
|
||||
import { getRandomRoom } from '../../src/cb.js'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { execa } from 'execa'
|
||||
|
||||
describe('record', function() {
|
||||
it('should record a file to disk', async function () {
|
||||
this.timeout(1000*60)
|
||||
const roomName = await getRandomRoom()
|
||||
console.log(`roomName:${roomName}`)
|
||||
const appContext = {
|
||||
env: {
|
||||
FUTUREPORN_WORKDIR: os.tmpdir(),
|
||||
DOWNLOADER_UA: "Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0"
|
||||
},
|
||||
logger: {
|
||||
log: (msg) => { console.log(JSON.stringify(msg)) }
|
||||
}
|
||||
}
|
||||
console.log(appContext)
|
||||
const { stdout } = await execa('yt-dlp', ['-g', `https://chaturbate.com/${roomName}`])
|
||||
const playlistUrl = stdout.trim()
|
||||
console.log(`playlistUrl:${playlistUrl}`)
|
||||
assertDependencyDirectory(appContext)
|
||||
const ffmpegProc = record(appContext, playlistUrl, roomName)
|
||||
// console.log(ffmpegProc)
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
ffmpegProc.kill('SIGINT')
|
||||
resolve()
|
||||
}, 1000*10)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,33 @@
|
|||
|
||||
import 'dotenv/config'
|
||||
import Video from '../src/Video.js'
|
||||
import { expect } from 'chai'
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const dataFixture = [
|
||||
{
|
||||
timestamp: 1,
|
||||
file: 'mock-stream0.mp4'
|
||||
}, {
|
||||
timestamp: 2,
|
||||
file: 'mock-stream1.mp4'
|
||||
}, {
|
||||
timestamp: 3,
|
||||
file: 'mock-stream2.mp4'
|
||||
}
|
||||
]
|
||||
|
||||
xdescribe('video', function () {
|
||||
describe('concat', function () {
|
||||
it('should combine several videos into one', async function() {
|
||||
const cwd = path.join(__dirname, './fixtures')
|
||||
const outputFile = await concat(dataFixture, {
|
||||
cwd
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,20 +0,0 @@
|
|||
# Base Image
|
||||
FROM golang:latest
|
||||
|
||||
# Set the Current Working Directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything from the current directory to the PWD(Present Working Directory) inside the container
|
||||
COPY . .
|
||||
|
||||
# Download all the dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Build the Go app
|
||||
RUN go build -o main .
|
||||
|
||||
# Expose port 8080 to the outside world
|
||||
EXPOSE 8080
|
||||
|
||||
# Command to run the executable
|
||||
CMD ["./main"]
|
|
@ -1,13 +0,0 @@
|
|||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/", hellowordhandler)
|
||||
|
||||
http.ListenAndServe(":9000", nil)
|
||||
}
|
||||
|
||||
func hellowordhandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("Hello World"))
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
node_modules
|
||||
README.md
|
|
@ -1,36 +0,0 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
|
@ -1,12 +0,0 @@
|
|||
FROM node:20
|
||||
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
ADD package.json .
|
||||
ADD package-lock.json .
|
||||
RUN pnpm install
|
||||
|
||||
ADD . .
|
||||
|
||||
ENTRYPOINT [ "pnpm", "next", "dev" ]
|
|
@ -1,36 +0,0 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB |
|
@ -1,107 +0,0 @@
|
|||
:root {
|
||||
--max-width: 1100px;
|
||||
--border-radius: 12px;
|
||||
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||
"Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
|
||||
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
||||
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
|
||||
--primary-glow: conic-gradient(
|
||||
from 180deg at 50% 50%,
|
||||
#16abff33 0deg,
|
||||
#0885ff33 55deg,
|
||||
#54d6ff33 120deg,
|
||||
#0071ff33 160deg,
|
||||
transparent 360deg
|
||||
);
|
||||
--secondary-glow: radial-gradient(
|
||||
rgba(255, 255, 255, 1),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
|
||||
--tile-start-rgb: 239, 245, 249;
|
||||
--tile-end-rgb: 228, 232, 233;
|
||||
--tile-border: conic-gradient(
|
||||
#00000080,
|
||||
#00000040,
|
||||
#00000030,
|
||||
#00000020,
|
||||
#00000010,
|
||||
#00000010,
|
||||
#00000080
|
||||
);
|
||||
|
||||
--callout-rgb: 238, 240, 241;
|
||||
--callout-border-rgb: 172, 175, 176;
|
||||
--card-rgb: 180, 185, 188;
|
||||
--card-border-rgb: 131, 134, 135;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
|
||||
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
|
||||
--secondary-glow: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(1, 65, 255, 0),
|
||||
rgba(1, 65, 255, 0),
|
||||
rgba(1, 65, 255, 0.3)
|
||||
);
|
||||
|
||||
--tile-start-rgb: 2, 13, 46;
|
||||
--tile-end-rgb: 2, 5, 19;
|
||||
--tile-border: conic-gradient(
|
||||
#ffffff80,
|
||||
#ffffff40,
|
||||
#ffffff30,
|
||||
#ffffff20,
|
||||
#ffffff10,
|
||||
#ffffff10,
|
||||
#ffffff80
|
||||
);
|
||||
|
||||
--callout-rgb: 20, 20, 20;
|
||||
--callout-border-rgb: 108, 108, 108;
|
||||
--card-rgb: 100, 100, 100;
|
||||
--card-border-rgb: 200, 200, 200;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
|
@ -1,95 +0,0 @@
|
|||
import Image from "next/image";
|
||||
import styles from "./page.module.css";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.description}>
|
||||
<p>
|
||||
Get started by editing
|
||||
<code className={styles.code}>app/page.js</code>
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
By{" "}
|
||||
<Image
|
||||
src="/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
className={styles.vercelLogo}
|
||||
width={100}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.center}>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/next.svg"
|
||||
alt="Next.js Logo"
|
||||
width={180}
|
||||
height={37}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2>
|
||||
Docs <span>-></span>
|
||||
</h2>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2>
|
||||
Learn <span>-></span>
|
||||
</h2>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2>
|
||||
Templates <span>-></span>
|
||||
</h2>
|
||||
<p>Explore starter templates for Next.js.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2>
|
||||
Deploy <span>-></span>
|
||||
</h2>
|
||||
<p>
|
||||
Instantly deploy your Next.js site to a shareable URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
|
@ -1,230 +0,0 @@
|
|||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: inherit;
|
||||
justify-content: inherit;
|
||||
align-items: inherit;
|
||||
font-size: 0.85rem;
|
||||
max-width: var(--max-width);
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.description a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.description p {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background-color: rgba(var(--callout-rgb), 0.5);
|
||||
border: 1px solid rgba(var(--callout-border-rgb), 0.3);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(25%, auto));
|
||||
max-width: 100%;
|
||||
width: var(--max-width);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem 1.2rem;
|
||||
border-radius: var(--border-radius);
|
||||
background: rgba(var(--card-rgb), 0);
|
||||
border: 1px solid rgba(var(--card-border-rgb), 0);
|
||||
transition: background 200ms, border 200ms;
|
||||
}
|
||||
|
||||
.card span {
|
||||
display: inline-block;
|
||||
transition: transform 200ms;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
opacity: 0.6;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
max-width: 30ch;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.center::before {
|
||||
background: var(--secondary-glow);
|
||||
border-radius: 50%;
|
||||
width: 480px;
|
||||
height: 360px;
|
||||
margin-left: -400px;
|
||||
}
|
||||
|
||||
.center::after {
|
||||
background: var(--primary-glow);
|
||||
width: 240px;
|
||||
height: 180px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.center::before,
|
||||
.center::after {
|
||||
content: "";
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
filter: blur(45px);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: relative;
|
||||
}
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card:hover {
|
||||
background: rgba(var(--card-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--card-border-rgb), 0.15);
|
||||
}
|
||||
|
||||
.card:hover span {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.card:hover span {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 700px) {
|
||||
.content {
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
margin-bottom: 120px;
|
||||
max-width: 320px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem 2.5rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.center {
|
||||
padding: 8rem 0 6rem;
|
||||
}
|
||||
|
||||
.center::before {
|
||||
transform: none;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.description a {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.description p,
|
||||
.description div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description p {
|
||||
align-items: center;
|
||||
inset: 0 0 auto;
|
||||
padding: 2rem 1rem 1.4rem;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--background-start-rgb), 1),
|
||||
rgba(var(--callout-rgb), 0.5)
|
||||
);
|
||||
background-clip: padding-box;
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
.description div {
|
||||
align-items: flex-end;
|
||||
pointer-events: none;
|
||||
inset: auto 0 0;
|
||||
padding: 2rem;
|
||||
height: 200px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgb(var(--background-end-rgb)) 40%
|
||||
);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet and Smaller Desktop */
|
||||
@media (min-width: 701px) and (max-width: 1120px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.vercelLogo {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
|
@ -1,389 +0,0 @@
|
|||
{
|
||||
"name": "helloworldy",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "helloworldy",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"next": "14.1.3",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.3.tgz",
|
||||
"integrity": "sha512-VhgXTvrgeBRxNPjyfBsDIMvgsKDxjlpw4IAUsHCX8Gjl1vtHUYRT3+xfQ/wwvLPDd/6kqfLqk9Pt4+7gysuCKQ=="
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.3.tgz",
|
||||
"integrity": "sha512-LALu0yIBPRiG9ANrD5ncB3pjpO0Gli9ZLhxdOu6ZUNf3x1r3ea1rd9Q+4xxUkGrUXLqKVK9/lDkpYIJaCJ6AHQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.3.tgz",
|
||||
"integrity": "sha512-E/9WQeXxkqw2dfcn5UcjApFgUq73jqNKaE5bysDm58hEUdUGedVrnRhblhJM7HbCZNhtVl0j+6TXsK0PuzXTCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.3.tgz",
|
||||
"integrity": "sha512-USArX9B+3rZSXYLFvgy0NVWQgqh6LHWDmMt38O4lmiJNQcwazeI6xRvSsliDLKt+78KChVacNiwvOMbl6g6BBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.3.tgz",
|
||||
"integrity": "sha512-esk1RkRBLSIEp1qaQXv1+s6ZdYzuVCnDAZySpa62iFTMGTisCyNQmqyCTL9P+cLJ4N9FKCI3ojtSfsyPHJDQNw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.3.tgz",
|
||||
"integrity": "sha512-8uOgRlYEYiKo0L8YGeS+3TudHVDWDjPVDUcST+z+dUzgBbTEwSSIaSgF/vkcC1T/iwl4QX9iuUyUdQEl0Kxalg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.3.tgz",
|
||||
"integrity": "sha512-DX2zqz05ziElLoxskgHasaJBREC5Y9TJcbR2LYqu4r7naff25B4iXkfXWfcp69uD75/0URmmoSgT8JclJtrBoQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.3.tgz",
|
||||
"integrity": "sha512-HjssFsCdsD4GHstXSQxsi2l70F/5FsRTRQp8xNgmQs15SxUfUJRvSI9qKny/jLkY3gLgiCR3+6A7wzzK0DBlfA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.3.tgz",
|
||||
"integrity": "sha512-DRuxD5axfDM1/Ue4VahwSxl1O5rn61hX8/sF0HY8y0iCbpqdxw3rB3QasdHn/LJ6Wb2y5DoWzXcz3L1Cr+Thrw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.3.tgz",
|
||||
"integrity": "sha512-uC2DaDoWH7h1P/aJ4Fok3Xiw6P0Lo4ez7NbowW2VGNXw/Xv6tOuLUcxhBYZxsSUJtpeknCi8/fvnSpyCFp4Rcg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz",
|
||||
"integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001599",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001599.tgz",
|
||||
"integrity": "sha512-LRAQHZ4yT1+f9LemSMeqdMpMxZcc4RMWdj4tiFe3G8tNkWK+E58g+/tzotb5cU6TbcVJLr4fySiAW7XmxQvZQA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.1.3.tgz",
|
||||
"integrity": "sha512-oexgMV2MapI0UIWiXKkixF8J8ORxpy64OuJ/J9oVUmIthXOUCcuVEZX+dtpgq7wIfIqtBwQsKEDXejcjTsan9g==",
|
||||
"dependencies": {
|
||||
"@next/env": "14.1.3",
|
||||
"@swc/helpers": "0.5.2",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"next": "dist/bin/next"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.1.3",
|
||||
"@next/swc-darwin-x64": "14.1.3",
|
||||
"@next/swc-linux-arm64-gnu": "14.1.3",
|
||||
"@next/swc-linux-arm64-musl": "14.1.3",
|
||||
"@next/swc-linux-x64-gnu": "14.1.3",
|
||||
"@next/swc-linux-x64-musl": "14.1.3",
|
||||
"@next/swc-win32-arm64-msvc": "14.1.3",
|
||||
"@next/swc-win32-ia32-msvc": "14.1.3",
|
||||
"@next/swc-win32-x64-msvc": "14.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sass": "^1.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
||||
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
|
||||
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.1.0.tgz",
|
||||
"integrity": "sha512-9vC2SfsJzlej6MAaMPLu8HiBSHGdRAJ9hVFYN1ibZoNkeanmDmLUcIrj6G9DGL7XMJ54AKg/G75akXl1/izTOw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
|
||||
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
|
||||
"dependencies": {
|
||||
"client-only": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"name": "helloworldy",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"next": "14.1.3"
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
Before Width: | Height: | Size: 1.3 KiB |
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
Before Width: | Height: | Size: 629 B |
File diff suppressed because it is too large
Load Diff
|
@ -4,9 +4,9 @@ export default function NotFound() {
|
|||
return (
|
||||
<div className='section'>
|
||||
<h2 className='title is-2'>404 Not Found</h2>
|
||||
<p>Could not find that stream.</p>
|
||||
<p>Could not find that stream archive.</p>
|
||||
|
||||
<Link href="/s">Return to streams list</Link>
|
||||
<Link href="/s">Return to archive list</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -12,6 +12,7 @@ interface IPageParams {
|
|||
|
||||
export default async function Page ({ params: { cuid } }: IPageParams) {
|
||||
const stream = await getStreamByCuid(cuid);
|
||||
console.log(`getting stream by cuid. cuid=${cuid}`)
|
||||
return (
|
||||
<>
|
||||
<StreamPage stream={stream} />
|
|
@ -0,0 +1,91 @@
|
|||
import Pager from "@/components/pager";
|
||||
import StreamsList from "@/components/streams-list";
|
||||
import StreamsTable from '@/components/streams-table';
|
||||
import { getAllStreams, getStreamsForVtuber } from "@/lib/streams";
|
||||
// import { getAllVtubers } from "@/lib/vtubers";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
|
||||
|
||||
export default async function Page() {
|
||||
// const vtubers = await getAllVtubers();
|
||||
// const streams = await getAllStreams();
|
||||
// const streams = await getStreamsForVtuber(1)
|
||||
// const pageSize = 100;
|
||||
// const page = 1;
|
||||
|
||||
|
||||
// export interface IStream {
|
||||
// id: number;
|
||||
// attributes: {
|
||||
// date: string;
|
||||
// archiveStatus: 'good' | 'issue' | 'missing';
|
||||
// vods: IVodsResponse;
|
||||
// cuid: string;
|
||||
// vtuber: IVtuberResponse;
|
||||
// tweet: ITweetResponse;
|
||||
// isChaturbateStream: boolean;
|
||||
// isFanslyStream: boolean;
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (!vtubers) notFound();
|
||||
// const streams = [
|
||||
// {
|
||||
// "firstName": "Tanner",
|
||||
// "lastName": "Linsley",
|
||||
// "age": 33,
|
||||
// "visits": 100,
|
||||
// "progress": 50,
|
||||
// "status": "Married",
|
||||
// "id": 5,
|
||||
// "attributes": {
|
||||
// date: '2023-10-10T15:18:20.003Z',
|
||||
// archiveStatus: 'missing',
|
||||
// isChaturbateStream: false,
|
||||
// isFanslyStream: true,
|
||||
// vods: {},
|
||||
// cuid: '2983482932384',
|
||||
// vtuber: {},
|
||||
// tweet: '',
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// "firstName": "Kevin",
|
||||
// "lastName": "Vandy",
|
||||
// "age": 27,
|
||||
// "visits": 200,
|
||||
// "progress": 100,
|
||||
// "status": "Single",
|
||||
// "id": 3,
|
||||
// "attributes": {
|
||||
// date: '2023-10-10T15:18:20.003Z',
|
||||
// archiveStatus: 'missing',
|
||||
// isChaturbateStream: true,
|
||||
// isFanslyStream: true,
|
||||
// vods: {},
|
||||
// cuid: '29823432384',
|
||||
// vtuber: {},
|
||||
// tweet: '',
|
||||
// }
|
||||
// }
|
||||
// ]
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
{/* <pre>
|
||||
<p>here are the streams object</p>
|
||||
<code>
|
||||
{JSON.stringify(streams, null, 2)}
|
||||
</code>
|
||||
</pre> */}
|
||||
|
||||
|
||||
|
||||
<h1 className="title">Stream Archive</h1>
|
||||
<StreamsTable />
|
||||
{/* <StreamsList vtubers={vtubers} page={page} pageSize={pageSize} />
|
||||
<Pager baseUrl="/streams" page={page} pageCount={vtubers.length/pageSize}/> */}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -7,7 +7,7 @@ export interface IArchiveProgressProps {
|
|||
}
|
||||
|
||||
export default async function ArchiveProgress ({ vtuber }: IArchiveProgressProps) {
|
||||
const vods = await getVodsForVtuber(vtuber.id)
|
||||
// const vods = await getVodsForVtuber(vtuber.id)
|
||||
// const streams = await getAllStreamsForVtuber(vtuber.id);
|
||||
// const goodStreams = await getAllStreamsForVtuber(vtuber.id, ['good']);
|
||||
// const issueStreams = await getAllStreamsForVtuber(vtuber.id, ['issue']);
|
||||
|
@ -16,17 +16,21 @@ export default async function ArchiveProgress ({ vtuber }: IArchiveProgressProps
|
|||
|
||||
// // Check if totalStreams is not zero before calculating completedPercentage
|
||||
// const completedPercentage = (totalStreams !== 0) ? Math.round(eligibleStreams / totalStreams * 100) : 0;
|
||||
// return (
|
||||
// <div>
|
||||
// <p className="heading">{eligibleStreams}/{totalStreams} Streams Archived ({completedPercentage}%)</p>
|
||||
// <progress className="progress is-success" value={eligibleStreams} max={totalStreams}>{completedPercentage}%</progress>
|
||||
// </div>
|
||||
// )
|
||||
// @todo
|
||||
|
||||
const completedPercentage = 50
|
||||
const totalStreams = 500
|
||||
const eligibleStreams = 50
|
||||
return (
|
||||
<div>
|
||||
<i><p className="">{(vods) ? vods.data.length : 0} vods</p></i>
|
||||
<p>@todo</p>
|
||||
<p className="heading">{eligibleStreams}/{totalStreams} Streams Archived ({completedPercentage}%)</p>
|
||||
<progress className="progress is-success" value={eligibleStreams} max={totalStreams}>{completedPercentage}%</progress>
|
||||
</div>
|
||||
)
|
||||
// @todo
|
||||
|
||||
// return (
|
||||
// <div>
|
||||
// <i><p className="">{(vods) ? vods.data.length : 0} vods</p></i>
|
||||
// </div>
|
||||
// )
|
||||
}
|
|
@ -17,7 +17,7 @@ export default function Footer() {
|
|||
<ul>
|
||||
<li><Link href="#top">↑ Top of page</Link></li>
|
||||
<li><Link href="/vt">Vtubers</Link></li>
|
||||
{/* <li><Link href="/streams">Stream Archive</Link></li> */}
|
||||
<li><Link href="/archive">Archive</Link></li>
|
||||
<li><Link href="/about">About</Link></li>
|
||||
<li><Link href="/faq">FAQ</Link></li>
|
||||
<li><Link href="/goals">Goals</Link></li>
|
||||
|
|
|
@ -43,7 +43,7 @@ export default function Navbar() {
|
|||
<div className={`navbar-menu ${isExpanded ? 'is-active' : ''}`} id="navMenu">
|
||||
<div className='navbar-start'>
|
||||
<Link className="navbar-item is-expanded" href="/vt">Vtubers</Link>
|
||||
{/* <Link className="navbar-item is-expanded" href="/streams">Stream Archive</Link> */}
|
||||
<Link className="navbar-item is-expanded" href="/archive">Archive</Link>
|
||||
<Link className="navbar-item is-expanded" href="/about">About</Link>
|
||||
<Link className="navbar-item is-expanded" href="/faq">FAQ</Link>
|
||||
<Link className="navbar-item is-expanded" href="/goals">Goals</Link>
|
||||
|
|
|
@ -8,7 +8,7 @@ export function StreamButton({ stream }: { stream: IStream }) {
|
|||
|
||||
return (
|
||||
<Link
|
||||
href={`/streams/${stream.attributes.cuid}`}
|
||||
href={`/archive/${stream.attributes.cuid}`}
|
||||
className="button is-medium"
|
||||
>
|
||||
<span className="mr-2"><FontAwesomeIcon icon={faCalendar} className="fas fa-calendar" /></span><span>{new Date(stream.attributes.date).toLocaleDateString()}</span>
|
||||
|
|
|
@ -45,6 +45,8 @@ function determineStatus(stream: IStream): Status {
|
|||
}
|
||||
|
||||
export default function StreamPage({ stream }: IStreamProps) {
|
||||
console.log('StreamPage function has been invoked! stream as follows')
|
||||
console.log(stream)
|
||||
const displayName = stream.attributes.vtuber.data.attributes.displayName;
|
||||
const date = new Date(stream.attributes.date);
|
||||
const [hemisphere, setHemisphere] = useState(Hemisphere.NORTHERN);
|
||||
|
@ -89,13 +91,22 @@ export default function StreamPage({ stream }: IStreamProps) {
|
|||
// </pre>
|
||||
|
||||
// </p>
|
||||
// const platformsList = '???';
|
||||
const { isChaturbateInvite, isFanslyInvite } = stream.attributes.tweet.data.attributes;
|
||||
const platformsArray = [
|
||||
isChaturbateInvite ? 'Chaturbate' : null,
|
||||
isFanslyInvite ? 'Fansly' : null
|
||||
].filter(Boolean);
|
||||
const platformsList = platformsArray.length > 0 ? platformsArray.join(', ') : 'None';
|
||||
// const platformsList = [
|
||||
// stream.attributes.isChaturbateStream ? 'Chaturbate' : null,
|
||||
// stream.attributes.isFanslyStream ? 'Fansly' : null
|
||||
// ].filter(Boolean).join(', ');
|
||||
// platformsList = platformsArray.length > 0 ? platformsArray.join(', ') : 'None';
|
||||
|
||||
// const platformsList = [
|
||||
// (stream.attributes.isChaturbateStream && 'CB'),
|
||||
// (stream.attributes.isFanslyStream && 'Fansly')
|
||||
// ].filter(Boolean).join(', ')
|
||||
|
||||
const platformsList = [
|
||||
(stream.attributes.isChaturbateStream && 'CB'),
|
||||
(stream.attributes.isFanslyStream && 'Fansly')
|
||||
].filter(Boolean).join(', ') || '!!!';
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
@ -110,23 +121,39 @@ export default function StreamPage({ stream }: IStreamProps) {
|
|||
<div className="section columns is-multiline">
|
||||
<div className="column is-half">
|
||||
<div className="box">
|
||||
<h2 className="title is-3">Details</h2>
|
||||
<div className="columns is-multiline">
|
||||
<div className="column is-full">
|
||||
<span><b>Announcement</b> <span><Link target="_blank" href={stream.attributes.tweet.data.attributes.url}><FontAwesomeIcon icon={faXTwitter}></FontAwesomeIcon><FontAwesomeIcon icon={faExternalLinkAlt}></FontAwesomeIcon></Link></span></span><br></br>
|
||||
<span><b>Platform</b> </span><span>{platformsList}</span><br></br>
|
||||
<span><b>UTC Datetime</b> </span><time dateTime={date.toISOString()}>{date.toISOString()}</time><br></br>
|
||||
<span><b>Local Datetime</b> </span><span>{date.toLocaleDateString()} {date.toLocaleTimeString()}</span><br></br>
|
||||
<span><b>Lunar Phase</b> </span><span>{Moon.lunarPhase(date)} {Moon.lunarPhaseEmoji(date, { hemisphere })}</span><br></br>
|
||||
<br></br>
|
||||
{/* <select className="mt-5"
|
||||
value={selectedStatus}
|
||||
onChange={e => setSelectedStatus(e.target.value as Status)}
|
||||
>
|
||||
<option>good</option>
|
||||
<option>issue</option>
|
||||
<option>missing</option>
|
||||
</select> */}
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-family-sans-serif">Description</th>
|
||||
<th className="is-family-sans-serif">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* <tr>
|
||||
<td>Announcement</td>
|
||||
<td><Link target="_blank" href={stream.attributes.tweet.data.attributes.url}><FontAwesomeIcon icon={faXTwitter}></FontAwesomeIcon><FontAwesomeIcon icon={faExternalLinkAlt}></FontAwesomeIcon></Link></td>
|
||||
</tr> */}
|
||||
<tr>
|
||||
<td>Platform</td>
|
||||
<td>{platformsList}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>UTC Datetime</td>
|
||||
<td><time dateTime={date.toISOString()}>{date.toISOString()}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Local Datetime</td>
|
||||
<td>{date.toLocaleDateString()} {date.toLocaleTimeString()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Lunar Phase</td>
|
||||
<td>{Moon.lunarPhase(date)} {Moon.lunarPhaseEmoji(date, { hemisphere })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -150,6 +177,7 @@ export default function StreamPage({ stream }: IStreamProps) {
|
|||
</div>
|
||||
|
||||
|
||||
{stream.attributes.vods.data.length !== 0 &&
|
||||
<div className="section">
|
||||
<h1 className="title">VODs</h1>
|
||||
<table className="table">
|
||||
|
@ -179,7 +207,7 @@ export default function StreamPage({ stream }: IStreamProps) {
|
|||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,318 +1,249 @@
|
|||
'use client'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
keepPreviousData,
|
||||
QueryClient,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
|
||||
import {
|
||||
Column,
|
||||
Table as ReactTable,
|
||||
useReactTable,
|
||||
ColumnFiltersState,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFacetedMinMaxValues,
|
||||
getPaginationRowModel,
|
||||
sortingFns,
|
||||
getSortedRowModel,
|
||||
FilterFn,
|
||||
SortingFn,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
FilterFns,
|
||||
ColumnOrderState,
|
||||
createColumnHelper,
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
} from '@tanstack/react-table'
|
||||
import Image from 'next/image';
|
||||
import { useState } from "react";
|
||||
import { IStream } from "@/lib/streams";
|
||||
import { LocalizedDate } from './localized-date';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faAngleLeft, faAngleRight, faAnglesLeft, faAnglesRight, faChevronCircleRight, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import Link from 'next/link';
|
||||
|
||||
function Filter({
|
||||
column,
|
||||
table,
|
||||
}: {
|
||||
column: Column<any, any>
|
||||
table: ReactTable<any>
|
||||
}) {
|
||||
const firstValue = table
|
||||
.getPreFilteredRowModel()
|
||||
.flatRows[0]?.getValue(column.id)
|
||||
|
||||
const columnFilterValue = column.getFilterValue()
|
||||
|
||||
|
||||
|
||||
if (typeof firstValue === 'number') {
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
value={(columnFilterValue as [number, number])?.[0] ?? ''}
|
||||
onChange={e =>
|
||||
column.setFilterValue((old: [number, number]) => [
|
||||
e.target.value,
|
||||
old?.[1],
|
||||
])
|
||||
}
|
||||
placeholder={`Min`}
|
||||
className="w-24 border shadow rounded"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={(columnFilterValue as [number, number])?.[1] ?? ''}
|
||||
onChange={e =>
|
||||
column.setFilterValue((old: [number, number]) => [
|
||||
old?.[0],
|
||||
e.target.value,
|
||||
])
|
||||
}
|
||||
placeholder={`Max`}
|
||||
className="w-24 border shadow rounded"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof firstValue === 'boolean') {
|
||||
return (
|
||||
<>
|
||||
<div className='select'>
|
||||
<select
|
||||
onChange={(evt) => {
|
||||
if (evt.target.value === "any")
|
||||
column?.setFilterValue(null);
|
||||
if (evt.target.value === "yes")
|
||||
column?.setFilterValue(true);
|
||||
if (evt.target.value === "no")
|
||||
column?.setFilterValue(false);
|
||||
}}
|
||||
>
|
||||
<option>any</option>
|
||||
<option>yes</option>
|
||||
<option>no</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={(columnFilterValue ?? '') as string}
|
||||
onChange={e => column.setFilterValue(e.target.value)}
|
||||
placeholder={`Search...`}
|
||||
className="input"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const archiveStatusClassName = (archiveStatus: string): string => {
|
||||
if (archiveStatus === 'missing') return 'is-danger';
|
||||
if (archiveStatus === 'issue') return 'is-warning';
|
||||
if (archiveStatus === 'good') return 'is-success';
|
||||
return 'is-info';
|
||||
};
|
||||
|
||||
export default function StreamsTable({ streams }: { streams: IStream[] }) {
|
||||
|
||||
|
||||
import { fetchStreamData, IStream } from '@/lib/streams'
|
||||
|
||||
const columnHelper = createColumnHelper<IStream>()
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('attributes.cuid', {
|
||||
cell: info => <Link href={`/streams/${info.getValue()}`}>{info.getValue()}</Link>,
|
||||
header: () => <span>ID</span>
|
||||
}),
|
||||
columnHelper.accessor('attributes.vtuber.data.attributes.image', {
|
||||
cell: info => <figure className='image is-24x24'><Image className="is-rounded" width={24} height={24} alt="" src={info.getValue()}></Image></figure>,
|
||||
header: () => <span></span>,
|
||||
enableColumnFilter: false
|
||||
}),
|
||||
columnHelper.accessor('attributes.vtuber.data.attributes.displayName', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => <span>VTuber</span>
|
||||
}),
|
||||
columnHelper.accessor('attributes.date', {
|
||||
cell: info => <LocalizedDate date={new Date(info.getValue())}/>,
|
||||
header: () => <span>Date</span>
|
||||
}),
|
||||
columnHelper.accessor('attributes.isChaturbateStream', {
|
||||
id: 'isChaturbateStream',
|
||||
cell: info => info.getValue() === true ? 'yes' : 'no',
|
||||
header: () => <span>Chaturbate</span>
|
||||
}),
|
||||
columnHelper.accessor('attributes.isFanslyStream', {
|
||||
id: 'isFanslyStream',
|
||||
cell: info => info.getValue() === true ? 'yes' : 'no',
|
||||
header: () => <span>Fansly</span>
|
||||
}),
|
||||
columnHelper.accessor('attributes.archiveStatus', {
|
||||
cell: info => <div className={`tag ${archiveStatusClassName(info.getValue())}`} >{info.getValue()}</div>,
|
||||
header: () => <span>Status</span>
|
||||
})
|
||||
]
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState({})
|
||||
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [data, setData] = useState(() => streams)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
state: {
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
columnFilters
|
||||
},
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
})
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
|
||||
<div className="p-2">
|
||||
<div className="h-2" />
|
||||
|
||||
|
||||
|
||||
<table className='table'>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => {
|
||||
return (
|
||||
<th key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getCanFilter() ? (
|
||||
<div>
|
||||
<Filter column={header.column} table={table} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map(row => {
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
return (
|
||||
<td key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="columns is-multiline is-mobile" />
|
||||
<div className="column is-12">
|
||||
<button
|
||||
className="button icon is-rounded is-medium p-1 m-1"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<FontAwesomeIcon className='fa-solid fa-angles-left' icon={faAnglesLeft}></FontAwesomeIcon>
|
||||
</button>
|
||||
<button
|
||||
className="button icon is-rounded is-medium p-1 m-1"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<FontAwesomeIcon className='fa-solid fa-angle-left' icon={faAngleLeft}></FontAwesomeIcon>
|
||||
</button>
|
||||
<button
|
||||
className="button icon is-rounded is-medium p-1 m-1"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<FontAwesomeIcon className="fa-solid fa-angle-right" icon={faAngleRight}></FontAwesomeIcon>
|
||||
</button>
|
||||
<button
|
||||
className="button icon is-medium is-rounded p-1 m-1"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<FontAwesomeIcon className='fa-solid fa-angles-right' icon={faAnglesRight}></FontAwesomeIcon>
|
||||
</button>
|
||||
</div>
|
||||
<div className='column is-2'>
|
||||
|
||||
<div className=''>
|
||||
<div className=''>
|
||||
<span className='mr-1'>Page</span>
|
||||
{table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=''>
|
||||
<label className='label'>
|
||||
Go to page:
|
||||
</label>
|
||||
<div className="control is-expanded">
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={table.getState().pagination.pageIndex + 1}
|
||||
onChange={e => {
|
||||
const page = e.target.value ? Number(e.target.value) - 1 : 0
|
||||
table.setPageIndex(page)
|
||||
}}
|
||||
className="input p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='column is-2'>
|
||||
<div className="m-1">
|
||||
<span className='mr-1'>Page</span>
|
||||
</div>
|
||||
<div className='select'>
|
||||
<select
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={e => {
|
||||
table.setPageSize(Number(e.target.value))
|
||||
}}
|
||||
>
|
||||
{[10, 20, 30, 40, 50].map(pageSize => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
Show {pageSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
function getStatusClass(value: string) {
|
||||
switch (value) {
|
||||
case 'issue':
|
||||
return 'is-warning';
|
||||
case 'missing':
|
||||
return 'is-danger';
|
||||
case 'good':
|
||||
return 'is-success';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function StreamsTable() {
|
||||
const rerender = React.useReducer(() => ({}), {})[1]
|
||||
|
||||
|
||||
// name
|
||||
// title
|
||||
// platform
|
||||
// date
|
||||
// archiveStatus
|
||||
const columns = React.useMemo<ColumnDef<IStream>[]>(
|
||||
() => [
|
||||
{
|
||||
header: 'VTuber',
|
||||
accessorFn: d => d.attributes.vtuber.data?.attributes?.displayName,
|
||||
},
|
||||
{
|
||||
header: 'Date',
|
||||
accessorFn: d => new Date(d.attributes.date2).toISOString().split('T').at(0),
|
||||
cell: info => <Link href={`/archive/${info.row.original.attributes.cuid}`}>{info.getValue() as string}</Link>
|
||||
},
|
||||
{
|
||||
header: 'Platform',
|
||||
accessorFn: d => [
|
||||
(d.attributes.isChaturbateStream && 'CB'),
|
||||
(d.attributes.isFanslyStream && 'Fansly')
|
||||
].filter(Boolean).join(', ') || '???'
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorFn: d => {
|
||||
if (!d.attributes.archiveStatus) return 'missing';
|
||||
return d.attributes.archiveStatus
|
||||
}
|
||||
},
|
||||
// {
|
||||
// header: 'Name',
|
||||
// footer: props => props.column.id,
|
||||
// columns: [
|
||||
// {
|
||||
// accessorKey: 'firstName',
|
||||
// cell: info => info.getValue(),
|
||||
// footer: props => props.column.id,
|
||||
// },
|
||||
// {
|
||||
// accessorFn: row => row.lastName,
|
||||
// id: 'lastName',
|
||||
// cell: info => info.getValue(),
|
||||
// header: () => <span>Last Name</span>,
|
||||
// footer: props => props.column.id,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
const dataQuery = useQuery({
|
||||
queryKey: ['streams', pagination.pageIndex, pagination.pageSize],
|
||||
queryFn: () => fetchStreamData(pagination),
|
||||
placeholderData: keepPreviousData, // don't have 0 rows flash while changing pages/loading next page,
|
||||
staleTime: 1000
|
||||
}, queryClient)
|
||||
|
||||
const defaultData = React.useMemo(() => [], [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: dataQuery?.data?.rows ?? defaultData,
|
||||
columns,
|
||||
// pageCount: dataQuery.data?.pageCount ?? -1, //you can now pass in `rowCount` instead of pageCount and `pageCount` will be calculated internally (new in v8.13.0)
|
||||
rowCount: dataQuery.data?.rowCount, // new in v8.13.0 - alternatively, just pass in `pageCount` directly
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true, //we're doing manual "server-side" pagination
|
||||
// getPaginationRowModel: getPaginationRowModel(), // If only doing manual pagination, you don't need this
|
||||
debugTable: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="h-2" />
|
||||
<table className='table is-hoverable is-fullwidth'>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => {
|
||||
return (
|
||||
<th key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map(row => {
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
return (
|
||||
<td
|
||||
className={getStatusClass(cell.getValue() as string)}
|
||||
key={cell.id}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="columns is-mobile is-vcentered">
|
||||
<div className='column is-half'>
|
||||
<button
|
||||
className="button border rounded mx-1"
|
||||
onClick={() => table.firstPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
{'<<'}
|
||||
</button>
|
||||
<button
|
||||
className="button border rounded mx-1"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
{'<'}
|
||||
</button>
|
||||
<button
|
||||
className="button border rounded mx-1"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
{'>'}
|
||||
</button>
|
||||
<button
|
||||
className="button border rounded mx-1"
|
||||
onClick={() => table.lastPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
{'>>'}
|
||||
</button>
|
||||
</div>
|
||||
<div className='column is-half'>
|
||||
<span>Page </span>
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount().toLocaleString()}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* second row with page number input and pages-per-screen select */}
|
||||
<div className='columns is-mobile is-vcentered'>
|
||||
<div className='column is-2 '>
|
||||
<span className='is-text-centered'>Go to page:</span>
|
||||
</div>
|
||||
<div className='column is-3'>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={table.getState().pagination.pageIndex + 1}
|
||||
onChange={e => {
|
||||
const page = e.target.value ? Number(e.target.value) - 1 : 0
|
||||
table.setPageIndex(page)
|
||||
}}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className='column is-5'>
|
||||
<div className="select">
|
||||
<select
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={e => {
|
||||
table.setPageSize(Number(e.target.value))
|
||||
}}
|
||||
>
|
||||
{[20, 50, 100].map(pageSize => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
Show {pageSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
|
|
@ -156,9 +156,9 @@ export function Tagger({ vod, setTimestamps }: ITaggerProps): React.JSX.Element
|
|||
}
|
||||
}
|
||||
|
||||
if (!isAuthed) {
|
||||
return <></>
|
||||
} else {
|
||||
// if (!isAuthed) {
|
||||
// return <></>
|
||||
// } else {
|
||||
if (isEditor) {
|
||||
return (
|
||||
<div className='card mt-2' style={{ width: '100%' }}>
|
||||
|
@ -234,7 +234,7 @@ export function Tagger({ vod, setTimestamps }: ITaggerProps): React.JSX.Element
|
|||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
// }
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -110,7 +110,6 @@ export const VideoPlayer = forwardRef(function VideoPlayer( props: IPlayerProps,
|
|||
|
||||
return (
|
||||
<>
|
||||
<p className='notification'>CDN1 (for Patrons only)</p>
|
||||
<MuxPlayer
|
||||
onCanPlay={() => {
|
||||
setIsPlayerReady(true)}
|
||||
|
|
|
@ -31,12 +31,16 @@ export function VideoSourceSelector({
|
|||
if (isEntitledToCDN) {
|
||||
if (selectedVideoSource === 'Mux' && isMux) {
|
||||
return 'Mux';
|
||||
} else if (selectedVideoSource === 'B2' && isB2) {
|
||||
return 'B2';
|
||||
}
|
||||
}
|
||||
// If the user doesn't have entitlements or their preference is not available, default to IPFS
|
||||
if (isIPFSSource) {
|
||||
|
||||
// if the user has B2 as their preference or they have no preference, use B2
|
||||
if (selectedVideoSource === 'B2' || !selectedVideoSource) {
|
||||
return 'B2'
|
||||
}
|
||||
|
||||
// use IPFS only if the user has opted to use it
|
||||
if (selectedVideoSource === 'IPFSSource' && isIPFSSource) {
|
||||
return 'IPFSSource';
|
||||
} else if (isIPFS240) {
|
||||
return 'IPFS240';
|
||||
|
@ -53,8 +57,16 @@ export function VideoSourceSelector({
|
|||
// Check if the saved preference is valid based on entitlements and available sources
|
||||
if (savedPreference === 'Mux' && isMux && isEntitledToCDN) {
|
||||
setSelectedVideoSource('Mux');
|
||||
} else if (savedPreference === 'B2' && isB2 && isEntitledToCDN) {
|
||||
} else if (savedPreference === 'B2') {
|
||||
setSelectedVideoSource('B2');
|
||||
} else if (savedPreference === 'IPFSSource') {
|
||||
setSelectedVideoSource('IPFSSource');
|
||||
} else if (savedPreference === 'IPFS240') {
|
||||
if (isIPFS240) {
|
||||
setSelectedVideoSource('IPFS240');
|
||||
} else {
|
||||
setSelectedVideoSource('IPFSSource');
|
||||
}
|
||||
} else {
|
||||
// Determine the best video source if the saved preference is invalid or not available
|
||||
const bestSource = determineBestVideoSource();
|
||||
|
@ -69,7 +81,7 @@ export function VideoSourceSelector({
|
|||
const handleSourceClick = (source: string) => {
|
||||
if (
|
||||
(source === 'Mux' && isMux && isEntitledToCDN) ||
|
||||
(source === 'B2' && isB2 && isEntitledToCDN) ||
|
||||
(source === 'B2' && isB2) ||
|
||||
(source === 'IPFSSource') ||
|
||||
(source === 'IPFS240')
|
||||
) {
|
||||
|
@ -100,9 +112,9 @@ export function VideoSourceSelector({
|
|||
</button>
|
||||
</div>}
|
||||
{(isB2) && <div className="nav-item">
|
||||
<button onClick={() => handleSourceClick('B2')} disabled={!isEntitledToCDN} className={`button ${selectedVideoSource === 'B2' && 'is-active'}`}>
|
||||
<button onClick={() => handleSourceClick('B2')} className={`button ${selectedVideoSource === 'B2' && 'is-active'}`}>
|
||||
<span className="icon">
|
||||
<FontAwesomeIcon icon={faPatreon} className="fab fa-patreon" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="fab fa-globe" />
|
||||
</span>
|
||||
<span>CDN 2</span>
|
||||
</button>
|
||||
|
|
|
@ -7,12 +7,17 @@ import "@fortawesome/fontawesome-svg-core/styles.css";
|
|||
import { AuthProvider } from './components/auth';
|
||||
import type { Metadata } from 'next';
|
||||
import NotificationCenter from './components/notification-center';
|
||||
import UppyProvider from './uppy';
|
||||
// import {
|
||||
// QueryClientProvider,
|
||||
// QueryClient
|
||||
// } from '@tanstack/react-query'
|
||||
// import NextTopLoader from 'nextjs-toploader';
|
||||
// import Ipfs from './components/ipfs'; // slows down the page too much
|
||||
|
||||
|
||||
|
||||
// const queryClient = new QueryClient()
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Futureporn.net',
|
||||
description: "The Galaxy's Best VTuber Hentai Site",
|
||||
|
@ -56,14 +61,14 @@ export default function RootLayout({
|
|||
shadow="0 0 10px #2299DD,0 0 5px #2299DD"
|
||||
/> */}
|
||||
<AuthProvider>
|
||||
<UppyProvider>
|
||||
{/* <QueryClientProvider client={queryClient}> */}
|
||||
<Navbar />
|
||||
<NotificationCenter />
|
||||
<div className="container">
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
</UppyProvider>
|
||||
{/* </QueryClientProvider> */}
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -29,6 +29,6 @@ export default async function fetchAPI(
|
|||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error(`Error while fetching data from API.`);
|
||||
throw new Error(`Error while fetching data from API. ${path}`);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
import { strapiUrl, siteUrl } from './constants';
|
||||
import { siteUrl, strapiUrl } from './constants';
|
||||
import { getSafeDate } from './dates';
|
||||
import { IVodsResponse } from './vods';
|
||||
import { IVtuber, IVtuberResponse } from './vtubers';
|
||||
|
@ -12,6 +12,7 @@ export interface IStream {
|
|||
id: number;
|
||||
attributes: {
|
||||
date: string;
|
||||
date2: string;
|
||||
archiveStatus: 'good' | 'issue' | 'missing';
|
||||
vods: IVodsResponse;
|
||||
cuid: string;
|
||||
|
@ -35,7 +36,8 @@ export interface IStreamsResponse {
|
|||
|
||||
const fetchStreamsOptions = {
|
||||
next: {
|
||||
tags: ['streams']
|
||||
tags: ['streams'],
|
||||
revalidation: 1
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -162,6 +164,7 @@ export async function getStream(id: number): Promise<IStream> {
|
|||
|
||||
|
||||
export async function getAllStreams(archiveStatuses = ['missing', 'issue', 'good']): Promise<IStream[]> {
|
||||
throw new Error('getAllStreams function is not performant. please use something more efficient.')
|
||||
const pageSize = 100; // Adjust this value as needed
|
||||
const sortDesc = true; // Adjust the sorting direction as needed
|
||||
|
||||
|
@ -308,7 +311,50 @@ export async function getAllStreamsForVtuber(vtuberId: number, archiveStatuses =
|
|||
return allStreams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used as table data on /archive page.
|
||||
* .pageIndex, pagination.pageSize
|
||||
*/
|
||||
|
||||
export async function fetchStreamData({ pageIndex, pageSize }: { pageIndex: number, pageSize: number }) {
|
||||
const offset = pageIndex * pageSize;
|
||||
const query = qs.stringify({
|
||||
populate: {
|
||||
vtuber: {
|
||||
fields: ['slug', 'displayName', 'publishedAt']
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
vtuber: {
|
||||
publishedAt: {
|
||||
$notNull: true
|
||||
}
|
||||
}
|
||||
},
|
||||
pagination: {
|
||||
start: offset,
|
||||
limit: pageSize,
|
||||
withCount: true
|
||||
}
|
||||
})
|
||||
const response = await fetch(
|
||||
`${strapiUrl}/api/streams?${query}`
|
||||
);
|
||||
const json = await response.json();
|
||||
console.log(json)
|
||||
const d = {
|
||||
rows: json.data,
|
||||
pageCount: Math.ceil(json.meta.pagination.total / pageSize),
|
||||
rowCount: json.meta.pagination.total,
|
||||
}
|
||||
// console.log(`fetchStreamData with pageIndex=${pageIndex}, pageSize=${pageSize}\n\n${JSON.stringify(d, null, 2)}`)
|
||||
return d;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function getStreamsForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25, sortDesc = true): Promise<IStreamsResponse> {
|
||||
console.log(`getStreamsForVtuber() with strapiUrl=${strapiUrl}`)
|
||||
const query = qs.stringify(
|
||||
{
|
||||
populate: {
|
||||
|
@ -334,8 +380,10 @@ export async function getStreamsForVtuber(vtuberId: number, page: number = 1, pa
|
|||
}
|
||||
}
|
||||
)
|
||||
return fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions)
|
||||
.then((res) => res.json())
|
||||
const res = await fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions)
|
||||
const data = await res.json()
|
||||
console.log(data)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue