Initial commit.

master
Icedream 2020-08-22 22:25:57 +02:00
commit 1a88b1a5ba
70 changed files with 19417 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
_old

5
configs/nginx/cors.conf Normal file
View File

@ -0,0 +1,5 @@
add_header Access-Control-Allow-Headers '*';
add_header Access-Control-Expose-Headers 'Server,range,Content-Length,Content-Range';
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS';
add_header Access-Control-Allow-Origin '*';

View File

@ -0,0 +1,81 @@
tcp_nopush on;
tcp_nodelay on;
vod_segment_duration 2000;
vod_manifest_segment_durations_mode accurate;
vod_align_segments_to_key_frames on;
vod_output_buffer_pool 64k 32;
vod_dash_fragment_file_name_prefix "seg";
vod_hls_segment_file_name_prefix "seg";
# shared memory zones
vod_response_cache vod_response_cache 128m;
vod_metadata_cache vod_meta_cache 2048m;
vod_performance_counters perf_counters;
reset_timedout_connection on;
send_timeout 20;
# manifest compression
gzip on;
gzip_types application/vnd.apple.mpegurl video/f4m application/dash+xml text/xml text/vtt;
gzip_proxied any;
open_file_cache max=1000 inactive=10m;
open_file_cache_valid 3m;
open_file_cache_min_uses 1;
open_file_cache_errors off;
aio on;
server {
listen 80;
server_name gdq-a.hls.edge.streaminginter.net;
root /htdocs;
include conf.d/cors.conf;
vod_base_url https://gdq-a.hls.edge.streaminginter.net;
location / {
vod hls;
}
}
server {
listen 80;
server_name gdq-a.dash.edge.streaminginter.net;
root /htdocs;
include conf.d/cors.conf;
vod_base_url https://gdq-a.dash.edge.streaminginter.net;
location / {
vod dash;
}
}
server {
listen 80;
server_name thumb-gdq-a.edge.streaminginter.net;
root /htdocs;
location / {
vod thumb;
expires 2d;
access_log off;
add_header Cache-Control "public";
}
}
server {
listen 80;
server_name gdq-a.upstream.streaminginter.net;
root /htdocs;
location ~ (.*\.(mp4|mkv|mp3|aac|ogg|mpg|ogv|oga|m4a|m4v|mov)) {
add_header Content-disposition "attachment";
}
}

View File

@ -0,0 +1,7 @@
vcl 4.0;
backend streamserver {
.host = "streamserver";
.port = "80";
}

45
frontend/.dockerignore Normal file
View File

@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# 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
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# react-intl
compiled-lang/
###
.git*
.docker*
Dockerfile
*.Dockerfile

11
frontend/.editorconfig Normal file
View File

@ -0,0 +1,11 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true
[*.{js,jsx,ts,tsx}]
end_of_line = lf

4
frontend/.eslintignore Normal file
View File

@ -0,0 +1,4 @@
.next/
/dist/
/public/
node_modules/

4
frontend/.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# 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
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# react-intl
compiled-lang/
# deployment
docker-compose.nostack.yml
docker-compose.final.yml

16
frontend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:lts AS build
WORKDIR /usr/src/gdq-archive/frontend/
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./ ./
ARG NODE_ENV
ARG UPSTREAM_URL
ARG UPSTREAM_DIRECT_URL
RUN yarn build
EXPOSE 3000
STOPSIGNAL SIGTERM
CMD ["yarn", "start"]

30
frontend/README.md Normal file
View File

@ -0,0 +1,30 @@
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
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
## 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/import?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.

View File

@ -0,0 +1,75 @@
import React, { useState, useRef } from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import FormControl from 'react-bootstrap/FormControl';
import Button from 'react-bootstrap/Button';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Overlay, Tooltip } from 'react-bootstrap';
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import { FormattedMessage } from './localization';
const CopyField = ({
children,
copyIcon = 'copy',
icon = null,
}: {
children: string,
copyIcon?: IconProp,
icon?: IconProp,
}) => {
const [show, setShow] = useState(false);
const target = useRef(null);
const textbox = useRef(null);
let timer: NodeJS.Timeout = null;
function doCopy() {
if (timer !== null) {
clearTimeout(timer);
}
textbox.current.select();
document.execCommand('copy');
setShow(true);
timer = setTimeout(() => {
setShow(false);
}, 3000);
return false;
}
return (
<InputGroup>
{
icon
? (
<InputGroup.Prepend>
<InputGroup.Text>
<FontAwesomeIcon icon="share" />
</InputGroup.Text>
</InputGroup.Prepend>
)
: ''
}
<FormControl ref={textbox} readOnly value={children} />
<InputGroup.Append>
<Button ref={target} onClick={doCopy}>
<FontAwesomeIcon icon={copyIcon} />
<Overlay target={target.current} show={show} placement="top">
{(props) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<Tooltip id="overlay-copy" {...props}>
<FormattedMessage
id="CopyField.copied"
defaultMessage="Copied!"
description="Tooltip shown when user clicks the Copy button."
/>
</Tooltip>
)}
</Overlay>
</Button>
</InputGroup.Append>
</InputGroup>
);
};
export default CopyField;

View File

@ -0,0 +1,40 @@
import React from 'react';
// import useSWR from 'swr';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
Button,
// Spinner
} from 'react-bootstrap';
import { FormattedMessage } from './localization';
export default function DarkToggler({
isDarkEnabled = false,
onChangeDarkMode = null,
disabled = false,
showLoading = false,
}: {
isDarkEnabled?: boolean,
onChangeDarkMode?: (value: boolean) => void,
disabled?: boolean,
showLoading?: boolean,
} = {}) {
return (
<Button
onClick={() => {
onChangeDarkMode(!isDarkEnabled);
}}
disabled={disabled || showLoading}
variant="outline-secondary"
active={!isDarkEnabled}
>
<FontAwesomeIcon icon={[isDarkEnabled ? 'far' : 'fas', 'lightbulb']} />
<span className="sr-only">
<FormattedMessage
id="DarkToggler.screenReaderText"
defaultMessage="Toggle dark mode"
description="Screen reader description of the dark mode toggle button"
/>
</span>
</Button>
);
}

View File

@ -0,0 +1,18 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { Button } from 'react-bootstrap';
import { getDownloadURL } from 'util/api';
import { FormattedMessage } from './localization';
export default function DownloadButton({ id, fileName }: { id: string, fileName: string }) {
return (
<Button variant="success" href={getDownloadURL(id, fileName)}>
<FontAwesomeIcon icon="download" className="mr-2" />
<FormattedMessage
id="DownloadButton.download"
defaultMessage="Download"
description="Text of the download button"
/>
</Button>
);
}

View File

@ -0,0 +1,35 @@
import Fuse from 'fuse.js';
import React from 'react';
export default function Filter<T, U extends React.ReactNode>({
items,
query = '',
isCaseSensitive = false,
keys,
output,
}: {
items: Array<T>,
query: string,
isCaseSensitive?: boolean,
keys: Array<string>,
output: (filteredItems: Array<Fuse.FuseResult<T>>) => U,
}) {
if (query.length > 0) {
const fuse = new Fuse(items, {
isCaseSensitive,
keys,
});
const filteredItems = fuse.search(query);
return (
<>{output(filteredItems)}</>
);
}
return (
<>
{output(items.map((item, refIndex) => ({
item,
refIndex,
})))}
</>
);
}

View File

@ -0,0 +1,61 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import {
ButtonGroup, Dropdown, DropdownButton,
} from 'react-bootstrap';
import { FormattedMessage } from './localization';
import { availableLocales, defaultLocale, localeDescriptions } from '../util/localization';
export default function LocaleSwitcher({
locale = defaultLocale,
onChangeLocale = null,
disabled = false,
showLoading = false,
}: {
locale?: string,
onChangeLocale?: (value: string) => void,
disabled?: boolean,
showLoading?: boolean,
} = {}) {
return (
<DropdownButton
disabled={showLoading || disabled}
as={ButtonGroup}
variant="outline-secondary"
alignRight
id="dropdown-locale"
title={(
<>
<FontAwesomeIcon icon="language" />
<span className="sr-only">
<FormattedMessage
id="LocaleSwitcher.screenReaderText"
defaultMessage="Switch language"
description="Screen reader description of the locale switcher button"
/>
</span>
</>
)}
>
{
availableLocales.map((thisLocale) => (
<Dropdown.Item
key={thisLocale}
onClick={
onChangeLocale
? () => { onChangeLocale(thisLocale); }
: null
}
active={locale === thisLocale}
data-lang={thisLocale}
>
<FormattedMessage
id={`LocaleSwitcher.language.${thisLocale}`}
defaultMessage={localeDescriptions[thisLocale]}
/>
</Dropdown.Item>
))
}
</DropdownButton>
);
}

View File

