Skip to content

Commit 051daa1

Browse files
committed
Adds ability to pin streams
Livesheet updating bots and scripts sometimes exhibit "false negatives", wherein some streams are marked offline when they are actually live In these cases, livesheet operators struggle to keep a stream available due to the bot's activities This branch introduces pins; when a stream's `isPinned` attribute is `true`, state-related attributes may not be changed (i.e., `status` and `isExpired`) Clients that attempt to change the state of a pinned stream will receive a 409 response. They should handle this gracefully Additionally, clients may now filter by pinned items. This is useful for only updating the state of non-pinned streams
1 parent cd62e3c commit 051daa1

File tree

5 files changed

+184
-9
lines changed

5 files changed

+184
-9
lines changed

README.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,42 @@
11
# StreamSource
2+
Streamsource is a publicly readable API to store and retrieve information about livestreams across many streaming platforms.
3+
4+
## Getting Started
5+
**If you just want to use the API to read stream data found at [streams.streamwall.io](http://streams.streamwall.io), see [API Reference](#api-reference)**
6+
7+
### Current state
8+
Streamsource is in active development at a very early stage. Do not consider the API stable. We don't even have versioning yet! (we accept PRs!)
9+
10+
Many assumptions are built in, and this application is tightly coupled to a few different services. This will improve over time.
11+
12+
### Installation
13+
14+
1. Clone this repository
15+
1. `npm install`
16+
1. Install Postgres and create a database, a user, etc.
17+
1. Copy example.env to just .env and update your configuration settings
18+
1. `npx sequelize-cli db:migrate`
19+
20+
### Running
21+
22+
1. Make sure Postgres is running
23+
1. Start server: `node bin/www`
24+
1. Preview streams json: http://localhost:3000/streams
25+
26+
### Upgrading
27+
28+
1. Get new code: `git pull`
29+
1. Apply migrations: `npx sequelize-cli db:migrate`
30+
1. Restart server: `node bin/www`
31+
32+
### Development and contributing
33+
34+
This project is in its infancy. We're open to pull requests and will work with you to get improvements merged.
235

336
## API Reference
37+
438
### Authentication
5-
Certain routes are protected with a JWT that you must include in your Authorization header when making authenticated requests.
39+
Most routes are protected with a JWT that you must include in your Authorization header when making authenticated requests.
640

741
API tokens can be obtained by creating a user and POSTing to /users/login, which will generate and return a token.
842

@@ -11,6 +45,17 @@ Subsequent authenticated requests must include the following header:
1145
Authorization: Bearer MYTOKEN
1246
```
1347

48+
#### Getting Started with Authentication
49+
1. Create your user
50+
```
51+
curl -d "[email protected]&password=abc123" -X POST http://localhost:3000/users/signup
52+
```
53+
2. Log in
54+
```
55+
curl -d "[email protected]&password=abc123" -X POST http://localhost:3000/users/login
56+
```
57+
3. Save the token in your app/script/bot's configuration file (keep it secret!)
58+
1459
### POST /users/signup
1560
Creates a new user
1661
@@ -67,6 +112,7 @@ Note: All string searches are case-insensitive and queried based on `ILIKE '%YOU
67112
|link|String|The URL of a stream|
68113
|status|String|One of: `['Live', 'Offline', 'Unknown']`|
69114
|notStatus|String|Exclude this status. One of: `['Live', 'Offline', 'Unknown']`|
115+
|isPinned|Boolean|Defaults to null. When true, prevents state changes, e.g. updates to `isExpired` or `status`|
70116
|isExpired|Boolean|Streams are considered expired when they are no longer active. Default: false|
71117
|title|String|Title of a stream|
72118
|notTitle|String|Title of a stream|
@@ -94,6 +140,7 @@ curl http://localhost:3000/streams?city=seattle
94140
"link": "https://www.instagram.com/future_crystals/live",
95141
"status": "Live",
96142
"title": "",
143+
"isPinned": false,
97144
"isExpired": false,
98145
"checkedAt": "2020-09-25T04:58:52.840Z",
99146
"liveAt": "2020-09-25T04:58:52.840Z",
@@ -121,6 +168,7 @@ Create a new stream.
121168
"link": "https://www.instagram.com/future_crystals/live",
122169
"status": "Live",
123170
"title": "",
171+
"isPinned": false,
124172
"isExpired": false,
125173
"checkedAt": "2020-09-25T04:58:52.840Z",
126174
"liveAt": "2020-09-25T04:58:52.840Z",
@@ -145,6 +193,7 @@ Get details for a single stream
145193
"link": "https://www.instagram.com/future_crystals/live",
146194
"status": "Live",
147195
"title": "",
196+
"isPinned": false,
148197
"isExpired": false,
149198
"checkedAt": "2020-09-25T04:58:52.840Z",
150199
"liveAt": "2020-09-25T04:58:52.840Z",
@@ -184,3 +233,31 @@ Expire a stream
184233
...
185234
}
186235
```
236+
### PUT /streams/:id/pin
237+
Pin a stream; prevents state changes while pinned
238+
- **Requires authentication**
239+
- **Requires privileged role: Editor or Admin**
240+
```
241+
curl -X PUT http://localhost:3000/streams/1/pin --header 'Authorization: Bearer MYTOKEN'
242+
```
243+
```json
244+
{
245+
...
246+
"isPinned": true,
247+
...
248+
}
249+
```
250+
### DELETE /streams/:id/pin
251+
Unpin a stream
252+
- **Requires authentication**
253+
- **Requires privileged role: Editor or Admin**
254+
```
255+
curl -X PUT http://localhost:3000/streams/1/pin --header 'Authorization: Bearer MYTOKEN'
256+
```
257+
```json
258+
{
259+
...
260+
"isPinned": false,
261+
...
262+
}
263+
```

example.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ DB_SSL=false
99
# Set to 0 for connecting to a production DB from local
1010
NODE_TLS_REJECT_UNAUTHORIZED=1
1111

12-
# Generate your own sufficiently long, sufficiently random secret
12+
# Generate your own sufficiently long, sufficiently random secret (just a random string)
1313
JWT_SECRET=YOURSECRET
1414

1515
# Sign up for an account at https://logdna.com/
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use strict';
2+
3+
module.exports = {
4+
up: async (queryInterface, Sequelize) => {
5+
return queryInterface.sequelize.transaction(t => {
6+
return Promise.all([
7+
queryInterface.addColumn('Streams', 'isPinned', {
8+
type: Sequelize.DataTypes.BOOLEAN,
9+
}, { transaction: t }),
10+
queryInterface.addIndex('Streams', ['isPinned'], {
11+
fields: 'isPinned',
12+
transaction: t,
13+
}),
14+
]);
15+
});
16+
},
17+
18+
down: async (queryInterface, Sequelize) => {
19+
return queryInterface.sequelize.transaction(t => {
20+
return Promise.all([
21+
queryInterface.removeColumn('Streams', 'isPinned', { transaction: t }),
22+
]);
23+
});
24+
}
25+
};

models/stream.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ module.exports = (sequelize, DataTypes) => {
2929
// However, we know that multiple streamers sometimes
3030
// use the same name to stream, e.g., Bear Gang, Concrete Reporting, Unicorn Riot, Boop Troop, etc.
3131
const matchingFilter = [
32-
{link: this.link},
32+
{ link: this.link },
3333
]
34-
if(this.source) {
34+
if (this.source) {
3535
matchingFilter.push({ source: this.source })
3636
}
3737
const pastStream = await Stream.findOne({
@@ -99,6 +99,7 @@ module.exports = (sequelize, DataTypes) => {
9999
}
100100
},
101101
postedBy: DataTypes.TEXT,
102+
isPinned: DataTypes.BOOLEAN,
102103
city: DataTypes.TEXT,
103104
region: DataTypes.TEXT,
104105
state: {

routes/streams.js

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ async function getStreams(req, res) {
6868
field: 'isExpired',
6969
rule: { [req.query.isExpired ? Op.is : Op.not]: true, }
7070
},
71+
isPinned: {
72+
field: 'isPinned',
73+
rule: { [req.query.isPinned ? Op.is : Op.not]: true, }
74+
},
7175
title: {
7276
field: 'title',
7377
rule: { [Op.iLike]: `%${req.query.title}%` }
@@ -173,7 +177,7 @@ async function getStreams(req, res) {
173177

174178
async function normalizeLink(link) {
175179
let normalizedLink = link
176-
normalizedLink = normalizedLink.replace(/\/$/,'')
180+
normalizedLink = normalizedLink.replace(/\/$/, '')
177181
normalizedLink = normalizedLink.replace(/https?:\/\/(www\.)?/i, '')
178182
return normalizedLink
179183
}
@@ -188,11 +192,11 @@ async function createStream(req, res) {
188192
const normalizedLink = await normalizeLink(req.body.link)
189193
const existingStream = await Stream.findOne({
190194
where: {
191-
link: { [Op.iLike]: `%${normalizedLink}%` },
195+
link: { [Op.iLike]: `%${normalizedLink}%` },
192196
isExpired: false,
193197
}
194198
})
195-
if(existingStream) {
199+
if (existingStream) {
196200
res.status(303).json({ data: existingStream })
197201
return
198202
}
@@ -223,6 +227,20 @@ async function patchStream(req, res) {
223227
const id = req.params.id
224228
const stream = await Stream.findByPk(id)
225229

230+
const statusIsChanging = req.body.status && req.body.status !== stream.status
231+
const isExpiredIsChanging = req.body.isExpired !== undefined && req.body.isExpired !== stream.isExpired
232+
const willChangeState = statusIsChanging || isExpiredIsChanging
233+
const pinInvalidatesStateChange = stream.isPinned && willChangeState
234+
if (pinInvalidatesStateChange) {
235+
const error = new Error("Stream is pinned and cannot alter 'isExpired' or 'status' states. Unpin this resource in order to make changes.")
236+
res.status(409).json({
237+
error: {
238+
message: error.message
239+
}
240+
})
241+
return
242+
}
243+
226244
const updatedStream = await stream.update({
227245
source: req.body.source,
228246
platform: req.body.platform,
@@ -235,7 +253,8 @@ async function patchStream(req, res) {
235253
city: req.body.city,
236254
region: req.body.region,
237255
checkedAt: req.body.checkedAt,
238-
liveAt: req.body.liveAt
256+
liveAt: req.body.liveAt,
257+
isPinned: req.body.isPinned,
239258
})
240259

241260
if (updatedStream instanceof ValidationError) {
@@ -265,6 +284,40 @@ async function getStream(req, res) {
265284
res.status(200).json(response)
266285
}
267286

287+
async function pinStream(req, res) {
288+
if (!req.user || !accessControl.can(req.user.role).updateAny('stream').granted) {
289+
res.status(401)
290+
return
291+
}
292+
293+
const id = req.params.id
294+
const stream = await Stream.findByPk(id)
295+
if (!stream) {
296+
res.status(404).send()
297+
}
298+
299+
await stream.update({ isPinned: true })
300+
301+
res.status(204).send()
302+
}
303+
304+
async function unpinStream(req, res) {
305+
if (!req.user || !accessControl.can(req.user.role).updateAny('stream').granted) {
306+
res.status(401)
307+
return
308+
}
309+
310+
const id = req.params.id
311+
const stream = await Stream.findByPk(id)
312+
if (!stream) {
313+
res.status(404).send()
314+
}
315+
316+
await stream.update({ isPinned: false })
317+
318+
res.status(204).send()
319+
}
320+
268321
async function expireStream(req, res) {
269322
if (!req.user || !accessControl.can(req.user.role).deleteAny('stream').granted) {
270323
res.status(401)
@@ -277,15 +330,34 @@ async function expireStream(req, res) {
277330
res.status(404).send()
278331
return
279332
}
333+
if (stream.isPinned) {
334+
const error = new Error("Stream is pinned and cannot alter 'isExpired' or 'status' states. Unpin this resource in order to make changes.")
335+
res.status(409).json({
336+
error: error,
337+
})
338+
}
339+
280340
await stream.update({ isExpired: true })
281341
res.status(204).send()
282342
}
283343

284-
/* GET streams listing. */
344+
// List streams
285345
router.get('/', getStreams)
346+
347+
// Create
286348
router.post('/', passport.authenticate('jwt', { session: false }), createStream)
349+
350+
// Pin / unpin streams. This is a field on the model; in RESTful terms, let's call this a "virtual resource". Think "pin" as in "thumbtack", not as a verb ;)
351+
router.put('/:id/pin', passport.authenticate('jwt', { session: false }), pinStream)
352+
router.delete('/:id/pin', passport.authenticate('jwt', { session: false }), unpinStream)
353+
354+
// Read
287355
router.get('/:id', getStream)
356+
357+
// Update
288358
router.patch('/:id', passport.authenticate('jwt', { session: false }), patchStream)
359+
360+
// Soft delete
289361
router.delete('/:id', passport.authenticate('jwt', { session: false }), expireStream)
290362

291363
module.exports = router;

0 commit comments

Comments
 (0)