From 4b6a07e4a1d997e7c1485fbab5597858d738a28d Mon Sep 17 00:00:00 2001
From: Jonas Heinrich <onny@project-insanity.org>
Date: Thu, 10 Dec 2020 12:31:44 +0100
Subject: [PATCH] create backend support for subscribing to podcast shows

---
 appinfo/routes.php                            |  36 +-
 lib/Controller/FavoriteController.php         | 100 ----
 ...centController.php => ShowsController.php} |  34 +-
 lib/Db/RecentMapper.php                       |  86 ----
 lib/Db/{Station.php => Show.php}              |  37 +-
 lib/Db/{FavoriteMapper.php => ShowMapper.php} |   8 +-
 .../Version000000Date20181013124731.php       |  59 +--
 lib/Service/FavoriteService.php               | 115 -----
 .../{RecentService.php => ShowService.php}    |  68 ++-
 src/views/Episode.vue                         | 454 ++++++++++++++++++
 src/views/Show.vue                            | 316 ++++++++++++
 11 files changed, 850 insertions(+), 463 deletions(-)
 delete mode 100644 lib/Controller/FavoriteController.php
 rename lib/Controller/{RecentController.php => ShowsController.php} (62%)
 delete mode 100644 lib/Db/RecentMapper.php
 rename lib/Db/{Station.php => Show.php} (60%)
 rename lib/Db/{FavoriteMapper.php => ShowMapper.php} (92%)
 delete mode 100644 lib/Service/FavoriteService.php
 rename lib/Service/{RecentService.php => ShowService.php} (57%)
 create mode 100644 src/views/Episode.vue
 create mode 100644 src/views/Show.vue