@ -0,0 +1,22 @@
import { selectUnit } from '@formatjs/intl-utils';
import React from 'react';
import { useIntl } from 'react-intl';
import { FormattedRelativeTime } from './localization';
export default function RelativeTime({
children: dateTimeValue,
}: {
children: Date,
}) {
const intl = useIntl();
const { value, unit } = selectUnit(dateTimeValue, new Date());
const title = `${intl.formatDate(dateTimeValue)} ${intl.formatTime(dateTimeValue)}`;
return (
<time title={title} dateTime={dateTimeValue.toISOString()}>
<FormattedRelativeTime
value={value}
unit={unit}
/>
</time>
);
}

View File

@ -0,0 +1,10 @@
import React from 'react';
import { defaultLocale } from '../util/localization';
const UserContext = React.createContext({
enableDark: false,
locale: defaultLocale,
messages: [],
});
export default UserContext;

View File

@ -0,0 +1,98 @@
import React, { ChangeEvent } from 'react';
import FormControl from 'react-bootstrap/FormControl';
import InputGroup from 'react-bootstrap/InputGroup';
import ListGroup from 'react-bootstrap/ListGroup';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { injectIntl, IntlShape } from 'react-intl';
import VideoListItem from './VideoListItem';
import Filter from './Filter';
import { VideoEntry } from '../util/datatypes/VideoList';
interface VideoListProps {
intl: IntlShape,
id: string,
thumbnailServerURL: string,
videos: Array<VideoEntry>,
}
interface VideoListState {
query: string
}
class VideoList extends React.Component<VideoListProps, VideoListState> {
constructor(props: VideoListProps) {
super(props);
this.state = {
query: '',
};
this.onQueryChange = this.onQueryChange.bind(this);
}
onQueryChange({ target }: ChangeEvent<HTMLInputElement>) {
this.setState({
query: target.value,
});
}
render() {
const { query } = this.state;
const {
intl,
id,
thumbnailServerURL,
videos,
} = this.props;
return (
<div>
<InputGroup>
<InputGroup.Prepend>
<InputGroup.Text id="search-prepend">
<FontAwesomeIcon icon="search" />
</InputGroup.Text>
</InputGroup.Prepend>
<FormControl
placeholder={intl.formatMessage({ id: 'VideoList.Search.Placeholder', defaultMessage: 'Type something to search here…' })}
aria-label={intl.formatMessage({ id: 'VideoList.Search.Label', defaultMessage: 'Search' })}
aria-describedby="search-prepend"
value={query}
onChange={this.onQueryChange}
/>
</InputGroup>
<ListGroup>
<Filter
items={videos}
query={query}
keys={['title', 'fileName']}
output={(filteredVideos) => filteredVideos.map(({
item: {
duration,
fileName,
title,
sourceVideoStart,
sourceVideoEnd,
},
refIndex: index,
}) => (
<VideoListItem
key={index}
duration={duration}
id={id}
thumbnailServerURL={thumbnailServerURL}
fileName={fileName}
title={title}
sourceVideoStart={sourceVideoStart}
sourceVideoEnd={sourceVideoEnd}
/>
))}
/>
</ListGroup>
</div>
);
}
}
export default injectIntl(VideoList);

View File

@ -0,0 +1,14 @@
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/variables';
@import '~bootstrap/scss/mixins';
.thumbnail {
flex-grow: 0;
width: 96px;
@include media-breakpoint-up(md) {
width: 160px;
}
@include media-breakpoint-up(xl) {
width: 256px;
}
}

View File

@ -0,0 +1,100 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ListGroup from 'react-bootstrap/ListGroup';
import Media from 'react-bootstrap/Media';
import Image from 'react-bootstrap/Image';
import Link from 'next/link';
import { getThumbnailURL } from '../util';
import FormattedDuration from './localization/FormattedDuration';
import style from './VideoListItem.module.scss';
import sanitizeTitle from '../util/sanitizeTitle';
export default function VideoListItem({
thumbnailServerURL,
duration,
id,
fileName,
title,
sourceVideoStart,
sourceVideoEnd,
}: {
thumbnailServerURL: string,
duration: number | string,
id: string,
fileName: string,
title: string,
sourceVideoStart: number | string,
sourceVideoEnd: number | string,
}) {
let displayDuration = null;
if (duration !== null) {
displayDuration = duration;
} else if (
sourceVideoStart !== null
&& sourceVideoEnd !== null
&& sourceVideoStart !== sourceVideoEnd
) {
let videoEnd: number;
if (typeof sourceVideoEnd === 'string') {
videoEnd = parseFloat(sourceVideoEnd);
} else {
videoEnd = sourceVideoEnd;
}
let videoStart: number;
if (typeof sourceVideoStart === 'string') {
videoStart = parseFloat(sourceVideoStart);
} else {
videoStart = sourceVideoStart;
}
displayDuration = videoEnd - videoStart;
}
const titleUrlSlug = sanitizeTitle(title);
return (
<Link passHref href="/[id]/[vslug]" as={`/${id}/${titleUrlSlug}`}>
<ListGroup.Item action>
<Media>
<Image
className={['mr-3', style.thumbnail].join(' ')}
src={getThumbnailURL(thumbnailServerURL, id, fileName, 90 * 1000, {
width: 96,
})}
srcSet={[96, 96 * 2, 96 * 3]
.map((width) => [
getThumbnailURL(thumbnailServerURL, id, fileName, 90 * 1000, {
width,
}),
`${width}w`,
])
.map((item) => item.join(' '))
.join(', ')}
alt={title}
/>
<Media.Body>
<h5 className="mt-0 mb-3">{title}</h5>
<p>
{displayDuration !== null ? (
<span>
<FontAwesomeIcon icon="clock" />
{' '}
<FormattedDuration
seconds={displayDuration}
format="{hours} {minutes} {seconds}"
unitDisplay="short"
/>
</span>
) : (
''
)}
</p>
</Media.Body>
</Media>
</ListGroup.Item>
</Link>
);
}

View File

@ -0,0 +1,38 @@
import * as React from 'react';
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js';
// import 'videojs-contrib-dash';
import { ResponsiveEmbed } from 'react-bootstrap';
export default class VideoPlayer extends React.Component<VideoJsPlayerOptions> {
videoNode: HTMLVideoElement;
player: VideoJsPlayer;
componentDidMount() {
this.player = videojs(this.videoNode, this.props, function onPlayerReady() {
console.log('Video.js Ready', this);
});
}
componentWillUnmount() {
if (this.player) {
this.player.dispose();
}
}
render() {
return (
<ResponsiveEmbed aspectRatio="16by9">
<div>
<div data-vjs-player>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={(node) => { this.videoNode = node; }}
className="video-js"
/>
</div>
</div>
</ResponsiveEmbed>
);
}
}

View File

@ -0,0 +1,4 @@
import FormattedDuration from 'react-intl-formatted-duration';
import WrapReactIntl from './WrapReactIntl';
export default WrapReactIntl(FormattedDuration);

View File

@ -0,0 +1,6 @@
import React from 'react';
import { Spinner } from 'react-bootstrap';
import { FormattedMessage } from 'react-intl';
import WrapReactIntl from './WrapReactIntl';
export default WrapReactIntl(FormattedMessage);

View File

@ -0,0 +1,4 @@
import { FormattedNumber } from 'react-intl';
import WrapReactIntl from './WrapReactIntl';
export default WrapReactIntl(FormattedNumber);

View File

@ -0,0 +1,4 @@
import { FormattedRelativeTime } from 'react-intl';
import WrapReactIntl from './WrapReactIntl';
export default WrapReactIntl(FormattedRelativeTime);

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import { Spinner } from 'react-bootstrap';
import { isPolyfillPhaseDone } from 'util/localization';
export default function WrapReactIntl<P>(Component: React.ComponentType<P>) {
return (props: P) => {
if (!isPolyfillPhaseDone()) {
return (
<Spinner
as="span"
size="sm"
animation="border"
role="status"
variant="secondary"
/>
);
}
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<Component {...props} />
);
};
}

View File

@ -0,0 +1,11 @@
import FormattedDuration from './FormattedDuration';
import FormattedMessage from './FormattedMessage';
import FormattedNumber from './FormattedNumber';
import FormattedRelativeTime from './FormattedRelativeTime';
export {
FormattedDuration,
FormattedMessage,
FormattedNumber,
FormattedRelativeTime,
};

62
frontend/custom.d.ts vendored Normal file
View File

@ -0,0 +1,62 @@
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.ico' {
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
declare module '*.css' {
const content: Record<string, string>;
export default content;
}
declare module '*.scss' {
const content: Record<string, string>;
export default content;
}
declare module '*.sass' {
const content: Record<string, string>;
export default content;
}
declare module '*.styl' {
const content: Record<string, string>;
export default content;
}
declare module '*.less' {
const content: Record<string, string>;
export default content;
}
declare module '@formatjs/intl-numberformat/locale-data/*' {
export default undefined;
}
declare namespace Intl {
type NumberFormatPartTypes = 'currency' | 'decimal' | 'fraction' | 'group' | 'infinity' | 'integer' | 'literal' | 'minusSign' | 'nan' | 'plusSign' | 'percentSign';
interface NumberFormatPart {
type: NumberFormatPartTypes;
value: string;
}
interface NumberFormat {
formatToParts(number?: number): NumberFormatPart[];
static polyfilled?: boolean;
}
}

