diff --git a/astro.config.mjs b/astro.config.mjs
index a7f95fc..210821a 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -13,6 +13,9 @@ export default defineConfig({
starlight({
title: "Open Podcast API",
favicon: "favicon.ico",
+ customCss: [
+ './src/styles/custom.css',
+ ],
social: {
github: "https://github.com/OpenPodcastApi/api-specs",
},
@@ -43,11 +46,26 @@ export default defineConfig({
},
{
label: "Subscriptions",
+ badge: {
+ text: "Core",
+ variant: "caution",
+ },
collapsed: true,
autogenerate: {
directory: "specs/subscriptions",
},
},
+ {
+ label: "Episodes",
+ badge: {
+ text: "Core",
+ variant: "caution",
+ },
+ collapsed: true,
+ autogenerate: {
+ directory: "specs/episodes",
+ },
+ },
],
},
...openAPISidebarGroups,
@@ -64,7 +82,9 @@ export default defineConfig({
"TabItem",
],
},
- "src/components/SponsorCallout.astro"
+ "src/components/SponsorCallout.astro",
+ "src/components/BadgeOptional.astro",
+ "src/components/BadgeCore.astro"
],
}),
],
diff --git a/schema.yml b/schema.yml
index 1320555..7e6f220 100644
--- a/schema.yml
+++ b/schema.yml
@@ -7,6 +7,8 @@ info:
tags:
- name: Subscriptions
description: All actions relating to subscription management
+ - name: Episodes
+ description: All actions relating to episode management
paths:
/subscriptions:
get:
@@ -202,6 +204,51 @@ paths:
security:
- podcast_auth:
- read:subscriptions
+ /episodes:
+ get:
+ tags:
+ - Episodes
+ summary: Retrieve all episodes for the authenticated user
+ description: Retrieve all episodes that has changed for the authenticated user since the provided timestamp
+ operationId: getEpisodes
+ parameters:
+ - in: query
+ name: since
+ schema:
+ type: string
+ format: date-time
+ required: false
+ example:
+ '2022-04-23T18:25:43.511Z'
+ - in: query
+ name: page
+ schema:
+ type: number
+ required: false
+ example:
+ 1
+ - in: query
+ name: per_page
+ schema:
+ type: number
+ required: false
+ example:
+ 5
+ responses:
+ '200':
+ description: Successful operation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Episodes'
+ application/xml:
+ schema:
+ $ref: '#/components/schemas/Episodes'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ security:
+ - podcast_auth:
+ - read:subscriptions
components:
responses:
Unauthorized:
@@ -550,6 +597,167 @@ components:
subscription_changed: 2023-02-23T14:41:00.000Z
guid_changed: 2023-02-23T14:41:00.000Z
new_guid: 965fcecf-ce04-482b-b57c-3119b866cc61
+ Episode:
+ xml:
+ name: episode
+ required:
+ - podcast_guid
+ - sync_id
+ - episode_guid
+ - title
+ - publish_date
+ - enclosure_url
+ - episode_url
+ type: object
+ properties:
+ podcast_guid:
+ type: string
+ format: guid
+ sync_id:
+ type: string
+ format: guid
+ episode_guid:
+ type: string
+ title:
+ type: string
+ publish_date:
+ type: string
+ format: date-time
+ enclosure_url:
+ type: string
+ format: url
+ episode_url:
+ type: string
+ format: url
+ playback_position:
+ value:
+ type: number
+ format: integer
+ timestamp:
+ type: string
+ format: date-time
+ played_status:
+ value:
+ type: boolean
+ timestamp:
+ type: string
+ format: date-time
+ new_status:
+ value:
+ type: boolean
+ timestamp:
+ type: string
+ format: date-time
+ download_status:
+ value:
+ type: boolean
+ timestamp:
+ type: string
+ format: date-time
+ favorite_status:
+ value:
+ type: boolean
+ timestamp:
+ type: string
+ format: date-time
+ example:
+ podcast_guid: 31740ac6-e39d-49cd-9179-634bcecf4143
+ sync_id: cff3ea32-4215-4f98-bc23-5358d1f35b55
+ episode_guid: https://example.com/podcast/episode-5-the-history-of-RSS
+ title: The history of RSS
+ publish_date: 2022-04-24T17:53:21.573Z
+ enclosure_url: https://example.com/podcast/episode-5-the-history-of-RSS.mp3
+ episode_url: https://example.com/podcast/episode-5-the-history-of-RSS
+ playback_position:
+ value: 0
+ timestamp: 2024-11-02T13:19
+ played_status:
+ value: true
+ timestamp: 2024-11-02T13:19
+ new_status:
+ value: false
+ timestamp: 2024-10-30T17:31
+ download_status:
+ value: false
+ timestamp: 2024-11-02T13:19
+ favorite_status:
+ value: false
+ timestamp: 2024-11-02T13:19
+ Episodes:
+ required:
+ - total
+ - page
+ - per_page
+ - episodes
+ xml:
+ name: episodes
+ type: object
+ properties:
+ total:
+ type: number
+ page:
+ type: number
+ per_page:
+ type: number
+ next:
+ type: string
+ format: url
+ previous:
+ type: string
+ format: url
+ episodes:
+ type: array
+ items:
+ $ref: '#/components/schemas/Episode'
+ example:
+ total: 2
+ page: 1
+ per_page: 5
+ episodes:
+ - podcast_guid: 31740ac6-e39d-49cd-9179-634bcecf4143
+ sync_id: cff3ea32-4215-4f98-bc23-5358d1f35b55
+ episode_guid: https://example.com/podcast/episode-5-the-history-of-RSS
+ title: The history of RSS
+ publish_date: 2022-04-24T17:53:21.573Z
+ enclosure_url: https://example.com/podcast/episode-5-the-history-of-RSS.mp3
+ episode_url: https://example.com/podcast/episode-5-the-history-of-RSS
+ playback_position:
+ value: 0
+ timestamp: 2024-11-02T13:19
+ played_status:
+ value: true
+ timestamp: 2024-11-02T13:19
+ new_status:
+ value: false
+ timestamp: 2024-10-30T17:31
+ download_status:
+ value: false
+ timestamp: 2024-11-02T13:19
+ favorite_status:
+ value: false
+ timestamp: 2024-11-02T13:19
+ - podcast_guid: 9d6786c9-ed48-470d-acbe-e593547f4b5b
+ sync_id: 5773f457-e71b-417d-8ea8-f07c38a03a3e
+ episode_guid: 01999e25-08cd-4f29-a61e-6ca459b40d27
+ title: Walk with the weatherman
+ publish_date: 2022-04-27T19:35:20.000Z
+ enclosure_url: https://op3.dev/e/https://podcasts.example2.net/audio/@digitalcitizen/49-walk-with-the-weatherman.mp3
+ episode_url: https://podcasts.example2.net/@digitalcitizen/episodes/49-walk-with-the-weatherman
+ playback_position:
+ value: 2100
+ timestamp: 2024-11-01T17:38
+ played_status:
+ value: false
+ timestamp: 2024-04-28T09:20
+ new_status:
+ value: false
+ timestamp: 2024-11-01T17:02
+ download_status:
+ value: true
+ timestamp: 2024-11-01T17:02
+ favorite_status:
+ value: false
+ timestamp: 2024-04-28T09:20
Deletion:
xml:
name: deletion
@@ -599,8 +807,8 @@ components:
implicit:
authorizationUrl: https://test.openpodcastapi.com/oauth/authorize
scopes:
- write:subscriptions: modify subscription information for your account
- read:subscriptions: read your subscription information
+ write:subscriptions: modify subscription information & related episodes for your account
+ read:subscriptions: read your subscription information & related episodes
api_key:
type: apiKey
name: api_key
diff --git a/src/components/BadgeCore.astro b/src/components/BadgeCore.astro
new file mode 100644
index 0000000..a8ae431
--- /dev/null
+++ b/src/components/BadgeCore.astro
@@ -0,0 +1,5 @@
+---
+import { Badge } from '@astrojs/starlight/components';
+---
+
+
\ No newline at end of file
diff --git a/src/components/BadgeOptional.astro b/src/components/BadgeOptional.astro
new file mode 100644
index 0000000..2cae475
--- /dev/null
+++ b/src/components/BadgeOptional.astro
@@ -0,0 +1,5 @@
+---
+import { Badge } from '@astrojs/starlight/components';
+---
+
+
\ No newline at end of file
diff --git a/src/content/docs/specs/episodes/get-all.mdx b/src/content/docs/specs/episodes/get-all.mdx
new file mode 100644
index 0000000..ebf5b0f
--- /dev/null
+++ b/src/content/docs/specs/episodes/get-all.mdx
@@ -0,0 +1,262 @@
+---
+title: Get all episodes
+description: Get all episodes for a user
+sidebar:
+ order: 3
+ badge:
+ text: Core
+ variant: caution
+---
+
+import CoreAction from "@partials/_core-action.mdx";
+
+
+
+```http title="Endpoint"
+GET /v1/episodes
+```
+
+This endpoint enables clients to return all episode information relating to the authenticated user. It returns pagination information and an array of `episodes`.
+
+While supported, this endpoint is expected to be used rarely in practice. More often, episodes are retrieved per queue (TBD) or per subscription (TBD).
+
+## Response fields
+
+### Metadata
+
+| Field | Type | Required? | Description |
+| ---------- | ------ | --------- | ------------------------------------------------ |
+| `total` | Number | Yes | The total number of objects returned by the call |
+| `page` | Number | Yes | The number of the page returned in the call |
+| `per_page` | Number | Yes | The number of results returned per page |
+| `next` | String | No | The URL for the next page of results |
+| `previous` | String | No | The URL for the previous page of results |
+
+### Episode fields
+
+| Group | Field | Type | Required? | Description |
+| ---------- | ----------------------------------- | --------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Identifier | `podcast_guid` | String \ | Yes | The globally unique ID of the parent podcast |
+| Identifier | `sync_id` | String \ | Yes | The synchronisation ID of the episode, globally unique at the server and its clients |
+| Identifier | `episode_guid` | String \ | Yes | The globally unique ID of the episode, as present in the RSS feed ([`guid` tag](https://www.rssboard.org/rss-specification#ltguidgtSubelementOfLtitemgt)) |
+| Identifier | `title` | String | Yes | The title of the episode, as present in the RSS feed (`title` tag) |
+| Identifier | `publish_date` | Datetime | Yes | The date of publishing of the episode, as present in the RSS feed ([`pubDate` tag](https://www.rssboard.org/rss-specification#ltpubdategtSubelementOfLtitemgt)). Presented in [ISO 8601 format] |
+| Identifier | `enclosure_url` | String | Yes | The media file of the episode, as present in the RSS feed ([`enclosure` tag](https://www.rssboard.org/rss-specification#ltenclosuregtSubelementOfLtitemgt)) |
+| Identifier | `episode_url` | String | Yes | The (webpage) URL of the episode, as present in the RSS feed (`link`tag) |
+| Data | `playback_position` | Integer | ??YES/NO??| The most recent playback position in seconds |
+| Data | `played_status` | Boolean | No | Whether the episode has been (marked as such) |
+| Data | `new_status` | Boolean | No | Whether the user (manually) interacted with the episode.
_Example:_ In AntennaPod this is used to indicate whether an episode is in the Inbox |
+| Data | `download_status` | Boolean | No | Whether the episode is downloaded on the client. For further details, see below. |
+| Data | `favorite_status` | Boolean | No | Whether the episode has been favorited by the user |
+
+The server can ignore the other identifier fields, if it found an episode based on the `podcast_guid` and the `sync_id`.
+
+:::note[Why all idenifiers are required]
+
+Assume client A has refreshed a feed locally, and client B hasn't done so yet. In order for client B to do episode matching with what it receives from the server, it would need to have more data than just the `sync_id`. In this scenario, how does the server know whether client B has already refreshed the feed and is aware of the episode or not (i.e. whether it can rely on `sync_id` alone or needs to send more information)? The server cannot know, thus a 'conversation' between server and client would be needed:
+* `S` "here's a new episode with podcast_guid x and sync_id y"
+* `C` "I don't recognise this one - tell me more!"
+* `S` "Ok, here's all information you can use for episode matching: …"
+
+To avoid such conversation, and as the sending of all matching data involves only a few bytes, all identifiers are sent always.
+
+:::
+
+Remember that each of the data fields MUST have both a 'value' and a 'timestamp'.
+
+:::note[Discussion details]
+See meeting notes from [2024-06-19](https://pad.funkwhale.audio/s/I3F_C2NbQ#GET-episode-information)
+:::
+
+## Parameters
+
+The client MAY add the following parameters to their call:
+
+| Field | Type | In | Required? | Description |
+| ---------- | -------- | ----- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `since` | DateTime | Query | No | The date from which the server should return objects. The server only returns entries whose `timestamp` data for any of the fields are greater than this parameter. Expected in [ISO 8601 format] |
+| `page` | Number | Query | No | The page of results to be returned by the server. Defaults to `1` if not present |
+| `per_page` | Number | Query | No | The number of results to return in each call. Defaults to `50` if not present |
+
+:::note
+If no `since` parameter is provided, the server MUST return all current subscription information.
+:::
+
+## Server-side behavior
+
+No particular behavior expected.
+
+## Client behavior
+
+The client SHOULD update its local episode data to match the information returned in the response.
+
+## Example request
+
+
+
+
+ ```console
+ $ curl -X 'GET' \
+ '/v1/episodes?since=2024-04-23T18%3A25%3A34.511Z&page=1&per_page=5' \
+ -H 'accept: application/json'
+ ```
+
+
+
+
+ ```console
+ $ curl -X 'GET' \
+ '/v1/episodes?since=2024-04-23T18%3A25%3A34.511Z&page=1&per_page=5' \
+ -H 'accept: application/xml'
+ ```
+
+
+
+
+## Example 200 response
+
+
+
+
+ ```json
+ {
+ "total": 2,
+ "page": 1,
+ "per_page": 5,
+ "episodes": [
+ {
+ "podcast_guid": "31740ac6-e39d-49cd-9179-634bcecf4143",
+ "sync_id": "cff3ea32-4215-4f98-bc23-5358d1f35b55",
+ "episode_guid": "https://example.com/podcast/episode-5-the-history-of-RSS",
+ "title": "The history of RSS",
+ "publish_date": "2022-04-24T17:53:21.573Z",
+ "enclosure_url": "https://example.com/podcast/episode-5-the-history-of-RSS.mp3",
+ "episode_url": "https://example.com/podcast/episode-5-the-history-of-RSS",
+ "playback_position": {
+ "value": 0,
+ "timestamp": "2024-11-02T13:19"
+ },
+ "played_status": {
+ "value": true,
+ "timestamp": "2024-11-02T13:19"
+ },
+ "new_status": {
+ "value": false,
+ "timestamp": "2024-10-30T17:31"
+ },
+ "download_status": {
+ "value": false,
+ "timestamp": "2024-11-02T13:19"
+ },
+ "favorite_status": {
+ "value": false,
+ "timestamp": "2024-11-02T13:19"
+ }
+ },
+ {
+ "podcast_guid": "9d6786c9-ed48-470d-acbe-e593547f4b5b",
+ "sync_id": "5773f457-e71b-417d-8ea8-f07c38a03a3e",
+ "episode_guid": "01999e25-08cd-4f29-a61e-6ca459b40d27",
+ "title": "Walk with the weatherman",
+ "publish_date": "2022-04-27T19:35:20.000Z",
+ "enclosure_url": "https://op3.dev/e/https://podcasts.example2.net/audio/@digitalcitizen/49-walk-with-the-weatherman.mp3",
+ "episode_url": "https://podcasts.example2.net/@digitalcitizen/episodes/49-walk-with-the-weatherman",
+ "playback_position": {
+ "value": 2100,
+ "timestamp": "2024-11-01T17:38"
+ },
+ "played_status": {
+ "value": false,
+ "timestamp": "2024-04-28T09:20"
+ },
+ "new_status": {
+ "value": false,
+ "timestamp": "2024-11-01T17:02"
+ },
+ "download_status": {
+ "value": true,
+ "timestamp": "2024-11-01T17:02"
+ },
+ "favorite_status": {
+ "value": false,
+ "timestamp": "2024-04-28T09:20"
+ }
+ }
+ ]
+ }
+ ```
+
+
+
+
+ ```xml
+
+
+ 2
+ 1
+ 5
+
+ 31740ac6-e39d-49cd-9179-634bcecf4143
+ cff3ea32-4215-4f98-bc23-5358d1f35b55
+ https://example.com/podcast/episode-5-the-history-of-RSS
+ The history of RSS
+ 2022-04-24T17:53:21.573Z
+ https://example.com/podcast/episode-5-the-history-of-RSS.mp3
+ https://example.com/podcast/episode-5-the-history-of-RSS
+
+ 0
+ 2024-11-02T13:19
+
+
+ true
+ 2024-11-02T13:19
+
+
+ false
+ 2024-10-30T17:31
+
+
+ false
+ 2024-11-02T13:19
+
+
+ false
+ 2024-11-02T13:19
+
+
+
+ 9d6786c9-ed48-470d-acbe-e593547f4b5b
+ 5773f457-e71b-417d-8ea8-f07c38a03a3e
+ 01999e25-08cd-4f29-a61e-6ca459b40d27
+ Walk with the weatherman
+ 2022-04-27T19:35:20.000Z
+ https://op3.dev/e/https://podcasts.example2.net/audio/@digitalcitizen/49-walk-with-the-weatherman.mp3
+ https://podcasts.example2.net/@digitalcitizen/episodes/49-walk-with-the-weatherman
+
+ 2100
+ 2024-11-01T17:38
+
+
+ false
+ 2024-04-28T09:20
+
+
+ false
+ 2024-11-01T17:02
+
+
+ true
+ 2024-11-01T17:02
+
+
+ false
+ 2024-04-28T09:20
+
+
+
+ ```
+
+
+
+
+[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html
diff --git a/src/content/docs/specs/episodes/index.mdx b/src/content/docs/specs/episodes/index.mdx
new file mode 100644
index 0000000..afccbce
--- /dev/null
+++ b/src/content/docs/specs/episodes/index.mdx
@@ -0,0 +1,131 @@
+---
+title: Episodes endpoint
+description: An endpoint for syncing episodes between devices.
+prev: "Subscriptions: Deletion status endpoint"
+sidebar:
+ label: Overview
+ order: 1
+---
+
+import CoreEndpoint from "@partials/_core-endpoint.mdx";
+
+
+
+The episodes endpoint allows synchronising user-generated episode metadata. As the RSS feed is the authoritative source of truth, episode metadata such as title are only synchronised in some cases for episode identification and matching.
+
+Clients can query the endpoint by specifying the datetime from which they want to fetch changes to ensure they only fetch information that is relevant to them since their last sync.
+
+## Important data fields
+
+We distinguish two types of data fields: _identifier fields_ (used to identify and match episodes) and _data fields_ (used to synchronise users' interaction with episodes).
+
+:::note[Tombstoning]
+Servers SHOULD hold all previous `guid` and `feed_url` field data with a link to the succeeding data (such that a path of values can be followed) or with a link to the most recent data. This enables the server to handle situations in which clients submit old data. For example:
+
+- A user finds a podcast, whose URL had changed, and adds the old URL in the app. Because the client doesn't have the old URL in its database, it recognizes the podcast as **new** and POSTs the `feed_url` to the `/subscriptions` endpoint. If the user is already subscribed to the podcast (with the current feed URL) this would lead to a duplicate subscription.
+- A user has a device that they didn't use for a very long time. In that time, a podcaster added a GUID in their feed, leading to updated data in this field. When the client connects to the server again to pull all episode changes since the last connection, it retrieves episodes with their current subscription `guid`. The client won't recognize the subscription and fail to update the status of episodes.
+ :::
+
+### Identifier fields
+
+| Field | Type | Nullable? | Description |
+| --------------- | --------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `podcast_guid` | String \ | No | The globally unique ID of the podcast |
+| `sync_id` | String \ | No | The synchronisation ID of the episode, globally unique at the server and its clients |
+| `episode_guid` | String \ | Yes | The globally unique ID of the episode, as present in the RSS feed ([`guid` tag](https://www.rssboard.org/rss-specification#ltguidgtSubelementOfLtitemgt)) |
+| `title` | String | Yes | The title of the episode, as present in the RSS feed (`title` tag) |
+| `publish_date` | Datetime | Yes | The date of publishing of the episode, as present in the RSS feed ([`pubDate` tag](https://www.rssboard.org/rss-specification#ltpubdategtSubelementOfLtitemgt)). Presented in [ISO 8601 format] |
+| `enclosure_url` | String | Yes | The media file of the episode, as present in the RSS feed ([`enclosure` tag](https://www.rssboard.org/rss-specification#ltenclosuregtSubelementOfLtitemgt)) |
+| `episode_url` | String | Yes | The (webpage) URL of the episode, as present in the RSS feed (`link`tag) |
+
+### Data fields
+
+| Field | Type | Nullable? | Description |
+| ----------------------------------- | -------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `playback_position` | Integer | Yes/No? | The most recent playback position |
+| `played_status` | Boolean | No | Whether the episode has ben (marked as) |
+| `new_status` | Boolean | Yes | Whether the user (manually) interacted with the episode.
_Example:_ In AntennaPod this is used to indicate whether an episode is in the Inbox |
+| `download_status` | Boolean | Yes | Whether the episode is downloaded on the client. For further details, see below.
+| `favorite_status` | Boolean | Yes | Whether the episode has been favorited by the user
+
+:::note[Potential future fields]
+- Bookmarks: timed bookmarks of this episode with optinally a description text. Related to AntennaPod feature request [#1946](https://github.com/AntennaPod/AntennaPod/issues/1946).
+- Tags: labels applied to this episode. Related to Kasts feature request // Can we add a URL as reference?
+:::
+
+## Implementation details
+### Deduplication
+
+When fetching a feed, several scenarios could lead to duplicated episdes if not matched correctly. To ensure that in these cases episode must be matched and deduplicated to ensure their data is still synced. For details on this topic, please see [Episode matching & deduplication](matching-deduplication).
+
+### Download status
+
+The `download_status` is a _declaration of intent_, not an indication of the current status. If a user downloads an episode on client A, this client passes on this value to the server and thereby to other clients. Client B can then:
+* download immediately
+* download later (e.g. as soon as a WiFi connection is availalbe)
+
+It is up to the implementers whether this applies both to automatic and manual downloads, or only to manual downloads.
+
+While an optional field, if `download_status` is supported both by the server and the client, the client is expected to respect this field value. If the client may not download due to space limitations or won't download at all, then it should not declare support for this field.
+
+:::note[Discussion details]
+See meeting notes from [2024-02-27](https://pad.funkwhale.audio/s/88C5eXrRq)
+:::
+
+## Timestamping changes to resolve field conflicts
+
+To prevent unresolvable conflicts at field level, apps are expected to record timestamps. These MUST be the _time at which a field value was changed in the client_ (by the user or system). More recent values take presedence. The time of sending by the client or processing by the client should _not_ be sent.
+
+:::note[Example]
+Scenario:
+1. User plays first quarter of an episode on client A, which is offline.
+2. User skips first quarter and starts playback until halfway through the episode on client B, which is offline.
+3. Client B comes online and synchronises changes.
+4. Client A comes online and synchronises changes.
+
+In this case, the changes made on client B should take presedence, as they were made later in time than the changes on client A, even though client A most recently synchronised its data.
+:::
+
+To enable this, all _data fields_ are nested objects with a `value` and a `timestamp` field:
+
+
+
+
+ ```json {8, 11} collapse={2-4}
+ {
+ "playback_position": {
+ "value": 15,
+ "timestamp": "2024-06-19T15:46"
+ }
+ }
+ ```
+
+
+
+
+ ```xml {8, 11} collapse={3-5}
+
+
+ 15
+ 2024-06-19T15:46
+
+ ```
+
+
+
+
+Timestamps are recorded in [ISO 8601 format]. When a new episode is created, timestamps are set to current time.
+
+:::note[Discussion details]
+See meeting notes from [2024-02-27](https://pad.funkwhale.audio/s/6mWuDexgz#Data-timestamps)
+
+**Important open discussion point:**
+
+Sending back & forth timestamps is not needed if client always first pulls before push, and assuming that the client stores the timestamps of these changes locally. Maybe we should note this as a requirement, rather than submitting the timestamps.
+Kasts, for example, keeps a log of changes which is wiped on each sync, and always has the timestamp of latest sync.
+
+Does this still work, though, with the scenario laid out above, where clients don't sync in order of changes being applied (but in order coming online)?
+
+:::
+
+[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html
\ No newline at end of file
diff --git a/src/content/docs/specs/episodes/matching-deduplication.mdx b/src/content/docs/specs/episodes/matching-deduplication.mdx
new file mode 100644
index 0000000..96e9cca
--- /dev/null
+++ b/src/content/docs/specs/episodes/matching-deduplication.mdx
@@ -0,0 +1,15 @@
+---
+title: Episode matching & deduplicating
+description: TBD
+sidebar:
+ label: Matching & deduplication
+ order: 2
+---
+
+:::note[Episode matching & deduplication]
+TODO: Describe or reference to information about episode matching and deduplication. See:
+* 2023-05-30 https://pad.funkwhale.audio/oCfs5kJ6QTu02d_oVHW7DA#Deduplication
+* 2024-04-16 https://pad.funkwhale.audio/s/6mWuDexgz#Episode-deduplication
+* 2024-04-24 https://pad.funkwhale.audio/kIRwEOYDRNqTA4np6vbBVg#
+* 2024-05-06 https://pad.funkwhale.audio/2iNOQ2N2RF-ICMQcPWyyYQ#
+:::
\ No newline at end of file
diff --git a/src/content/docs/specs/subscriptions/add-new.mdx b/src/content/docs/specs/subscriptions/add-new.mdx
index 422e892..50765a9 100644
--- a/src/content/docs/specs/subscriptions/add-new.mdx
+++ b/src/content/docs/specs/subscriptions/add-new.mdx
@@ -3,6 +3,9 @@ title: Add a new subscription
description: Add a new subscription
sidebar:
order: 2
+ badge:
+ text: Core
+ variant: caution
---
import CoreAction from "@partials/_core-action.mdx";
diff --git a/src/content/docs/specs/subscriptions/delete.mdx b/src/content/docs/specs/subscriptions/delete.mdx
index 4939f5d..26325c8 100644
--- a/src/content/docs/specs/subscriptions/delete.mdx
+++ b/src/content/docs/specs/subscriptions/delete.mdx
@@ -3,6 +3,9 @@ title: Delete a subscription
description: Fetch the status of a deletion process
sidebar:
order: 6
+ badge:
+ text: Core
+ variant: caution
---
import CoreAction from "@partials/_core-action.mdx";
diff --git a/src/content/docs/specs/subscriptions/get-all.mdx b/src/content/docs/specs/subscriptions/get-all.mdx
index 69fc795..eea370e 100644
--- a/src/content/docs/specs/subscriptions/get-all.mdx
+++ b/src/content/docs/specs/subscriptions/get-all.mdx
@@ -3,6 +3,9 @@ title: Get all subscriptions
description: Get all subscriptions for a user
sidebar:
order: 3
+ badge:
+ text: Core
+ variant: caution
---
import CoreAction from "@partials/_core-action.mdx";
diff --git a/src/content/docs/specs/subscriptions/get-single.mdx b/src/content/docs/specs/subscriptions/get-single.mdx
index 601623e..6cce7f4 100644
--- a/src/content/docs/specs/subscriptions/get-single.mdx
+++ b/src/content/docs/specs/subscriptions/get-single.mdx
@@ -3,6 +3,9 @@ title: Get a single subscription
description: Get a single subscription for a user
sidebar:
order: 4
+ badge:
+ text: Core
+ variant: caution
---
import CoreAction from "@partials/_core-action.mdx";
diff --git a/src/content/docs/specs/subscriptions/status.mdx b/src/content/docs/specs/subscriptions/status.mdx
index 91b5994..2a73340 100644
--- a/src/content/docs/specs/subscriptions/status.mdx
+++ b/src/content/docs/specs/subscriptions/status.mdx
@@ -3,6 +3,9 @@ title: Deletion status endpoint
description: Fetch the status of a deletion process
sidebar:
order: 7
+ badge:
+ text: Core
+ variant: caution
---
import CoreAction from "@partials/_core-action.mdx";
diff --git a/src/content/docs/specs/subscriptions/update.mdx b/src/content/docs/specs/subscriptions/update.mdx
index d7bb440..370cf17 100644
--- a/src/content/docs/specs/subscriptions/update.mdx
+++ b/src/content/docs/specs/subscriptions/update.mdx
@@ -3,6 +3,9 @@ title: Update a subscription
description: Update details about a subscription
sidebar:
order: 5
+ badge:
+ text: Core
+ variant: caution
---
import CoreAction from "@partials/_core-action.mdx";
diff --git a/src/styles/custom.css b/src/styles/custom.css
new file mode 100644
index 0000000..47e5cc3
--- /dev/null
+++ b/src/styles/custom.css
@@ -0,0 +1,3 @@
+tbody code {
+ overflow-wrap: normal;
+}
\ No newline at end of file