diff --git a/appinfo/routes.php b/appinfo/routes.php
index 709b327..ab8aa29 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -23,8 +23,7 @@
 
 return [
     'resources' => [
-  		'favorite' => ['url' => '/api/favorites'],
-      'recent' => ['url' => '/api/recent'],
+	'show' => ['url' => '/api/shows'],
   	],
     'routes' => [
 
@@ -36,33 +35,21 @@ return [
        ],
        [
           'name' => 'page#index',
-          'url' => '/top',
+          'url' => '/listening',
           'verb' => 'GET',
-          'postfix' => 'top'
+          'postfix' => 'listening'
        ],
        [
           'name' => 'page#index',
-          'url' => '/recent',
+          'url' => '/library',
           'verb' => 'GET',
-          'postfix' => 'recent'
+          'postfix' => 'library'
        ],
        [
           'name' => 'page#index',
-          'url' => '/new',
+          'url' => '/browse',
           'verb' => 'GET',
-          'postfix' => 'new'
-       ],
-       [
-         'name' => 'page#index',
-         'url' => '/favorites',
-         'verb' => 'GET',
-         'postfix' => 'favorites'
-       ],
-       [
-         'name' => 'page#index',
-         'url' => '/categories',
-         'verb' => 'GET',
-         'postfix' => 'categories'
+          'postfix' => 'browse'
        ],
        [
          'name' => 'page#index',
@@ -101,17 +88,10 @@ return [
 
        // Api
        [
-         'name' => 'favorite_api#preflighted_cors',
-         'url' => '/api/0.1/{path}',
-         'verb' => 'OPTIONS',
-         'requirements' => ['path' => '.+']
-       ],
-       [
-         'name' => 'recent_api#preflighted_cors',
+         'name' => 'show_api#preflighted_cors',
          'url' => '/api/0.1/{path}',
          'verb' => 'OPTIONS',
          'requirements' => ['path' => '.+']
        ]
-
     ]
 ];
diff --git a/lib/Controller/FavoriteController.php b/lib/Controller/FavoriteController.php
deleted file mode 100644
index 072ccac..0000000
--- a/lib/Controller/FavoriteController.php
+++ /dev/null
@@ -1,100 +0,0 @@
-<?php
-
-/**
- * Podcast App
- *
- * @author Jonas Heinrich
- * @copyright 2020 Jonas Heinrich <onny@project-insanity.org>
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this library.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-namespace OCA\Podcast\Controller;
-
-use OCA\Podcast\AppInfo\Application;
-use OCA\Podcast\Service\FavoriteService;
-use OCP\AppFramework\Controller;
-use OCP\AppFramework\Http\DataResponse;
-use OCP\IRequest;
-
-class FavoriteController extends Controller {
-	/** @var FavoriteService */
-	private $service;
-
-	/** @var string */
-	private $userId;
-
-	use Errors;
-
-	public function __construct(IRequest $request,
-								FavoriteService $service,
-								$userId) {
-		parent::__construct(Application::APP_ID, $request);
-		$this->service = $service;
-		$this->userId = $userId;
-	}
-
-	/**
-	 * @NoAdminRequired
-	 */
-	public function index(): DataResponse {
-		return new DataResponse($this->service->findAll($this->userId));
-	}
-
-	/**
-	 * @NoAdminRequired
-	 */
-	public function show(int $id): DataResponse {
-		return $this->handleNotFound(function () use ($id) {
-			return $this->service->find($id, $this->userId);
-		});
-	}
-
-	/**
-	 * @NoAdminRequired
-	 */
-	 public function create(string $stationuuid, string $name, string $favicon, string $urlresolved,
- 		string $bitrate, string $country, string $language, string $homepage,
- 		string $codec, string $tags): DataResponse {
- 		return new DataResponse($this->service->create($stationuuid, $name,
- 			$favicon, $urlresolved, $bitrate, $country, $language, $homepage, $codec,
- 			$tags, $this->userId));
- 	}
-
-	/**
-	 * @NoAdminRequired
-	 */
-	 public function update(int $id, string $stationuuid,
- 						   string $name, string $favicon, string $urlresolved,
- 							 string $bitrate, string $country, string $language, string $homepage,
- 					 		 string $codec, string $tags): DataResponse {
- 		return $this->handleNotFound(function () use ($id, $stationuuid, $name,
- 			$favicon, $urlresolved, $bitrate, $country, $language, $homepage, $codec,
- 			$tags) {
- 			return $this->service->update($id, $stationuuid, $name, $favicon,
- 				$urlresolved, $bitrate, $country, $language, $homepage, $codec,
- 				$tags, $this->userId);
- 		});
- 	}
-
-	/**
-	 * @NoAdminRequired
-	 */
-	public function destroy(int $id): DataResponse {
-		return $this->handleNotFound(function () use ($id) {
-			return $this->service->delete($id, $this->userId);
-		});
-	}
-}
diff --git a/lib/Controller/RecentController.php b/lib/Controller/ShowsController.php
similarity index 62%
rename from lib/Controller/RecentController.php
rename to lib/Controller/ShowsController.php
index fe60682..363c71e 100644
--- a/lib/Controller/RecentController.php
+++ b/lib/Controller/ShowsController.php
@@ -24,13 +24,13 @@
 namespace OCA\Podcast\Controller;
 
 use OCA\Podcast\AppInfo\Application;
-use OCA\Podcast\Service\RecentService;
+use OCA\Podcast\Service\ShowsService;
 use OCP\AppFramework\Controller;
 use OCP\AppFramework\Http\DataResponse;
 use OCP\IRequest;
 
-class RecentController extends Controller {
-	/** @var RecentService */
+class ShowsController extends Controller {
+	/** @var ShowsService */
 	private $service;
 
 	/** @var string */
@@ -39,7 +39,7 @@ class RecentController extends Controller {
 	use Errors;
 
 	public function __construct(IRequest $request,
-								RecentService $service,
+								ShowsService $service,
 								$userId) {
 		parent::__construct(Application::APP_ID, $request);
 		$this->service = $service;
@@ -65,28 +65,18 @@ class RecentController extends Controller {
 	/**
 	 * @NoAdminRequired
 	 */
-	public function create(string $stationuuid, string $name, string $favicon, string $urlresolved,
-		string $bitrate, string $country, string $language, string $homepage,
-		string $codec, string $tags): DataResponse {
-		return new DataResponse($this->service->create($stationuuid, $name,
-			$favicon, $urlresolved, $bitrate, $country, $language, $homepage, $codec,
-			$tags, $this->userId));
-	}
+	 public function create(int $id, string $title, string $htmlURL, string $smallImageURL, string $categories, string $lastpub, string $description, string $author): DataResponse {
+ 		return new DataResponse($this->service->create($id, $title, $htmlURL, $smallImageURL, $categories, $lastpub, $description, $author, $this->userId));
+ 	}
 
 	/**
 	 * @NoAdminRequired
 	 */
-	public function update(int $id, string $stationuuid, string $name,
-		string $favicon, string $urlresolved, string $bitrate, string $country,
-		string $language, string $homepage, string $codec, string $tags): DataResponse {
-		return $this->handleNotFound(function () use ($id, $stationuuid, $name,
-			$favicon, $urlresolved, $bitrate, $country, $language, $homepage, $codec,
-			$tags) {
-			return $this->service->update($id, $stationuuid, $name, $favicon,
-				$urlresolved, $bitrate, $country, $language, $homepage, $codec,
-				$tags, $this->userId);
-		});
-	}
+	public function update(int $id, string $title, string $htmlURL, string $smallImageURL, string $categories, string $lastpub, string $description, string $author): DataResponse {
+		return $this->handleNotFound(function () use ($id, $title, $htmlURL, $smallImageURL, $categories, $lastpub, $description, $author) {
+			return $this->service->update($id, $title, $htmlURL, $smallImageURL, $categories, $lastpub, $description, $author, $this->userId);
+ 		});
+ 	}
 
 	/**
 	 * @NoAdminRequired
diff --git a/lib/Db/RecentMapper.php b/lib/Db/RecentMapper.php
deleted file mode 100644
index 1538077..0000000
--- a/lib/Db/RecentMapper.php
+++ /dev/null
@@ -1,86 +0,0 @@
-<?php
-
-/**
- * Podcast App
- *
- * @author Jonas Heinrich
- * @copyright 2020 Jonas Heinrich <onny@project-insanity.org>
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this library.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-namespace OCA\Podcast\Db;
-
-use OCP\AppFramework\Db\DoesNotExistException;
-use OCP\AppFramework\Db\Entity;
-use OCP\AppFramework\Db\QBMapper;
-use OCP\DB\QueryBuilder\IQueryBuilder;
-use OCP\IDBConnection;
-
-class RecentMapper extends QBMapper {
-	public function __construct(IDBConnection $db) {
-		parent::__construct($db, 'recent', Station::class);
-	}
-
-	/**
-	 * @param int $id
-	 * @param string $userId
-	 * @return Entity|Station
-	 * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
-	 * @throws DoesNotExistException
-	 */
-	public function find(int $id, string $userId): Station {
-		/* @var $qb IQueryBuilder */
-		$qb = $this->db->getQueryBuilder();
-		$qb->selectDistinct('stationuuid')
-			->addSelect('name')
-			->addSelect('favicon')
-			->addSelect('urlresolved')
-			->addSelect('bitrate')
-			->addSelect('country')
-			->addSelect('language')
-			->addSelect('homepage')
-			->addSelect('codec')
-			->addSelect('tags')
-			->from('recent')
-			->orderBy('id', 'DESC')
-			->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
-			->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
-		return $this->findEntity($qb);
-	}
-
-	/**
-	 * @param string $userId
-	 * @return array
-	 */
-	public function findAll(string $userId): array {
-		/* @var $qb IQueryBuilder */
-		$qb = $this->db->getQueryBuilder();
-		$qb->selectDistinct('stationuuid')
-			->addSelect('name')
-			->addSelect('favicon')
-			->addSelect('urlresolved')
-			->addSelect('bitrate')
-			->addSelect('country')
-			->addSelect('language')
-			->addSelect('homepage')
-			->addSelect('codec')
-			->addSelect('tags')
-			->from('recent')
-			->orderBy('id', 'DESC')
-			->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
-		return $this->findEntities($qb);
-	}
-}
diff --git a/lib/Db/Station.php b/lib/Db/Show.php
similarity index 60%
rename from lib/Db/Station.php
rename to lib/Db/Show.php
index a12afbf..8c081cc 100644
--- a/lib/Db/Station.php
+++ b/lib/Db/Show.php
@@ -27,32 +27,25 @@ use JsonSerializable;
 
 use OCP\AppFramework\Db\Entity;
 
-class Station extends Entity implements JsonSerializable {
-	protected $stationuuid;
-	protected $name;
-	protected $favicon;
-	protected $urlresolved;
-	protected $bitrate;
-	protected $country;
-	protected $language;
-	protected $homepage;
-	protected $codec;
-	protected $tags;
-	protected $userId;
+class Show extends Entity implements JsonSerializable {
+	protected $title;
+	protected $htmlURL;
+	protected $smallImageURL;
+	protected $categories;
+	protected $lastpub;
+	protected $description;
+	protected $author;
 
 	public function jsonSerialize(): array {
 		return [
 			'id' => $this->id,
-			'stationuuid' => $this->stationuuid,
-			'name' => $this->name,
-			'favicon' => $this->favicon,
-			'urlresolved' => $this->urlresolved,
-			'bitrate' => $this->bitrate,
-			'country' => $this->country,
-			'language' => $this->language,
-			'homepage' => $this->homepage,
-			'codec' => $this->codec,
-			'tags' => $this->tags
+			'title' => $this->title,
+			'htmlURL' => $this->htmlURL,
+			'smallImageURL' => $this->smallImageURL,
+			'categories' => $this->categories,
+			'lastpub' => $this->lastpub,
+			'description' => $this->description,
+			'author' => $this->author,
 		];
 	}
 }
diff --git a/lib/Db/FavoriteMapper.php b/lib/Db/ShowMapper.php
similarity index 92%
rename from lib/Db/FavoriteMapper.php
rename to lib/Db/ShowMapper.php
index d94cf44..5a0d7d6 100644
--- a/lib/Db/FavoriteMapper.php
+++ b/lib/Db/ShowMapper.php
@@ -29,9 +29,9 @@ use OCP\AppFramework\Db\QBMapper;
 use OCP\DB\QueryBuilder\IQueryBuilder;
 use OCP\IDBConnection;
 
-class FavoriteMapper extends QBMapper {
+class ShowMapper extends QBMapper {
 	public function __construct(IDBConnection $db) {
-		parent::__construct($db, 'favorites', Station::class);
+		parent::__construct($db, 'shows', Station::class);
 	}
 
 	/**
@@ -45,7 +45,7 @@ class FavoriteMapper extends QBMapper {
 		/* @var $qb IQueryBuilder */
 		$qb = $this->db->getQueryBuilder();
 		$qb->select('*')
-			->from('favorites')
+			->from('shows')
 			->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
 			->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
 		return $this->findEntity($qb);
@@ -59,7 +59,7 @@ class FavoriteMapper extends QBMapper {
 		/* @var $qb IQueryBuilder */
 		$qb = $this->db->getQueryBuilder();
 		$qb->select('*')
-			->from('favorites')
+			->from('shows')
 			->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
 		return $this->findEntities($qb);
 	}
diff --git a/lib/Migration/Version000000Date20181013124731.php b/lib/Migration/Version000000Date20181013124731.php
index 7239635..ad9b84a 100644
--- a/lib/Migration/Version000000Date20181013124731.php
+++ b/lib/Migration/Version000000Date20181013124731.php
@@ -42,60 +42,21 @@ class Version000000Date20181013124731 extends SimpleMigrationStep {
 		/** @var ISchemaWrapper $schema */
 		$schema = $schemaClosure();
 
-		if (!$schema->hasTable('favorites')) {
-			$table = $schema->createTable('favorites');
+		if (!$schema->hasTable('shows')) {
+			$table = $schema->createTable('shows');
 			$table->addColumn('id', 'integer', [
-				'autoincrement' => true,
 				'notnull' => true,
 			]);
-			$table->addColumn('stationuuid', 'string', [
-				'notnull' => true,
-			]);
-			$table->addColumn('user_id', 'string', [
-				'notnull' => true,
-			]);
-			$table->addColumn('name', 'text', [
-				'notnull' => true,
-			]);
-			$table->addColumn('favicon', 'text');
-			$table->addColumn('urlresolved', 'text');
-			$table->addColumn('bitrate', 'text');
-			$table->addColumn('country', 'text');
-			$table->addColumn('language', 'text');
-			$table->addColumn('homepage', 'text');
-			$table->addColumn('codec', 'text');
-			$table->addColumn('tags', 'text');
-
-			$table->setPrimaryKey(['id']);
-			$table->addIndex(['user_id'], 'favorites_user_id_index');
-		}
-
-		if (!$schema->hasTable('recent')) {
-			$table = $schema->createTable('recent');
-			$table->addColumn('id', 'integer', [
-				'autoincrement' => true,
-				'notnull' => true,
-			]);
-			$table->addColumn('stationuuid', 'string', [
-				'notnull' => true,
-			]);
-			$table->addColumn('user_id', 'string', [
-				'notnull' => true,
-			]);
-			$table->addColumn('name', 'text', [
-				'notnull' => true,
-			]);
-			$table->addColumn('favicon', 'text');
-			$table->addColumn('urlresolved', 'text');
-			$table->addColumn('bitrate', 'text');
-			$table->addColumn('country', 'text');
-			$table->addColumn('language', 'text');
-			$table->addColumn('homepage', 'text');
-			$table->addColumn('codec', 'text');
-			$table->addColumn('tags', 'text');
+			$table->addColumn('title', 'string');
+			$table->addColumn('htmlURL', 'string');
+			$table->addColumn('smallImageURL', 'string');
+			$table->addColumn('categories', 'text');
+			$table->addColumn('lastpub', 'text');
+			$table->addColumn('description', 'text');
+			$table->addColumn('author', 'text');
 
 			$table->setPrimaryKey(['id']);
-			$table->addIndex(['user_id'], 'recent_user_id_index');
+			$table->addIndex(['user_id'], 'shows_user_id_index');
 		}
 
 		return $schema;
diff --git a/lib/Service/FavoriteService.php b/lib/Service/FavoriteService.php
deleted file mode 100644
index fdb1b27..0000000
--- a/lib/Service/FavoriteService.php
+++ /dev/null
@@ -1,115 +0,0 @@
-<?php
-
-/**
- * Podcast App
- *
- * @author Jonas Heinrich
- * @copyright 2020 Jonas Heinrich <onny@project-insanity.org>
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this library.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-namespace OCA\Podcast\Service;
-
-use Exception;
-
-use OCP\AppFramework\Db\DoesNotExistException;
-use OCP\AppFramework\Db\MultipleObjectsReturnedException;
-
-use OCA\Podcast\Db\Station;
-use OCA\Podcast\Db\FavoriteMapper;
-
-class FavoriteService {
-
-	/** @var FavoriteMapper */
-	private $mapper;
-
-	public function __construct(FavoriteMapper $mapper) {
-		$this->mapper = $mapper;
-	}
-
-	public function findAll(string $userId): array {
-		return $this->mapper->findAll($userId);
-	}
-
-	private function handleException(Exception $e): void {
-		if ($e instanceof DoesNotExistException ||
-			$e instanceof MultipleObjectsReturnedException) {
-			throw new StationNotFound($e->getMessage());
-		} else {
-			throw $e;
-		}
-	}
-
-	public function find($id, $userId) {
-		try {
-			return $this->mapper->find($id, $userId);
-
-			// in order to be able to plug in different storage backends like files
-		// for instance it is a good idea to turn storage related exceptions
-		// into service related exceptions so controllers and service users
-		// have to deal with only one type of exception
-		} catch (Exception $e) {
-			$this->handleException($e);
-		}
-	}
-
-	public function create($stationuuid, $name, $favicon, $urlresolved,
-	 	$bitrate, $country, $language, $homepage, $codec, $tags, $userId) {
-		$station = new Station();
-		$station->setStationuuid($stationuuid);
-		$station->setName($name);
-		$station->setFavicon($favicon);
-		$station->setUrlresolved($urlresolved);
-		$station->setBitrate($bitrate);
-		$station->setCountry($country);
-		$station->setLanguage($language);
-		$station->setHomepage($homepage);
-		$station->setCodec($codec);
-		$station->setTags($tags);
-		$station->setUserId($userId);
-		return $this->mapper->insert($station);
-	}
-
-	public function update($id, $stationuuid, $name, $favicon, $urlresolved,
-		$bitrate, $country, $language, $homepage, $codec, $tags, $userId) {
-		try {
-			$station = $this->mapper->find($id, $userId);
-			$station->setStationuuid($stationuuid);
-			$station->setName($name);
-			$station->setFavicon($favicon);
-			$station->setUrlresolved($urlresolved);
-			$station->setBitrate($bitrate);
-			$station->setCountry($country);
-			$station->setLanguage($language);
-			$station->setHomepage($homepage);
-			$station->setCodec($codec);
-			$station->setTags($tags);
-			return $this->mapper->update($station);
-		} catch (Exception $e) {
-			$this->handleException($e);
-		}
-	}
-
-	public function delete($id, $userId) {
-		try {
-			$station = $this->mapper->find($id, $userId);
-			$this->mapper->delete($station);
-			return $station;
-		} catch (Exception $e) {
-			$this->handleException($e);
-		}
-	}
-}
diff --git a/lib/Service/RecentService.php b/lib/Service/ShowService.php
similarity index 57%
rename from lib/Service/RecentService.php
rename to lib/Service/ShowService.php
index 62925b4..0185e89 100644
--- a/lib/Service/RecentService.php
+++ b/lib/Service/ShowService.php
@@ -28,15 +28,15 @@ use Exception;
 use OCP\AppFramework\Db\DoesNotExistException;
 use OCP\AppFramework\Db\MultipleObjectsReturnedException;
 
-use OCA\Podcast\Db\Station;
-use OCA\Podcast\Db\RecentMapper;
+use OCA\Podcast\Db\Show;
+use OCA\Podcast\Db\ShowMapper;
 
-class RecentService {
+class ShowService {
 
-	/** @var RecentMapper */
+	/** @var ShowMapper */
 	private $mapper;
 
-	public function __construct(RecentMapper $mapper) {
+	public function __construct(ShowMapper $mapper) {
 		$this->mapper = $mapper;
 	}
 
@@ -66,38 +66,32 @@ class RecentService {
 		}
 	}
 
-	public function create($stationuuid, $name, $favicon, $urlresolved,
-		$bitrate, $country, $language, $homepage, $codec, $tags, $userId) {
-		$station = new Station();
-		$station->setStationuuid($stationuuid);
-		$station->setName($name);
-		$station->setFavicon($favicon);
-		$station->setUrlresolved($urlresolved);
-		$station->setBitrate($bitrate);
-		$station->setCountry($country);
-		$station->setLanguage($language);
-		$station->setHomepage($homepage);
-		$station->setCodec($codec);
-		$station->setTags($tags);
-		$station->setUserId($userId);
-		return $this->mapper->insert($station);
+	public function create($id, $title, $htmlURL, $smallImageURL, $categories, $lastpub, $description, $author) {
+		$show = new Show();
+		$show->setId($id);
+		$show->setTitle($title);
+		$show->setHtmlURL($htmlURL);
+		$show->setSmallImageURL($smallImageURL);
+		$show->setCategories($categories);
+		$show->setLastpub($lastpub);
+		$show->setDescription($description);
+		$show->setAuthor($author);
+		$show->setUserId($userId);
+		return $this->mapper->insert($show);
 	}
 
-	public function update($id, $stationuuid, $name, $favicon, $urlresolved,
-		$bitrate, $country, $language, $homepage, $codec, $tags, $userId) {
+	public function update($id, $title, $htmlURL, $smallImageURL, $categories, $lastpub, $description, $author) {
 		try {
-			$station = $this->mapper->find($id, $userId);
-			$station->setStationuuid($stationuuid);
-			$station->setName($name);
-			$station->setFavicon($favicon);
-			$station->setUrlresolved($urlresolved);
-			$station->setBitrate($bitrate);
-			$station->setCountry($country);
-			$station->setLanguage($language);
-			$station->setHomepage($homepage);
-			$station->setCodec($codec);
-			$station->setTags($tags);
-			return $this->mapper->update($station);
+			$show = $this->mapper->find($id, $userId);
+			$show->setTitle($title);
+			$show->setHtmlURL($htmlURL);
+			$show->setSmallImageURL($smallImageURL);
+			$show->setCategories($categories);
+			$show->setLastpub($lastpub);
+			$show->setDescription($description);
+			$show->setAuthor($author);
+			$show->setUserId($userId);
+			return $this->mapper->update($show);
 		} catch (Exception $e) {
 			$this->handleException($e);
 		}
@@ -105,9 +99,9 @@ class RecentService {
 
 	public function delete($id, $userId) {
 		try {
-			$station = $this->mapper->find($id, $userId);
-			$this->mapper->delete($station);
-			return $station;
+			$show = $this->mapper->find($id, $userId);
+			$this->mapper->delete($show);
+			return $show;
 		} catch (Exception $e) {
 			$this->handleException($e);
 		}
diff --git a/src/views/Episode.vue b/src/views/Episode.vue
new file mode 100644
index 0000000..52427c8
--- /dev/null
+++ b/src/views/Episode.vue
@@ -0,0 +1,454 @@
+<!--
+  - @copyright Copyright (c) 2020 Jonas Heinrich
+  -
+  - @author Jonas Heinrich <onny@project-insanity.org>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<Content app-name="podcast">
+		<Navigation
+			:station-data="tableData" />
+		<AppContent>
+			<div class="podcastHeader">
+				<div
+					class="podcastImage"
+					:style="{ backgroundImage: `url(${podcast.imgURL})` }" />
+				<div class="podcastDescription">
+					<h1>{{ podcast.title }}</h1>
+					<div class="podcastAuthor">
+						by <a href="#">{{ podcast.author }}</a>
+					</div>
+					<div>{{ podcast.description }}</div>
+					<ul
+						v-for="(category, idx) in podcast.categories"
+						:key="idx"
+						:class="podcastCategory">
+						<li>{{ podcast.categories[idx] }}</li>
+					</ul>
+				</div>
+			</div>
+			<Table
+				v-show="!pageLoading && podcast.episodes.length > 0"
+				v-resize="onResize"
+				:station-data="podcast.episodes"
+				:favorites="favorites"
+				@doPlay="doPlay"
+				@doFavor="doFavor"
+				@toggleSidebar="toggleSidebar" />
+			<EmptyContent
+				v-if="pageLoading"
+				icon="icon-loading" />
+			<EmptyContent
+				v-if="tableData.length === 0 && !pageLoading"
+				:icon="emptyContentIcon">
+				{{ emptyContentMessage }}
+				<template #desc>
+					{{ emptyContentDesc }}
+				</template>
+			</EmptyContent>
+		</AppContent>
+		<Sidebar
+			:show-sidebar="showSidebar"
+			:sidebar-station="sidebarStation"
+			@toggleSidebar="toggleSidebar" />
+	</Content>
+</template>
+
+<script>
+import Content from '@nextcloud/vue/dist/Components/Content'
+import AppContent from '@nextcloud/vue/dist/Components/AppContent'
+import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
+import Navigation from '../components/Navigation'
+import Table from '../components/Table'
+import Sidebar from '../components/Sidebar'
+import { Howl, Howler } from 'howler'
+
+import { generateUrl } from '@nextcloud/router'
+import { showError } from '@nextcloud/dialogs'
+import axios from '@nextcloud/axios'
+
+let audioPlayer = null
+const requesttoken = axios.defaults.headers.requesttoken
+
+export default {
+	name: 'Episode',
+	components: {
+		Navigation,
+		Content,
+		AppContent,
+		Table,
+		EmptyContent,
+		Sidebar,
+	},
+	data: () => ({
+		podcast: {},
+		tableData: [],
+		pageLoading: false,
+		favorites: [],
+		showSidebar: false,
+		sidebarStation: {},
+		queryParams: {},
+	}),
+	computed: {
+		player() {
+			return this.$store.state.player
+		},
+		emptyContentMessage() {
+			if (this.$route.name === 'FAVORITES') {
+				return t('podcast', 'No favorites yet')
+			} else if (this.$route.name === 'RECENT') {
+				return t('podcast', 'No recent stations yet')
+			} else if (this.$route.name === 'SEARCH') {
+				return t('podcast', 'No search results')
+			}
+			return 'No stations here'
+		},
+		emptyContentIcon() {
+			if (this.$route.name === 'FAVORITES') {
+				return 'icon-star'
+			} else if (this.$route.name === 'RECENT') {
+				return 'icon-recent'
+			} else if (this.$route.name === 'SEARCH') {
+				return 'icon-search'
+			}
+			return 'icon-podcast'
+		},
+		emptyContentDesc() {
+			if (this.$route.name === 'FAVORITES') {
+				return t('podcast', 'Stations you mark as favorite will show up here')
+			} else if (this.$route.name === 'RECENT') {
+				return t('podcast', 'Stations you recently played will show up here')
+			} else if (this.$route.name === 'SEARCH') {
+				return t('podcast', 'No stations were found matching your search term')
+			}
+			return t('podcast', 'No stations here')
+		},
+	},
+	watch: {
+		$route: 'onRoute',
+		'player.volume'(newVolume, oldVolume) {
+			if (audioPlayer !== null) {
+				audioPlayer.volume(newVolume)
+			}
+		},
+		'player.isPaused'(newState, oldState) {
+			if (newState === true && audioPlayer !== null) {
+				audioPlayer.pause()
+			} else if (newState === false && audioPlayer !== null) {
+				audioPlayer.play()
+			}
+		},
+	},
+	created() {
+		this.loadSettings()
+		this.loadFavorites()
+	},
+	mounted() {
+		this.onRoute()
+		this.scroll()
+	},
+	methods: {
+
+		onResize({ width, height }) {
+			const contentHeight = document.getElementById('app-content-vue').scrollHeight
+			const tableHeight = height
+			if (tableHeight < contentHeight) {
+				this.preFill()
+			}
+		},
+
+		preFill() {
+			const route = this.$route
+			console.log(route)
+			// this.queryPodcast(route.name)
+		},
+
+		async onRoute() {
+			this.tableData = []
+			this.pageLoading = true
+			const podcastId = this.$route.params.id
+			this.queryPodcast(podcastId)
+		},
+
+		/**
+			 * Favor a new station by sending the information to the server
+			 * @param {Object} station Station object
+			 */
+		async doFavor(station) {
+			if (this.favorites.flat().includes(station.stationuuid)) {
+				let stationid = null
+				try {
+					for (let i = 0, len = this.favorites.length; i < len; i++) {
+						if (station.stationuuid === this.favorites[i][1]) {
+							stationid = this.favorites[i][0]
+						}
+					}
+					axios.defaults.headers.requesttoken = requesttoken
+					await axios
+						.delete(generateUrl(`/apps/podcast/api/favorites/${stationid}`))
+						.then(response => {
+							this.favorites = this.favorites.filter(item => item[1] !== station.stationuuid)
+						})
+				} catch (error) {
+					showError(t('podcast', 'Could not remove station from favorites'))
+				}
+			} else {
+				try {
+					let stationSrc = ''
+					if (!station.url_resolved) {
+						stationSrc = station.urlresolved
+					} else {
+						stationSrc = station.url_resolved
+					}
+					const stationMap = {
+						id: -1,
+						name: station.name.toString(),
+						urlresolved: stationSrc.toString(),
+						favicon: station.favicon.toString(),
+						stationuuid: station.stationuuid.toString(),
+						bitrate: station.bitrate.toString(),
+						country: station.country.toString(),
+						language: station.language.toString(),
+						homepage: station.homepage.toString(),
+						codec: station.codec.toString(),
+						tags: station.tags.toString(),
+					}
+					axios.defaults.headers.requesttoken = requesttoken
+					await axios
+						.post(generateUrl('/apps/podcast/api/favorites'), stationMap)
+						.then(response => {
+							this.favorites.push([response.data.id, station.stationuuid])
+						})
+				} catch (error) {
+					showError(t('podcast', 'Could not favor station'))
+				}
+			}
+		},
+
+		/**
+			 * Start playing a podcast station and counting the playback
+			 * @param {Object} station Station object
+			 */
+		async doPlay(station) {
+			const vm = this
+
+			vm.$store.dispatch('isBuffering', true)
+
+			if (audioPlayer !== null) {
+				audioPlayer.fade(vm.player.volume, 0, 500)
+			}
+			vm.$store.dispatch('setTitle', station.name)
+
+			let stationSrc = ''
+			if (!station.url_resolved) {
+				stationSrc = station.urlresolved
+			} else {
+				stationSrc = station.url_resolved
+			}
+			Howler.unload()
+			audioPlayer = new Howl({
+				src: stationSrc,
+				html5: true,
+				volume: vm.player.volume,
+				onplay() {
+					vm.$store.dispatch('isPlaying', true)
+					vm.$store.dispatch('isBuffering', false)
+				},
+				onpause() {
+					vm.$store.dispatch('isPlaying', false)
+					vm.$store.dispatch('isBuffering', false)
+				},
+				onend() {
+					showError(t('podcast', 'Lost connection to podcast station, retrying ...'))
+					vm.$store.dispatch('isPlaying', false)
+					vm.$store.dispatch('isBuffering', true)
+				},
+			})
+			audioPlayer.unload()
+			audioPlayer.play()
+			audioPlayer.fade(0, vm.player.volume, 500)
+
+			/* Count click */
+			try {
+				delete axios.defaults.headers.requesttoken
+				axios.get(this.$apiUrl + '/json/url/' + station.stationuuid)
+			} catch (error) {
+				showError(t('podcast', 'Unable to count play on remote API'))
+			}
+
+			/* Put into recent stations */
+			try {
+				let stationSrc = ''
+				if (!station.url_resolved) {
+					stationSrc = station.urlresolved
+				} else {
+					stationSrc = station.url_resolved
+				}
+				const stationMap = {
+					id: -1,
+					name: station.name.toString(),
+					urlresolved: stationSrc.toString(),
+					favicon: station.favicon.toString(),
+					stationuuid: station.stationuuid.toString(),
+					bitrate: station.bitrate.toString(),
+					country: station.country.toString(),
+					language: station.language.toString(),
+					homepage: station.homepage.toString(),
+					codec: station.codec.toString(),
+					tags: station.tags.toString(),
+				}
+				axios.defaults.headers.requesttoken = requesttoken
+				await axios
+					.post(generateUrl('/apps/podcast/api/recent'), stationMap)
+			} catch (error) {
+				showError(t('podcast', 'Could not add station to recent list'))
+			}
+
+		},
+
+		async queryPodcast(podcastId) {
+
+			const vm = this
+			podcastId = 1084
+
+			const queryURI = 'https://api.fyyd.de/0.2/podcast/episodes?podcast_id=' + podcastId
+			try {
+				delete axios.defaults.headers.requesttoken
+				await axios.get(queryURI)
+					.then(function(response) {
+						vm.processPodcast(response.data)
+					})
+			} catch (error) {
+				showError(t('podcast', 'Could not fetch stations from remote API'))
+			}
+		},
+
+		processPodcast(podcast) {
+			this.podcast = podcast.data
+			this.pageLoading = false
+		},
+
+		/**
+		 * On scroll event, load more stations if bottom reached
+		 */
+		scroll() {
+			window.onscroll = () => {
+				if ((window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
+					const route = this.$route
+					console.log(route)
+					// this.queryPodcast(route.name)
+		    }
+			}
+		},
+		loadSettings() {
+
+			// axios.defaults.headers.common = {
+			// 'User-Agent': 'Nextcloud Podcast App/' + this.$version,
+			// }
+			this.$store.dispatch('getVolumeState')
+
+		},
+
+		async loadFavorites() {
+			const vm = this
+			try {
+				axios.defaults.headers.requesttoken = requesttoken
+				await axios.get(generateUrl('/apps/podcast/api/favorites'))
+					.then(function(response) {
+						const favorites = []
+						for (let i = 0, len = response.data.length; i < len; i++) {
+							favorites.push([response.data[i].id, response.data[i].stationuuid])
+						}
+						vm.favorites = favorites
+					})
+			} catch (error) {
+				showError(t('podcast', 'Unable to load favorites'))
+			}
+		},
+
+		toggleSidebar(station = null) {
+			if (station) {
+				this.showSidebar = true
+				this.sidebarStation = station
+			} else {
+				this.showSidebar = false
+			}
+		},
+	},
+}
+</script>
+
+<style lang="scss">
+
+@media only screen and (min-width: 1024px) {
+	.app-navigation-toggle {
+		display: none;
+	}
+}
+
+.podcastHeader {
+	width: 100%;
+	background: black;
+	min-height: 300px;
+	display: flex;
+	color: white;
+	justify-content: center;
+	padding: 40px 20px;
+	gap: 30px;
+
+	.podcastImage {
+		width: 230px;
+		height: 230px;
+		background: red;
+		background-size: cover;
+		background-position: center center;
+	}
+
+	.podcastDescription {
+		max-width: 500px;
+		max-height: 200px;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		color: #ddd;
+
+		h1 {
+			font-size: 30px;
+			font-weight: bold;
+			line-height: 1.2em;
+			color: white;
+		}
+
+		.podcastAuthor {
+			padding-bottom: 10px;
+
+			a {
+				color: white;
+			}
+		}
+
+		ul.podcastCategory li {
+			padding: 5px;
+			background: red;
+		}
+
+	}
+
+}
+
+</style>
diff --git a/src/views/Show.vue b/src/views/Show.vue
new file mode 100644
index 0000000..cda01cf
--- /dev/null
+++ b/src/views/Show.vue
@@ -0,0 +1,316 @@
+<!--
+  - @copyright Copyright (c) 2020 Jonas Heinrich
+  -
+  - @author Jonas Heinrich <onny@project-insanity.org>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<Content app-name="podcast">
+		<Navigation
+			:station-data="tableData" />
+		<AppContent>
+			<div class="podcastHeader">
+				<div
+					class="podcastImage"
+					:style="{ backgroundImage: `url(${podcast.smallImageURL})` }" />
+				<div class="podcastDescription">
+					<h1>{{ podcast.title }}</h1>
+					<div class="podcastAuthor">
+						by <a href="#">{{ podcast.author }}</a>
+					</div>
+					<div>{{ podcast.description }}</div>
+					<ul
+						v-for="(category, idx) in podcast.categories"
+						:key="idx"
+						:class="podcastCategory">
+						<li>{{ podcast.categories[idx] }}</li>
+					</ul>
+				</div>
+			</div>
+			<Table
+				v-show="!pageLoading && podcast.episodes.length > 0"
+				v-resize="onResize"
+				:episodes="podcast.episodes"
+				@doPlay="doPlay" />
+			<EmptyContent
+				v-if="pageLoading"
+				icon="icon-loading" />
+			<EmptyContent
+				v-if="tableData.length === 0 && !pageLoading"
+				:icon="emptyContentIcon">
+				{{ emptyContentMessage }}
+				<template #desc>
+					{{ emptyContentDesc }}
+				</template>
+			</EmptyContent>
+		</AppContent>
+	</Content>
+</template>
+
+<script>
+import Content from '@nextcloud/vue/dist/Components/Content'
+import AppContent from '@nextcloud/vue/dist/Components/AppContent'
+import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
+import Navigation from '../components/Navigation'
+import Table from '../components/Table'
+import { Howl, Howler } from 'howler'
+
+import { showError } from '@nextcloud/dialogs'
+import axios from '@nextcloud/axios'
+
+let audioPlayer = null
+
+export default {
+	name: 'Show',
+	components: {
+		Navigation,
+		Content,
+		AppContent,
+		Table,
+		EmptyContent,
+	},
+	data: () => ({
+		podcast: {},
+		tableData: [],
+		pageLoading: false,
+		queryParams: {},
+	}),
+	computed: {
+		player() {
+			return this.$store.state.player
+		},
+		emptyContentMessage() {
+			if (this.$route.name === 'FAVORITES') {
+				return t('podcast', 'No favorites yet')
+			} else if (this.$route.name === 'RECENT') {
+				return t('podcast', 'No recent stations yet')
+			} else if (this.$route.name === 'SEARCH') {
+				return t('podcast', 'No search results')
+			}
+			return 'No stations here'
+		},
+		emptyContentIcon() {
+			if (this.$route.name === 'FAVORITES') {
+				return 'icon-star'
+			} else if (this.$route.name === 'RECENT') {
+				return 'icon-recent'
+			} else if (this.$route.name === 'SEARCH') {
+				return 'icon-search'
+			}
+			return 'icon-podcast'
+		},
+		emptyContentDesc() {
+			if (this.$route.name === 'FAVORITES') {
+				return t('podcast', 'Stations you mark as favorite will show up here')
+			} else if (this.$route.name === 'RECENT') {
+				return t('podcast', 'Stations you recently played will show up here')
+			} else if (this.$route.name === 'SEARCH') {
+				return t('podcast', 'No stations were found matching your search term')
+			}
+			return t('podcast', 'No stations here')
+		},
+	},
+	watch: {
+		$route: 'onRoute',
+		'player.volume'(newVolume, oldVolume) {
+			if (audioPlayer !== null) {
+				audioPlayer.volume(newVolume)
+			}
+		},
+		'player.isPaused'(newState, oldState) {
+			if (newState === true && audioPlayer !== null) {
+				audioPlayer.pause()
+			} else if (newState === false && audioPlayer !== null) {
+				audioPlayer.play()
+			}
+		},
+	},
+	created() {
+		this.loadSettings()
+	},
+	mounted() {
+		this.onRoute()
+		this.scroll()
+	},
+	methods: {
+
+		onResize({ width, height }) {
+			const contentHeight = document.getElementById('app-content-vue').scrollHeight
+			const tableHeight = height
+			if (tableHeight < contentHeight) {
+				this.preFill()
+			}
+		},
+
+		preFill() {
+			const route = this.$route
+			console.log(route)
+			// this.queryPodcast(route.name)
+		},
+
+		async onRoute() {
+			this.tableData = []
+			this.pageLoading = true
+			const podcastId = this.$route.params.id
+			this.queryPodcast(podcastId)
+		},
+
+		/**
+			 * Start playing a podcast episode
+			 * @param {Object} episode Episode object
+			 */
+		async doPlay(episode) {
+			const vm = this
+
+			vm.$store.dispatch('isBuffering', true)
+
+			if (audioPlayer !== null) {
+				audioPlayer.fade(vm.player.volume, 0, 500)
+			}
+			vm.$store.dispatch('setTitle', episode.title)
+
+			Howler.unload()
+			audioPlayer = new Howl({
+				src: episode.enclosure,
+				html5: true,
+				volume: vm.player.volume,
+				onplay() {
+					vm.$store.dispatch('isPlaying', true)
+					vm.$store.dispatch('isBuffering', false)
+				},
+				onpause() {
+					vm.$store.dispatch('isPlaying', false)
+					vm.$store.dispatch('isBuffering', false)
+				},
+				onend() {
+					showError(t('podcast', 'Lost connection to podcast station, retrying ...'))
+					vm.$store.dispatch('isPlaying', false)
+					vm.$store.dispatch('isBuffering', true)
+				},
+			})
+			audioPlayer.unload()
+			audioPlayer.play()
+			audioPlayer.fade(0, vm.player.volume, 500)
+		},
+
+		async queryPodcast(podcastId) {
+
+			const vm = this
+			podcastId = 1084
+
+			const queryURI = 'https://api.fyyd.de/0.2/podcast/episodes?podcast_id=' + podcastId
+			try {
+				delete axios.defaults.headers.requesttoken
+				await axios.get(queryURI)
+					.then(function(response) {
+						vm.processPodcast(response.data)
+					})
+			} catch (error) {
+				showError(t('podcast', 'Could not fetch stations from remote API'))
+			}
+		},
+
+		processPodcast(podcast) {
+			this.podcast = podcast.data
+			this.pageLoading = false
+		},
+
+		/**
+		 * On scroll event, load more stations if bottom reached
+		 */
+		scroll() {
+			window.onscroll = () => {
+				if ((window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
+					const route = this.$route
+					console.log(route)
+					// this.queryPodcast(route.name)
+		    }
+			}
+		},
+		loadSettings() {
+
+			// axios.defaults.headers.common = {
+			// 'User-Agent': 'Nextcloud Podcast App/' + this.$version,
+			// }
+			this.$store.dispatch('getVolumeState')
+
+		},
+
+	},
+}
+</script>
+
+<style lang="scss">
+
+@media only screen and (min-width: 1024px) {
+	.app-navigation-toggle {
+		display: none;
+	}
+}
+
+.podcastHeader {
+	width: 100%;
+	background: black;
+	min-height: 300px;
+	display: flex;
+	color: white;
+	justify-content: center;
+	padding: 40px 20px;
+	gap: 30px;
+
+	.podcastImage {
+		width: 230px;
+		height: 230px;
+		background: red;
+		background-size: cover;
+		background-position: center center;
+	}
+
+	.podcastDescription {
+		max-width: 500px;
+		max-height: 200px;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		color: #ddd;
+
+		h1 {
+			font-size: 30px;
+			font-weight: bold;
+			line-height: 1.2em;
+			color: white;
+		}
+
+		.podcastAuthor {
+			padding-bottom: 10px;
+
+			a {
+				color: white;
+			}
+		}
+
+		ul.podcastCategory li {
+			padding: 5px;
+			background: red;
+		}
+
+	}
+
+}
+
+</style>
-- 
GitLab