BIN
frontend/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 128 128" height="128px" id="Layer_1" version="1.1" viewBox="0 0 128 128" width="128px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g><g><path d="M84,20H44C19.699,20,0,39.703,0,64s19.699,44,44,44c7.211,0,13.996-1.766,20-4.844 C70.004,106.234,76.789,108,84,108c24.301,0,44-19.703,44-44S108.301,20,84,20z M84,100c-5.719,0-11.223-1.336-16.352-3.961 c-1.145-0.586-2.395-0.883-3.648-0.883s-2.504,0.297-3.648,0.883C55.223,98.664,49.719,100,44,100C24.148,100,8,83.852,8,64 s16.148-36,36-36h40c19.852,0,36,16.148,36,36S103.852,100,84,100z" fill="#B0BEC5"/></g></g><path d="M44,92c-6.617,0-12-5.383-12-12v-4h-4c-6.617,0-12-5.383-12-12s5.383-12,12-12h4v-4c0-6.617,5.383-12,12-12 s12,5.383,12,12v4h4c6.617,0,12,5.383,12,12s-5.383,12-12,12h-4v4C56,86.617,50.617,92,44,92z M28,60c-2.207,0-4,1.797-4,4 s1.793,4,4,4h8c2.211,0,4,1.789,4,4v8c0,2.203,1.793,4,4,4s4-1.797,4-4v-8c0-2.211,1.789-4,4-4h8c2.207,0,4-1.797,4-4s-1.793-4-4-4 h-8c-2.211,0-4-1.789-4-4v-8c0-2.203-1.793-4-4-4s-4,1.797-4,4v8c0,2.211-1.789,4-4,4H28z" fill="#546E7A"/><path d="M96,60c0,4.422-3.582,8-8,8s-8-3.578-8-8s3.582-8,8-8S96,55.578,96,60z" fill="#4CAF50"/><path d="M112,52c0,4.422-3.582,8-8,8s-8-3.578-8-8s3.582-8,8-8S112,47.578,112,52z" fill="#03A9F4"/><path d="M96,84c0,4.422-3.582,8-8,8s-8-3.578-8-8s3.582-8,8-8S96,79.578,96,84z" fill="#FF9800"/><path d="M112,76c0,4.422-3.582,8-8,8s-8-3.578-8-8s3.582-8,8-8S112,71.578,112,76z" fill="#F44336"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
frontend/images/gdqlogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

51
frontend/lang/de.json Normal file
View File

@ -0,0 +1,51 @@
{
"App.title": {
"defaultMessage": "Games Done Quick Instant Archive",
"description": "The full title of the website"
},
"Breadcrumb.homeTitle": {
"defaultMessage": "GDQ Instant Archive",
"description": "Root node text in breadcrumb"
},
"CopyField.copied": {
"defaultMessage": "Kopiert!",
"description": "Tooltip shown when user clicks the Copy button."
},
"DarkToggler.screenReaderText": {
"defaultMessage": "Dunkelmodus umschalten",
"description": "Screen reader description of the dark mode toggle button"
},
"Home.introText1": {
"defaultMessage": "Greife sofort auf deine Lieblings-Runs von GDQ zu!"
},
"Home.introText2": {
"defaultMessage": "Diese Website sammelt alle Runs sehr bald nachdem sie ausgestrahlt wurden und stellt sie zum Streamen zur Verfügung."
},
"LocaleSwitcher.screenReaderText": {
"defaultMessage": "Sprache ändern",
"description": "Screen reader description of the locale switcher button"
},
"LocaleSwitcher.language.de": {
"defaultMessage": "Deutsch"
},
"LocaleSwitcher.language.en": {
"defaultMessage": "Englisch"
},
"Navbar.brandText": {
"defaultMessage": "Instant Archive"
},
"VideoList.Search.Label": {
"defaultMessage": "Suche"
},
"VideoList.Search.Placeholder": {
"defaultMessage": "Gib hier etwas zum Suchen ein…"
},
"VideoPlayerPage.cutFrom": {
"defaultMessage": "Geschnitten von {url}",
"description": "Text below video that describes where a video was cut from"
},
"VideoPlayerPage.watchOnTwitch": {
"defaultMessage": "Auf Twitch anschauen",
"description": "Button below video that links to the exact position in the archived stream to watch it there."
}
}

47
frontend/lang/en.json Normal file
View File

@ -0,0 +1,47 @@
{
"App.title": {
"defaultMessage": "Games Done Quick Instant Archive",
"description": "The full title of the website"
},
"Breadcrumb.homeTitle": {
"defaultMessage": "GDQ Instant Archive",
"description": "Root node text in breadcrumb"
},
"CopyField.copied": {
"defaultMessage": "Copied!",
"description": "Tooltip shown when user clicks the Copy button."
},
"DarkToggler.screenReaderText": {
"defaultMessage": "Toggle dark mode",
"description": "Screen reader description of the dark mode toggle button"
},
"Home.introText1": {
"defaultMessage": "Instant access to your favorite VODs of Games Done Quick!"
},
"Home.introText2": {
"defaultMessage": "This website collects all the broadcasted runs and allows streaming and downloading them."
},
"LocaleSwitcher.screenReaderText": {
"defaultMessage": "Switch language",
"description": "Screen reader description of the locale switcher button"
},
"LocaleSwitcher.language.de": {
"defaultMessage": "German"
},
"LocaleSwitcher.language.en": {
"defaultMessage": "English"
},
"Navbar.brandText": {
"defaultMessage": "Instant Archive"
},
"VideoList.Search.Label": {
"defaultMessage": "Search"
},
"VideoList.Search.Placeholder": {
"defaultMessage": "Type something to search here…"
},
"VideoPlayerPage.watchOnTwitch": {
"defaultMessage": "Watch on Twitch",
"description": "Button below video that links to the exact position in the archived stream to watch it there."
}
}

2
frontend/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

12
frontend/next.config.js Normal file
View File

@ -0,0 +1,12 @@
const withPlugins = require('next-compose-plugins');
const optimizedImages = require('next-optimized-images');
module.exports = withPlugins([
[optimizedImages, {
/* config for next-optimized-images */
inlineImageLimit: -1,
}],
// your other plugins here
]);

117
frontend/package.json Normal file
View File

@ -0,0 +1,117 @@
{
"name": "gdq-archive-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "run-s i18n:build dev:next",
"dev:next": "next dev",
"build": "run-s i18n:build build:next",
"build:next": "next build",
"start": "next start",
"i18n:extract": "formatjs extract \"{public,components,pages}/**/*.{js,jsx,ts,tsx}\" --out-file .\\lang\\en.json --id-interpolation-pattern \"[sha512:contenthash:base64:6]\"",
"i18n:build:en": "formatjs compile lang/en.json --ast --out-file compiled-lang/en.json",
"i18n:build:de": "formatjs compile lang/de.json --ast --out-file compiled-lang/de.json",
"i18n:build": "run-p i18n:build:*",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"dependencies": {
"@forevolve/bootstrap-dark": "^1.0.0-alpha.1091",
"@formatjs/intl-numberformat": "^5.5.4",
"@formatjs/intl-utils": "^3.8.3",
"@fortawesome/fontawesome-free": "^5.14.0",
"@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/free-brands-svg-icons": "^5.14.0",
"@fortawesome/free-regular-svg-icons": "^5.14.0",
"@fortawesome/free-solid-svg-icons": "^5.14.0",
"@fortawesome/react-fontawesome": "^0.1.11",
"bootstrap": "^4.5.2",
"cssnano": "^4.1.10",
"file-loader": "^6.0.0",
"fuse.js": "^6.4.1",
"imagemin-optipng": "^8.0.0",
"imagemin-svgo": "^8.0.0",
"intl-messageformat": ">= 5.1",
"intl-messageformat-parser": "^6.0.1",
"jquery": "~3",
"native-url": "^0.3.4",
"next": "9.5.2",
"next-compose-plugins": "^2.2.0",
"next-iron-session": "^4.1.8",
"next-optimized-images": "^2.6.2",
"nprogress": "^0.2.0",
"popper.js": "^1.16.1",
"raw-loader": "^4.0.1",
"react": "16.13.1",
"react-bootstrap": "^1.3.0",
"react-dom": "16.13.1",
"react-intl": "^5.6.3",
"react-intl-formatted-duration": "^4.0.0",
"sass": "^1.26.10",
"shaka-player": "^3.0.3",
"shaka-player-react": "^1.0.1",
"swr": "^0.3.0",
"url-loader": "^4.1.0",
"url-slug": "^2.3.2",
"video.js": "^7.8.4",
"videojs-contrib-dash": "^2.11.0",
"webpack": "^4.0.0",
"xmlbuilder2": "^2.3.1"
},
"devDependencies": {
"@formatjs/cli": "^2.7.5",
"@types/node": "^14.6.0",
"@types/nprogress": "^0.2.0",
"@types/react": "^16.9.46",
"@typescript-eslint/eslint-plugin": "^3.9.1",
"@typescript-eslint/parser": "^3.9.1",
"eslint": "^7.7.0",
"eslint-config-airbnb-typescript": "^9.0.0",
"eslint-plugin-import": "2.21.2",
"eslint-plugin-jsx-a11y": "6.3.0",
"eslint-plugin-react": "7.20.0",
"eslint-plugin-react-hooks": "4",
"lint-staged": "^10.2.11",
"npm-run-all": "^4.1.5",
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-preset-env": "^6.7.0",
"typescript": "^4.0.2"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix"
]
},
"eslintConfig": {
"env": {
"browser": true,
"es2020": true
},
"extends": [
"airbnb-typescript"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 11,
"project": "./tsconfig.json",
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"react/prop-types": "off"
},
"settings": {
"react": {
"version": "detect"
}
}
}
}

141
frontend/pages/[id].tsx Normal file
View File

@ -0,0 +1,141 @@
import * as React from 'react';
import { Breadcrumb } from 'react-bootstrap';
import Head from 'next/head';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import { FormattedMessage } from '../components/localization';
import RelativeTime from '../components/RelativeTime';
import VideoList from '../components/VideoList';
import { notFound } from '../util/status';
import { getIndex, getVideos } from '../util/api';
import { VideoEntry } from '../util/datatypes/VideoList';
export async function getServerSideProps({ params: { id } }: { params: { id: string } }) {
// Fetch URL to thumbnails server
const {
ids,
servers: { thumbnails: thumbnailServerURL },
} = await getIndex();
const vodMeta = ids.find(({
id: thisID,
}: {
id: string
}) => id === thisID);
if (!vodMeta) {
return {
props: {},
};
}
const { title } = vodMeta;
// Fetch list of videos for this VOD ID
const vodInfo = await getVideos(id);
const { videos } = vodInfo;
let lastUpdatedAt = null;
lastUpdatedAt = vodInfo.lastUpdatedAt;
const finalVideos = videos
.map((video: VideoEntry) => ({
...video,
duration: typeof video.duration === 'string' ? parseFloat(video.duration) : video.duration || null,
sourceVideoStart: typeof video.sourceVideoStart === 'string' ? parseFloat(video.sourceVideoStart) : video.sourceVideoStart || null,
sourceVideoEnd: typeof video.sourceVideoEnd === 'string' ? parseFloat(video.sourceVideoEnd) : video.sourceVideoEnd || null,
}));
// Pass data to the page via props
return {
props: {
id,
thumbnailServerURL,
title,
videos: finalVideos,
lastUpdatedAt,
},
};
}
export default function VideoListPage({
id,
lastUpdatedAt,
thumbnailServerURL,
title,
videos,
}: {
id: string,
lastUpdatedAt: string,
thumbnailServerURL: string,
title: string,
videos: Array<VideoEntry>
}) {
if (!id) {
return notFound();
}
let lastUpdatedDate = null;
if (typeof (lastUpdatedAt) === 'string') {
lastUpdatedDate = new Date(lastUpdatedAt);
}
const intl = useIntl();
return (
<div>
<Head>
<title>
{title}
{' '}
{' '}
{intl.formatMessage({
id: 'App.title',
description: 'The full title of the website',
defaultMessage: 'Games Done Quick Instant Archive',
})}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Breadcrumb>
<Link passHref href="/">
<Breadcrumb.Item>
<FormattedMessage
id="Breadcrumb.homeTitle"
defaultMessage="GDQ Instant Archive"
description="Root node text in breadcrumb"
/>
</Breadcrumb.Item>
</Link>
<Link passHref href="/[id]" as={`/${id}`}>
<Breadcrumb.Item active>
{title}
</Breadcrumb.Item>
</Link>
</Breadcrumb>
<VideoList
id={id}
thumbnailServerURL={thumbnailServerURL}
videos={videos}
/>
{
lastUpdatedDate
? (
<footer className="pt-4 my-md-5 pt-md-5 border-top">
Last updated
{' '}
<RelativeTime>{lastUpdatedDate}</RelativeTime>
</footer>
)
: ''
}
</div>
);
}

View File

@ -0,0 +1,264 @@
import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
Breadcrumb, Button, ButtonGroup, Col, Row,
} from 'react-bootstrap';
import { useIntl } from 'react-intl';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { IncomingMessage } from 'http';
import { VideoEntry } from 'util/datatypes/VideoList';
import DownloadButton from 'components/DownloadButton';
import { getHLSMasterURL } from '../../util';
import VideoPlayer from '../../components/VideoPlayer';
import CopyField from '../../components/CopyField';
import { FormattedMessage } from '../../components/localization';
import sanitizeFileName from '../../util/sanitizeFileName';
import sanitizeTitle from '../../util/sanitizeTitle';
import { notFound } from '../../util/status';
import { getIndex, getVideos } from '../../util/api';
export const getServerSideProps: GetServerSideProps = async (context) => {
const { req }: { req: IncomingMessage } = context;
const { id, vslug } = context.params || {};
if (typeof id !== 'string') {
throw new Error('only expected a single id');
}
if (typeof vslug !== 'string') {
throw new Error('only expected a single vslug');
}
// Fetch URL to thumbnails server
const {
ids,
servers: { hls: hlsServerURL, dash: dashServerURL },
} = await getIndex();
const vodMeta = ids.find(({
id: thisID,
}: {
id: string
}) => id === thisID);
if (!vodMeta) {
return { props: {} };
}
// Fetch list of videos for this VOD ID
const vodInfo = await getVideos(id);
let videos;
if (Array.isArray(vodInfo)) {
videos = vodInfo;
} else {
videos = vodInfo.videos;
}
// Check if vslug is actually an index number (old app style)
/*
NOTE - parseInt will accept strings CONTAINING numbers.
This is supposed to reject strings that are not JUST numbers.
*/
const vindexNum = +vslug;
if (!Number.isNaN(vindexNum)) {
// Check if video exists
if (vindexNum < 0 || vindexNum >= videos.length) {
return { props: {} };
}
const video = videos[vindexNum];
return {
props: {
redirect: true,
id,
video,
vslug: sanitizeTitle(video.title),
},
};
}
// Check if vslug is actually point to a file name
const sanitizedFileName = sanitizeFileName(vslug);
const realVIndex = videos.findIndex(
(video: VideoEntry) => video.fileName === sanitizedFileName,
);
if (realVIndex >= 0) {
const video = videos[realVIndex];
return {
props: {
redirect: true,
id,
video: realVIndex,
vslug: sanitizeTitle(video.title),
},
};
}
// Check if we can find any video with matching vslug
const video = videos.find(({ title }: { title: string }) => sanitizeTitle(title) === vslug);
if (!video) {
return { props: {} };
}
// At this point we found the right video, just get more information at this point
const { title } = vodMeta;
// let basePath = null;
// basePath = `${JSON.stringify(req.toString())}`;
const basePath = `https://${req.headers.host}`;
// Pass data to the page via props
return {
props: {
id,
vslug,
video,
title,
hlsServerURL,
dashServerURL,
basePath,
},
};
};
export default function VideoPlayerPage({
id,
vslug,
video,
redirect,
title,
hlsServerURL,
// dashServerURL,
basePath,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
if (redirect) {
const router = useRouter();
React.useEffect(() => {
router.push(`/${id}/${vslug}`);
});
return (
<p>
You will be redirected
{' '}
<Link href="/[id]/[vslug]" as={`/${id}/${vslug}`}>
<span>here</span>
</Link>
</p>
);
}
if (!video) {
return notFound();
}
const intl = useIntl();
const {
fileName,
title: videoTitle,
sourceVideoURL,
sourceVideoStart,
} = video;
return (
<div>
<Head>
<title>
{videoTitle}
{' '}
{' '}
{title}
{' '}
{' '}
{intl.formatMessage({
id: 'App.title',
description: 'The full title of the website',
defaultMessage: 'Games Done Quick Instant Archive',
})}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Breadcrumb>
<Link passHref href="/">
<Breadcrumb.Item>
<FormattedMessage
id="Breadcrumb.homeTitle"
defaultMessage="GDQ Instant Archive"
description="Root node text in breadcrumb"
/>
</Breadcrumb.Item>
</Link>
<Link passHref href="/[id]" as={`/${id}`}>
<Breadcrumb.Item>{title}</Breadcrumb.Item>
</Link>
<Link passHref href="/[id]/[vslug]" as={`/${id}/${vslug}`}>
<Breadcrumb.Item active>{videoTitle}</Breadcrumb.Item>
</Link>
</Breadcrumb>
<VideoPlayer
autoplay
controls
sources={[
// getDASHManifestURL(dashServerURL, id, fileName),
{ src: getHLSMasterURL(hlsServerURL, id, fileName) },
]}
aspectRatio="16:9"
fill
/>
<h1>
{title}
:
{' '}
{videoTitle}
</h1>
<Row className="mb-3">
<Col sm={12} md={7}>
<ButtonGroup>
<DownloadButton id={id} fileName={fileName} />
{sourceVideoURL ? (
<Button
href={
sourceVideoStart
? `${sourceVideoURL}?t=${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60
}s`
: sourceVideoURL
}
target="blank"
variant="twitch"
>
<FontAwesomeIcon icon={['fab', 'twitch']} className="mr-2" />
<FormattedMessage
id="VideoPlayerPage.watchOnTwitch"
description="Button below video that links to the exact position in the archived stream to watch it there."
defaultMessage="Watch on Twitch"
/>
</Button>
) : (
''
)}
</ButtonGroup>
</Col>
<Col sm={12} md={5}>
<CopyField icon="share">
{[basePath, id, vslug].join('/')}
</CopyField>
</Col>
</Row>
</div>
);
}

297
frontend/pages/_app.tsx Normal file
View File

@ -0,0 +1,297 @@
import * as React from 'react';
import {
config as fontawesomeSvgCoreConfig,
library,
} from '@fortawesome/fontawesome-svg-core';
import {
faClock,
faSearch,
faShare,
faLightbulb,
faCopy,
faLanguage,
} from '@fortawesome/free-solid-svg-icons';
import { faLightbulb as farLightbulb } from '@fortawesome/free-regular-svg-icons';
import { faTwitch as fabTwitch } from '@fortawesome/free-brands-svg-icons';
import '@fortawesome/fontawesome-svg-core/styles.css';
import Container from 'react-bootstrap/Container';
import Navbar from 'react-bootstrap/Navbar';
import { IntlProvider, FormattedMessage } from 'react-intl';
import Link from 'next/link';
import Router from 'next/router';
import App, { AppContext } from 'next/app';
import useSWR, { SWRConfig } from 'swr';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import '../styles/main.scss';
import Image from 'react-bootstrap/Image';
import Head from 'next/head';
import { ButtonGroup } from 'react-bootstrap';
import { shouldPolyfill } from '@formatjs/intl-numberformat/should-polyfill';
import HomeStyle from '../styles/Home.module.css';
import DarkToggler from '../components/DarkToggler';
import { fetchJson } from '../util/api';
import { defaultLocale, loadLocaleData, loadLocalePolyfill } from '../util/localization';
import gdqLogo from '../images/gdqlogo.png';
import favicon from '../images/favicon.svg';
import LocaleSwitcher from '../components/LocaleSwitcher';
import withSession from '../util/session';
fontawesomeSvgCoreConfig.autoAddCss = false;
library.add(
faClock,
faCopy,
faLanguage,
faSearch,
faShare,
fabTwitch,
faLightbulb,
farLightbulb,
);
Router.events.on('routeChangeStart', (url) => {
console.log(`Loading: ${url}`);
NProgress.start();
});
Router.events.on('routeChangeComplete', () => NProgress.done());
Router.events.on('routeChangeError', () => NProgress.done());
export interface GDQArchiveAppPageProps extends Record<string, any> {
locale: string,
messages: any,
enableDark: boolean,
}
const polyfillRequired = shouldPolyfill();
function GDQArchiveApp({
Component,
pageProps: {
initialEnableDark,
initialLocale,
initialMessages,
...pageProps
},
}: {
Component: React.ElementType<Record<string, any>>,
pageProps: GDQArchiveAppPageProps,
}) {
/* Component state */
const [currentMessages, setMessages] = React.useState(initialMessages);
const [isChangingLocale, setIsChangingLocale] = React.useState(false);
const [isChangingDarkMode, setIsChangingDarkMode] = React.useState(false);
const [{
loadedPolyfill,
isLoadingPolyfill,
}, setPolyfillState] = React.useState({
loadedPolyfill: null,
isLoadingPolyfill: false,
});
/* Synchronize via SWR */
const {
data: userData,
error,
isValidating,
mutate,
} = useSWR('/api/user', {
fetcher: fetchJson,
initialData: {
locale: initialLocale,
enableDark: initialEnableDark,
},
onError(err) {
console.log(err);
},
});
if (error) {
console.log(error);
}
const {
enableDark: enableDarkMode,
locale: currentLocale,
} = userData;
/* Polyfill for locale */
if (!isLoadingPolyfill
&& polyfillRequired
&& currentLocale !== loadedPolyfill) {
console.log('load locale polyfill start');
loadLocalePolyfill(currentLocale)
.then(() => {
console.log('set polyfill state end');
setPolyfillState({
loadedPolyfill: currentLocale,
isLoadingPolyfill: false,
});
});
}
async function onChangeLocale(value: string) {
setIsChangingLocale(true);
const formData = new URLSearchParams();
formData.append('locale', value);
await fetchJson('/api/changePreferences', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData,
});
mutate({ locale: value });
setMessages(await loadLocaleData(value));
setIsChangingLocale(false);
}
let darkToggleAnimationTimer: NodeJS.Timeout = null;
function triggerDarkToggleAnimation() {
document.documentElement.setAttribute('data-toggled-dark', 'true');
if (darkToggleAnimationTimer !== null) {
clearTimeout(darkToggleAnimationTimer);
}
darkToggleAnimationTimer = setTimeout(() => {
document.documentElement.removeAttribute('data-toggled-dark');
}, 1200);
}
/**
* @param {bool} value
*/
async function onChangeDarkMode(value: boolean) {
setIsChangingDarkMode(true);
const formData = new URLSearchParams();
formData.append('enableDark', value.toString());
await fetchJson('/api/changePreferences', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData,
});
mutate({ enableDark: value });
triggerDarkToggleAnimation();
setIsChangingDarkMode(false);
}
React.useEffect(() => {
if (enableDarkMode) {
document.documentElement.setAttribute('data-enable-dark', 'true');
} else {
document.documentElement.removeAttribute('data-enable-dark');
}
document.documentElement.setAttribute('lang', currentLocale);
});
return (
<SWRConfig
value={{
fetcher: fetchJson,
onError(err) {
console.error(err);
},
}}
>
<IntlProvider
messages={currentMessages}
locale={currentLocale}
defaultLocale={defaultLocale}
>
<Navbar bg="dark" variant="dark">
<Link passHref href="/">
<Navbar.Brand>
<Image
src={gdqLogo}
alt="Games Done Quick"
className={[
HomeStyle.logo,
'd-inline-block',
'align-middle',
].join(' ')}
/>
{' '}
<FormattedMessage
id="Navbar.brandText"
defaultMessage="Instant Archive"
/>
</Navbar.Brand>
</Link>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse
id="basic-navbar-nav"
className="justify-content-end"
>
<ButtonGroup>
<LocaleSwitcher
locale={currentLocale}
onChangeLocale={onChangeLocale}
disabled={isChangingLocale || isValidating}
showLoading={isChangingLocale}
/>
<DarkToggler
isDarkEnabled={enableDarkMode}
onChangeDarkMode={onChangeDarkMode}
disabled={isChangingDarkMode || isValidating}
showLoading={isChangingDarkMode}
/>
</ButtonGroup>
</Navbar.Collapse>
</Navbar>
<Head>
<link rel="icon" type="image/svg+xml" href={favicon} />
</Head>
<Container>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<Component {...pageProps} />
</Container>
</IntlProvider>
</SWRConfig>
);
}
const getInitialPropsFromReq = withSession(async (req) => {
const initialLocale = req.session.get('locale') || defaultLocale;
const initialMessages = await loadLocaleData(initialLocale);
const initialEnableDark = req.session.get('enable-dark') || false;
return {
pageProps: {
initialEnableDark,
initialLocale,
initialMessages,
},
};
});
GDQArchiveApp.getInitialProps = async (appContext: AppContext) => {
const result = ({
...await App.getInitialProps(appContext),
...await getInitialPropsFromReq(appContext.ctx.req, appContext.ctx.res),
});
// console.log('getInitialProps for app:', result);
return result;
};
export default GDQArchiveApp;

View File

@ -0,0 +1,66 @@
import Document, {
Html,
Head,
Main,
NextScript,
DocumentContext,
DocumentInitialProps,
} from 'next/document';
import { SWRConfig } from 'swr';
import * as React from 'react';
import { IntlProvider } from 'react-intl';
import { fetchJson } from '../util/api';
import withSession, { IncomingMessageWithSession } from '../util/session';
import { defaultLocale, loadLocaleData, LocaleData } from '../util/localization';
const getNewProps = withSession(async (req: IncomingMessageWithSession) => {
const enableDark = req.session.get('enable-dark') || false;
const locale = req.session.get('locale') || defaultLocale;
const messages = await loadLocaleData(locale);
return {
enableDark,
locale,
messages,
};
});
class GDQArchiveDocument extends Document<{
enableDark: boolean,
locale: string,
messages: LocaleData,
}> {
static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> {
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
...await getNewProps(ctx.req, ctx.res),
};
}
render() {
const { enableDark, locale, messages } = this.props;
return (
<SWRConfig
value={{
fetcher: fetchJson,
onError: (err) => {
console.error(err);
},
}}
>
<IntlProvider messages={messages} locale={locale} defaultLocale={defaultLocale}>
<Html lang={locale} data-enable-dark={enableDark}>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
</IntlProvider>
</SWRConfig>
);
}
}
export default GDQArchiveDocument;

View File

@ -0,0 +1,34 @@
import { availableLocales } from '../../util/localization';
import parseBool from '../../util/parseBool';
import withSession from '../../util/session';
export default withSession(async (req, res) => {
if (!req.body) {
res.status(400).json({
success: false,
error: 'bad request',
});
return;
}
if (req.body.enableDark) {
const enableDark = parseBool(req.body.enableDark);
req.session.set('enable-dark', enableDark);
}
if (req.body.locale) {
if (!availableLocales.includes(req.body.locale)) {
res.status(400).json({
success: false,
error: 'wanted locale does not exist',
});
}
req.session.set('locale', req.body.locale);
}
await req.session.save();
res.json({
success: true,
});
});

View File

@ -0,0 +1,14 @@
import { defaultLocale } from '../../util/localization';
import withSession from '../../util/session';
export default withSession(async (req, res) => {
const enableDark = req.session.get('enable-dark') || false;
const locale = req.session.get('locale') || defaultLocale;
res.setHeader('cache-control', 'public, max-age=0, must-revalidate');
res.json({
enableDark,
locale,
});
});

76
frontend/pages/index.tsx Normal file
View File

@ -0,0 +1,76 @@
import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import ListGroup from 'react-bootstrap/ListGroup';
import { Breadcrumb } from 'react-bootstrap';
import { useIntl } from 'react-intl';
import { FormattedMessage } from '../components/localization';
import { VideoOnDemandIndex } from '../util/datatypes/VideoOnDemandIdentifier';
import { getIndex } from '../util/api';
export async function getServerSideProps() {
// Fetch VOD IDs
const { ids } = await getIndex();
return {
props: {
ids,
},
};
}
export default function Home({ ids }: VideoOnDemandIndex) {
const intl = useIntl();
return (
<div>
<Head>
<title>
{intl.formatMessage({
id: 'App.title',
description: 'The full title of the website',
defaultMessage: 'Games Done Quick Instant Archive',
})}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Breadcrumb>
<Link passHref href="/">
<Breadcrumb.Item active>
<FormattedMessage
id="Breadcrumb.homeTitle"
defaultMessage="GDQ Instant Archive"
description="Root node text in breadcrumb"
/>
</Breadcrumb.Item>
</Link>
</Breadcrumb>
<h1>
<FormattedMessage
id="Home.introText1"
defaultMessage="Instant access to your favorite VODs of Games Done Quick!"
/>
</h1>
<p>
<FormattedMessage
id="Home.introText2"
defaultMessage="This website collects all the broadcasted runs and allows streaming and downloading them."
/>
</p>
<ListGroup>
{ids.map(({ id, title }) => (
<Link key={id} passHref href="/[id]" as={`/${id}`}>
<ListGroup.Item action>
<h5>{title}</h5>
</ListGroup.Item>
</Link>
))}
</ListGroup>
</div>
);
}

View File

@ -0,0 +1,19 @@
import { GetServerSideProps } from 'next';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const { req, res } = ctx;
const basePath = `https://${req.headers.host}`;
res.setHeader('content-type', 'text-plain');
res.write('User-agent: *\n');
res.write(`Sitemap: ${basePath}/sitemap.xml\n`);
res.write('Disallow: /api/*\n');
res.end();
return {
props: {},
};
};
export default () => {};

View File

@ -0,0 +1,62 @@
import { GetServerSideProps } from 'next';
import { create } from 'xmlbuilder2';
import { getIndex, getVideos } from '../util/api';
import sanitizeTitle from '../util/sanitizeTitle';
const urlsetNamespace = 'http://www.sitemaps.org/schemas/sitemap/0.9';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const { req, res } = ctx;
const { ids } = await getIndex();
const basePath = `https://${req.headers.host}`;
const url = [
{
loc: basePath,
},
];
const results = await Promise.all(
ids.map(async ({ id }) => {
const vodInfo = await getVideos(id);
const { lastUpdatedAt, videos } = vodInfo;
return [
{
loc: `${basePath}/${id}`,
lastmod: lastUpdatedAt,
},
...videos.map(({
title,
}) => ({
loc: `${basePath}/${id}/${sanitizeTitle(title)}`,
lastmod: lastUpdatedAt,
})),
];
}, url),
);
url.push(
...(results
.reduce((old, current) => [...old, ...current], [])),
);
const root = create({ version: '1.0' })
.ele(urlsetNamespace, 'urlset')
.ele({
url,
})
.doc();
// convert the XML tree to string
const xml = root.end({ prettyPrint: true });
res.setHeader('Content-Type', 'text/xml');
res.write(xml);
res.end();
return {
props: {},
};
};
export default () => {};

View File

@ -0,0 +1,23 @@
module.exports = {
plugins: [
'postcss-flexbugs-fixes',
[
'postcss-preset-env',
{
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
features: {
'custom-properties': false,
},
},
],
[
'cssnano',
{
preset: 'default',
},
],
],
};

View File

@ -0,0 +1,124 @@
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}
.footer img {
margin-left: 0.5rem;
}
.footer a {
display: flex;
justify-content: center;
align-items: center;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
line-height: 1.5;
font-size: 1.5rem;
}
.code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 3rem;
}
.card {
margin: 1rem;
flex-basis: 45%;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h3 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
.logo {
height: 1em;
vertical-align: baseline;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}

View File

@ -0,0 +1,96 @@
/*
* BOOTSTRAP
*/
html:not([data-enable-dark='true']) {
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/variables';
@import '~bootstrap/scss/mixins';
@import '~bootstrap/scss/root';
@import '~bootstrap/scss/reboot';
@import '~bootstrap/scss/type';
@import '~bootstrap/scss/images';
// @import '~bootstrap/scss/code';
@import '~bootstrap/scss/grid';
// @import '~bootstrap/scss/tables';
@import '~bootstrap/scss/forms';
@import '~bootstrap/scss/buttons';
@import '~bootstrap/scss/transitions';
@import '~bootstrap/scss/dropdown';
@import '~bootstrap/scss/button-group';
@import '~bootstrap/scss/input-group';
// @import '~bootstrap/scss/custom-forms';
@import '~bootstrap/scss/nav';
@import '~bootstrap/scss/navbar';
// @import '~bootstrap/scss/card';
@import '~bootstrap/scss/breadcrumb';
// @import '~bootstrap/scss/pagination';
// @import '~bootstrap/scss/badge';
// @import '~bootstrap/scss/jumbotron';
// @import '~bootstrap/scss/alert';
// @import '~bootstrap/scss/progress';
@import '~bootstrap/scss/media';
@import '~bootstrap/scss/list-group';
@import '~bootstrap/scss/close';
// @import '~bootstrap/scss/toasts';
// @import '~bootstrap/scss/modal';
@import '~bootstrap/scss/tooltip';
@import '~bootstrap/scss/popover';
// @import '~bootstrap/scss/carousel';
@import '~bootstrap/scss/spinners';
@import '~bootstrap/scss/utilities';
// @import '~bootstrap/scss/print';
}
/**
* BOOTSTRAP DARK
*/
html[data-enable-dark='true'] {
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/variables';
@import '~@forevolve/bootstrap-dark/scss/dark-variables';
@import '~bootstrap/scss/mixins';
@import '~@forevolve/bootstrap-dark/scss/dark-mixins';
@import '~bootstrap/scss/root';
@import '~bootstrap/scss/reboot';
@import '~bootstrap/scss/type';
@import '~bootstrap/scss/images';
// @import '~bootstrap/scss/code';
@import '~bootstrap/scss/grid';
// @import '~bootstrap/scss/tables';
// @import '~@forevolve/bootstrap-dark/scss/dark-tables';
@import '~bootstrap/scss/forms';
@import '~bootstrap/scss/buttons';
@import '~bootstrap/scss/transitions';
@import '~bootstrap/scss/dropdown';
@import '~bootstrap/scss/button-group';
@import '~bootstrap/scss/input-group';
@import '~@forevolve/bootstrap-dark/scss/dark-input-group';
// @import '~bootstrap/scss/custom-forms';
@import '~bootstrap/scss/nav';
@import '~bootstrap/scss/navbar';
// @import '~bootstrap/scss/card';
@import '~bootstrap/scss/breadcrumb';
// @import '~bootstrap/scss/pagination';
// @import '~bootstrap/scss/badge';
// @import '~bootstrap/scss/jumbotron';
// @import '~bootstrap/scss/alert';
// @import '~bootstrap/scss/progress';
@import '~bootstrap/scss/media';
@import '~bootstrap/scss/list-group';
@import '~bootstrap/scss/close';
// @import '~bootstrap/scss/toasts';
// @import '~bootstrap/scss/modal';
@import '~bootstrap/scss/tooltip';
@import '~bootstrap/scss/popover';
// @import '~bootstrap/scss/carousel';
@import '~bootstrap/scss/spinners';
@import '~bootstrap/scss/utilities';
// @import '~bootstrap/scss/print';
@import '~@forevolve/bootstrap-dark/scss/dark-styles';
}
.breadcrumb {
border-top: 0 !important;
}

19
frontend/styles/main.scss Normal file
View File

@ -0,0 +1,19 @@
@import '~video.js/src/css/video-js';
@import 'bootstrap';
$colorTransitionFunction: ease-out;
$colorTransitionDuration: 1s;
html[data-toggled-dark='true'] * {
transition: background-color $colorTransitionDuration $colorTransitionFunction,
color $colorTransitionDuration $colorTransitionFunction,
border-color $colorTransitionDuration $colorTransitionFunction !important;
}
html {
.btn.btn-twitch {
border-color: purple;
background-color: purple;
color: white;
}
}

43
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
"jsx": "preserve",
"resolveJsonModule": true,
"noImplicitAny": true,
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"isolatedModules": true,
"typeRoots": [
"./typings",
"./node_modules/@types"
],
"baseUrl": ".",
"paths": {
"url-slug": [
"typings/url-slug"
],
"video.js": [
"typings/video.js"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1 @@
declare module 'react-intl-formatted-duration';

32
frontend/typings/url-slug/index.d.ts vendored Normal file
View File

@ -0,0 +1,32 @@
/* eslint-disable no-redeclare */
export type UrlSlugTransformer = (
slugFragments: string[],
separator: UrlSlugSeparator
) => string;
export type UrlSlugSeparator = '-'|'.'|'_'|'~'|'';
export interface UrlSlugOptions {
camelCase?: boolean = true;
separator?: UrlSlugSeparator = '-';
transformer: false|UrlSlugTransformer = false;
}
declare function urlSlug(
string: string,
options?: urlSlug.Options): string;
export default urlSlug;
export as namespace urlSlug;
declare namespace urlSlug {
type Options = UrlSlugOptions;
static function revert(slug: string, options?: urlSlug.Options);
declare namespace transformers {
static const lowercase: UrlSlugTransformer;
}
}

6908
frontend/typings/video.js/index.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

65
frontend/util/api.ts Normal file
View File

@ -0,0 +1,65 @@
import { VideoList } from './datatypes/VideoList';
import { VideoOnDemandIndex } from './datatypes/VideoOnDemandIdentifier';
const upstreamURL = process.env.UPSTREAM_URL;
const upstreamDirectURL = process.env.UPSTREAM_DIRECT_URL || upstreamURL;
// const apiURL = process.env.API_URL || `${upstreamURL}/api`;
const apiDirectURL = process.env.API_DIRECT_URL || `${upstreamDirectURL}/api`;
export class HTTPError extends Error {
response: Response;
data: any;
constructor(response: Response, data: any) {
super(`HTTP server responded with error: ${response.statusText}`);
this.response = response;
this.data = data;
}
}
export async function fetchJson(
input: RequestInfo,
init?: RequestInit,
) {
try {
const response = await fetch(input, init);
// if the server replies, there's always some data in json
// if there's a network error, it will throw at the previous line
const data = await response.json();
if (response.ok) {
return data;
}
throw new HTTPError(response, data);
} catch (error) {
if (!error.data) {
error.data = { message: error.message };
}
throw error;
}
}
async function getDirect(relativeURL: string) {
return fetchJson(`${apiDirectURL}/${relativeURL}`);
}
// async function get(relativeURL: any) {
// return fetchJson(`${apiURL}/${relativeURL}`);
// }
export async function getIndex(): Promise<VideoOnDemandIndex> {
return getDirect('index.json');
}
export async function getVideos(id: string): Promise<VideoList> {
return getDirect(`videos/${id}.json`);
}
export function getDownloadURL(id: string, fileName: string): string {
return [upstreamURL, encodeURIComponent(id), encodeURIComponent(fileName)]
.join('/');
}

View File

@ -0,0 +1,13 @@
export interface VideoEntry {
fileName:string
title: string
duration?: number | string,
sourceVideoURL: string
sourceVideoStart: number | string
sourceVideoEnd: number | string
}
export interface VideoList {
lastUpdatedAt: string
videos: Array<VideoEntry>
}

View File

@ -0,0 +1,13 @@
export interface VideoOnDemandIdentifier {
id: string
title: string
}
export interface VideoOnDemandIndex {
ids: Array<VideoOnDemandIdentifier>
servers: {
dash: string
hls: string
thumbnails: string
}
}

71
frontend/util/index.ts Normal file
View File

@ -0,0 +1,71 @@
export function getThumbnailURL(
thumbnailServerURL: string,
id: string,
fileName: string,
offset: number|string,
resizeparams: {
width?: number,
height?: number,
} = {},
) {
// Example: https://thumb-gdq-a.edge.streaminginter.net/agdq2020vods/000.%20Pre%2dpreshow.mp4/thumb-90000-w240.jpg
// thumb-<offset>[<resizeparams>].jpg
const resizeparamsStr = Object.entries(resizeparams)
.map(([key, value]) => {
switch (key) {
case 'width':
return `w${value}`;
case 'height':
return `h${value}`;
default:
throw new Error(`unsupported resizeparam: ${key}`);
}
})
.filter((v) => !!v)
.join('-');
return `${thumbnailServerURL}/${[
id,
fileName,
`thumb-${offset}${resizeparamsStr.length > 0 ? `-${resizeparamsStr}` : ''}.jpg`,
]
.map(encodeURIComponent)
.join('/')}`;
}
export function getHLSMasterURL(
hlsServerURL: string,
id: string,
fileName: string,
) {
return `${hlsServerURL}/${[
id,
fileName,
'master.m3u8',
]
.map(encodeURIComponent)
.join('/')}`;
}
export function getDASHManifestURL(
dashServerURL:string,
id:string,
fileName:string,
) {
return `${dashServerURL}/${[
id,
fileName,
'manifest.mpd',
]
.map(encodeURIComponent)
.join('/')}`;
}
export default null;
export * as api from './api';
export * as localization from './localization';
export * as session from './session';
export * as status from './status';

View File

@ -0,0 +1,73 @@
import { shouldPolyfill } from '@formatjs/intl-numberformat/should-polyfill';
import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-numberformat/locale-data/de';
import '@formatjs/intl-numberformat/locale-data/en';
import { UnboxPromise } from './types';
export const defaultLocale = 'en';
export const defaultLocaleDescription = 'English';
export const availableLocales: Array<string> = [
defaultLocale,
'de',
];
export const localeDescriptions: Record<string, string> = {
[defaultLocale]: defaultLocaleDescription,
de: 'German',
};
export async function loadLocalePolyfill(
locale: string,
): Promise<void> {
// if (shouldPolyfill()) {
// // Load the polyfill 1st BEFORE loading data
// console.log('Loading intl-numberformat polyfill');
// await import('@formatjs/intl-numberformat/polyfill');
// } else {
// console.log('Skipping intl-numberformat polyfill');
// }
// if (Object.keys(Intl.NumberFormat).includes('polyfilled')) {
// console.log('Intl.NumberFormat is polyfilled, let\'s load intl-numberformat locale data');
// switch (locale) {
// case 'de':
// await import('@formatjs/intl-numberformat/locale-data/de');
// break;
// default:
// await import('@formatjs/intl-numberformat/locale-data/en');
// break;
// }
// console.log('intl-numberformat locale data loaded');
// } else {
// console.log('Intl.NumberFormat is native');
// }
}
export async function loadLocaleData(
locale: string,
) {
let localeData;
switch (locale) {
case 'de':
localeData = await import('../compiled-lang/de.json');
break;
default:
localeData = await import('../compiled-lang/en.json');
break;
}
return localeData.default;
}
export function isPolyfilled(): boolean {
return !Object.keys(Intl.NumberFormat).includes('polyfilled');
}
export function isPolyfillPhaseDone(): boolean {
// return shouldPolyfill() && isPolyfilled();
return true;
}
export type LocaleData = UnboxPromise<ReturnType<typeof loadLocaleData>>;

View File

@ -0,0 +1,12 @@
export default function parseBool(value: string|number) {
switch (value.toString().toLowerCase()) {
case '':
case '0':
case 'false':
case 'n':
case 'no':
return false;
default:
return true;
}
}

View File

@ -0,0 +1,3 @@
const sanitizeFileName = (name: string) => name
.replace(/[<>:"/\\|?*%() ]/g, '_');
export default sanitizeFileName;

View File

@ -0,0 +1,5 @@
import urlSlug from 'url-slug';
const sanitizeTitle = urlSlug;
export default sanitizeTitle;

52
frontend/util/session.ts Normal file
View File

@ -0,0 +1,52 @@
import { withIronSession } from 'next-iron-session';
import type { Session } from 'next-iron-session';
import { IncomingMessage, ServerResponse } from 'http';
import { NextApiResponse, NextPageContext } from 'next';
import { DocumentContext } from 'next/document';
export interface IncomingMessageWithSession extends IncomingMessage {
body: any;
session: Session
}
export type HandlerWithDocumentContextAndSession<T> = (
ctx: DocumentContext & {
req: IncomingMessageWithSession
},
) => Promise<T>;
export type HandlerWithPageContextAndSession<T> = (
ctx: NextPageContext & {
req: IncomingMessageWithSession
},
) => Promise<T>;
export type HandlerWithReqResAndSession<T> = (
req: IncomingMessageWithSession,
res: NextApiResponse
) => Promise<T>;
export type HandlerWithSession<T, U> =
T extends IncomingMessageWithSession
? HandlerWithReqResAndSession<U>
: T extends NextPageContext
? HandlerWithPageContextAndSession<U>
: HandlerWithDocumentContextAndSession<U>;
export default function withSession<T>(
handler: (
req: IncomingMessageWithSession,
res: NextApiResponse,
...args: any) => Promise<T>,
): (...args: any) => Promise<T> {
return withIronSession(handler, {
cookieName: 'gdq-archive',
cookieOptions: {
// the next line allows to use the session in non-https environements like
// Next.js dev mode (http://localhost:3000)
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
},
password: process.env.SECRET_PASSWORD || '0'.repeat(32),
});
}

26
frontend/util/status.tsx Normal file
View File

@ -0,0 +1,26 @@
import * as React from 'react';
import Head from 'next/head';
import DefaultErrorPage from 'next/error';
export function notFound() {
return (
<>
<Head>
<meta name="robots" content="noindex" />
</Head>
<DefaultErrorPage statusCode={404} />
</>
);
}
export function forbidden() {
return (
<>
<Head>
<meta name="robots" content="noindex" />
</Head>
<DefaultErrorPage statusCode={403} />
</>
);
}

1
frontend/util/types.ts Normal file
View File

@ -0,0 +1 @@
export type UnboxPromise<T extends Promise<any>> = T extends Promise<infer U> ? U: never;

9595
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
.docker*
Docker*
example/

59
streamserver/Dockerfile Normal file
View File

@ -0,0 +1,59 @@
FROM icedream/nginx AS ffmpeg-build
RUN echo "@testing http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
RUN apk update
RUN apk add sudo alpine-sdk git fdk-aac@testing fdk-aac-dev@testing
# Prepare abuild
WORKDIR /usr/src
RUN chown 999:0 /usr/src
RUN adduser -h /usr/src -S -u 999 apk
RUN addgroup apk abuild
RUN echo "apk ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
USER apk
RUN abuild-keygen -a -i
# Check out aports
RUN git config --global user.name Docker
RUN git config --global user.email "$(whoami)@localhost"
RUN git clone --depth=1 -b $(. /etc/os-release && echo "$VERSION_ID" | grep -o '^[0-9]\+.[0-9]\+')-stable https://gitlab.alpinelinux.org/alpine/aports.git
WORKDIR /usr/src/aports
# Apply package changes
COPY patches/aports /patches/
RUN git am /patches/*.patch
WORKDIR /usr/src/aports/community/ffmpeg-serverkomplex
#RUN sed -i 's,^license=.\+$,license="non-free",g' APKBUILD
RUN sed -i 's,!check,!check !checkroot,g' APKBUILD
#RUN apkgrel -a .
RUN abuild -r
###
FROM alpine AS module-source
RUN apk add --no-cache git
RUN git config --global user.name Docker
RUN git config --global user.email "$(whoami)@localhost"
WORKDIR /usr/src
RUN git clone --depth=1 --recursive https://github.com/kaltura/nginx-vod-module.git
###
FROM icedream/nginx
COPY --from=module-source /usr/src/ /usr/src/nginx-modules/
COPY --from=0 /usr/src/packages/ /packages/
COPY --from=0 /etc/apk/keys/ /etc/apk/keys/
RUN echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
RUN sed -i '1s#^#/packages/community\n#' /etc/apk/repositories
RUN cat /etc/apk/repositories
RUN apk update
RUN apk add ffmpeg-serverkomplex ffmpeg-serverkomplex-dev openssl openssl-dev fdk-aac
RUN \
docker-nginx-download-source &&\
docker-nginx-build \
--add-dynamic-module=../nginx-modules/nginx-vod-module --with-file-aio &&\
rm -rf /packages
RUN sed -i '1s#^#load_module modules/ngx_http_vod_module.so;\n#' /etc/nginx/nginx.conf

View File

@ -0,0 +1,8 @@
version: "3.7"
services:
nginx:
build: ..
volumes:
- ./conf.d:/etc/nginx/conf.d/:ro
- /srv/nfs4/gdq-archive:/videos:ro

View File

@ -0,0 +1,95 @@
From f79a9b7aee7894803aa6a754894a566d19b126ee Mon Sep 17 00:00:00 2001
From: Carl Kittelberger <icedream@icedream.pw>
Date: Mon, 17 Aug 2020 02:17:15 +0200
Subject: [PATCH] Build non-free binary with fdk-aac support.
---
...util-clean-up-unused-FF_SYMVER-macro.patch | 0
.../{ffmpeg => ffmpeg-serverkomplex}/APKBUILD | 20 ++++++++++++++++---
2 files changed, 17 insertions(+), 3 deletions(-)
rename community/{ffmpeg => ffmpeg-serverkomplex}/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch (100%)
rename community/{ffmpeg => ffmpeg-serverkomplex}/APKBUILD (93%)
diff --git a/community/ffmpeg/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch b/community/ffmpeg-serverkomplex/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch
similarity index 100%
rename from community/ffmpeg/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch
rename to community/ffmpeg-serverkomplex/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch
diff --git a/community/ffmpeg/APKBUILD b/community/ffmpeg-serverkomplex/APKBUILD
similarity index 93%
rename from community/ffmpeg/APKBUILD
rename to community/ffmpeg-serverkomplex/APKBUILD
index 601dd210831..bf2505beadf 100644
--- a/community/ffmpeg/APKBUILD
+++ b/community/ffmpeg-serverkomplex/APKBUILD
@@ -2,19 +2,20 @@
# Contributor: Łukasz Jendrysik <scadu@yandex.com>
# Contributor: Jakub Skrzypnik <j.skrzypnik@openmailbox.org>
# Maintainer: Natanael Copa <ncopa@alpinelinux.org>
-pkgname=ffmpeg
+pkgname="ffmpeg-serverkomplex"
pkgver=4.3.1
pkgrel=0
pkgdesc="Complete and free Internet live audio and video broadcasting solution for Linux/Unix"
url="https://ffmpeg.org/"
arch="all"
-license="GPL-2.0-or-later AND LGPL-2.1-or-later"
+license="GPL-2.0-or-later AND LGPL-2.1-or-later AND non-free"
options="!check" # tests/data/hls-lists.append.m3u8 fails
subpackages="$pkgname-dev $pkgname-doc $pkgname-libs"
makedepends="
alsa-lib-dev
coreutils
bzip2-dev
+ fdk-aac-dev
gnutls-dev
imlib2-dev
lame-dev
@@ -132,6 +133,7 @@ build() {
--enable-gnutls \
--enable-gpl \
--enable-libass \
+ --enable-libfdk-aac \
--enable-libmp3lame \
--enable-libvorbis \
--enable-libvpx \
@@ -141,6 +143,7 @@ build() {
--enable-libtheora \
--enable-libv4l2 \
--enable-libdav1d \
+ --enable-nonfree \
--enable-postproc \
--enable-pic \
--enable-pthreads \
@@ -169,17 +172,28 @@ check() {
}
package() {
+ provides="ffmpeg"
make DESTDIR="$pkgdir" install install-man
install -D -m755 tools/qt-faststart "$pkgdir/usr/bin/qt-faststart"
# strip --strip-debug "$pkgdir"/usr/lib/*.a
}
+prepare() {
+ mv ffmpeg-*/ "${builddir}"
+}
+
libs() {
pkgdesc="Libraries for ffmpeg"
- replaces="ffmpeg"
+ replaces="ffmpeg-serverkomplex"
+ provides="ffmpeg-libs"
mkdir -p "$subpkgdir"/usr
mv "$pkgdir"/usr/lib "$subpkgdir"/usr
}
+dev() {
+ provides="ffmpeg-dev"
+ default_dev
+}
+
sha512sums="64e1052c45145e27726e43d4fe49c9a92058e55562d34fd3b3adf54d3506e6bd680f016b748828215e1bfc8ce19aa85b6f7e4eb05fafe21479118a4ad528a81f ffmpeg-4.3.1.tar.xz
1047a23eda51b576ac200d5106a1cd318d1d5291643b3a69e025c0a7b6f3dbc9f6eb0e1e6faa231b7e38c8dd4e49a54f7431f87a93664da35825cc2e9e8aedf4 0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch"
--
2.28.0