├── docker ├── nextpod.config.php ├── Dockerfile ├── README.md ├── docker-compose.yml ├── nextcloud │ └── sign-app.sh ├── justfile └── entrypoint.sh ├── .eslintrc.js ├── playwright ├── .gitignore ├── Makefile ├── README.md ├── package.json ├── .github │ └── workflows │ │ └── playwright.yml ├── package-lock.json ├── tests │ └── screenshots.spec.ts └── playwright.config.js ├── templates └── main.php ├── babel.config.js ├── img ├── screenshots │ ├── episodes.png │ ├── podcasts.png │ └── episode-description.png └── app.svg ├── stylelint.config.js ├── devenv.yaml ├── jsconfig.json ├── webpack.config.js ├── .envrc ├── .gitignore ├── tests ├── Helper │ ├── Writer │ │ └── TestWriter.php │ └── DatabaseTransaction.php ├── phpunit.xml ├── bootstrap.php ├── Integration │ └── AppTest.php └── Unit │ └── Core │ ├── EpisodeAction │ ├── EpisodeActionTest.php │ └── EpisodeActionReaderTest.php │ ├── SubscriptionChange │ ├── SubscriptionChangeReaderTest.php │ └── SubscriptionChangeRequestParserTest.php │ └── PodcastData │ └── PodcastDataTest.php ├── .php-cs-fixer.dist.php ├── .github └── workflows │ ├── ci-js.yml │ ├── format-check.yml │ ├── create_release.yml │ └── ci.yml ├── .php_cs.dist ├── term.kdl ├── lib ├── Core │ ├── SubscriptionChange │ │ ├── SubscriptionChangesReader.php │ │ ├── SubscriptionChange.php │ │ ├── SubscriptionChangeRequestParser.php │ │ └── SubscriptionChangeSaver.php │ ├── PodcastData │ │ ├── PodcastActionCounts.php │ │ ├── PodcastMetrics.php │ │ ├── PodcastMetricsReader.php │ │ ├── PodcastDataReader.php │ │ └── PodcastData.php │ └── EpisodeAction │ │ ├── EpisodeAction.php │ │ ├── EpisodeActionData.php │ │ ├── EpisodeActionReader.php │ │ ├── EpisodeActionSaver.php │ │ └── EpisodeActionExtraData.php ├── Db │ ├── SubscriptionChange │ │ ├── SubscriptionChangeEntity.php │ │ ├── SubscriptionChangeWriter.php │ │ ├── SubscriptionChangeRepository.php │ │ └── SubscriptionChangeMapper.php │ └── EpisodeAction │ │ ├── EpisodeActionWriter.php │ │ ├── EpisodeActionEntity.php │ │ ├── EpisodeActionRepository.php │ │ └── EpisodeActionMapper.php ├── Sections │ └── NextPodPersonal.php ├── Settings │ └── NextPodPersonal.php └── Controller │ ├── PageController.php │ ├── EpisodeActionController.php │ ├── SubscriptionChangeController.php │ └── PersonalSettingsController.php ├── composer.json ├── src ├── main.js ├── router │ └── index.js ├── components │ ├── SubscriptionListItem.vue │ └── ActionListItem.vue ├── views │ ├── HeaderNavigation.vue │ ├── Podcasts.vue │ └── Actions.vue ├── App.vue └── AppExample.vue ├── vendor ├── autoload.php └── bin │ └── phpunit ├── treefmt.toml ├── appinfo ├── routes.php ├── info.xml └── signature.json ├── devenv.nix ├── package.json ├── docs └── deployment.md ├── justfile ├── devenv.lock ├── README.md └── CHANGELOG.md /docker/nextpod.config.php: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const babelConfig = require('@nextcloud/babel-config') 2 | 3 | module.exports = babelConfig 4 | -------------------------------------------------------------------------------- /img/screenshots/episodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/nextcloud-nextpod/HEAD/img/screenshots/episodes.png -------------------------------------------------------------------------------- /img/screenshots/podcasts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/nextcloud-nextpod/HEAD/img/screenshots/podcasts.png -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | const stylelintConfig = require('@nextcloud/stylelint-config') 2 | 3 | module.exports = stylelintConfig 4 | -------------------------------------------------------------------------------- /img/screenshots/episode-description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/nextcloud-nextpod/HEAD/img/screenshots/episode-description.png -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://devenv.sh/devenv.schema.json 2 | inputs: 3 | nixpkgs: 4 | url: github:cachix/devenv-nixpkgs/rolling 5 | -------------------------------------------------------------------------------- /playwright/Makefile: -------------------------------------------------------------------------------- 1 | 2 | install-playwright: 3 | npm install && npm playwright:install 4 | 5 | screenshots: 6 | npx playwright test tests/screenshots.spec.ts --project=chromium --headed 7 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "jsx": "preserve" 6 | }, 7 | "exclude": ["node_modules"], 8 | "include": ["src/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpackConfig = require('@nextcloud/webpack-vue-config') 3 | 4 | webpackConfig.entry = { 5 | main: path.join(__dirname, 'src', 'main.js'), 6 | } 7 | 8 | module.exports = webpackConfig 9 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export DIRENV_WARN_TIMEOUT=20s 2 | 3 | eval "$(devenv direnvrc)" 4 | 5 | # The use_devenv function supports passing flags to the devenv command 6 | # For example: use devenv --impure --option services.postgres.enable:bool true 7 | use devenv 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/* 2 | tests/.phpunit.result.cache 3 | node_modules/ 4 | js/ 5 | /mydb.db* 6 | docker/nextcloud/certificates/* 7 | /*.gz 8 | 9 | # Devenv 10 | .devenv* 11 | devenv.local.nix 12 | 13 | # direnv 14 | .direnv 15 | 16 | # pre-commit 17 | .pre-commit-config.yaml 18 | -------------------------------------------------------------------------------- /playwright/README.md: -------------------------------------------------------------------------------- 1 | # Playwright 2 | 3 | ## Installation / Running 4 | 5 | This needs the docker container from the `docker` folder to be running. 6 | 7 | ```bash 8 | # Install dependencies 9 | make install-playwright 10 | 11 | # Create screenshots 12 | make screenshots 13 | ``` 14 | -------------------------------------------------------------------------------- /tests/Helper/Writer/TestWriter.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ./Unit 5 | 6 | 7 | ./Integration 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | getFinder() 12 | ->ignoreVCSIgnored(true) 13 | ->notPath('build') 14 | ->notPath('vendor') 15 | ->in(__DIR__); 16 | return $config; 17 | -------------------------------------------------------------------------------- /playwright/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e-tests", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "playwright:test": "playwright test", 8 | "playwright:install": "playwright install" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@playwright/test": "^1.31.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # https://hub.docker.com/_/nextcloud/ 2 | #FROM nextcloud:27-apache 3 | #FROM nextcloud:28-apache 4 | #FROM nextcloud:29-apache 5 | #FROM nextcloud:30-apache 6 | #FROM nextcloud:31-apache 7 | #FROM ghcr.io/pbek/nextcloud-docker-pre-apache:latest 8 | FROM ghcr.io/digital-blueprint/nextcloud-docker-pre-apache:latest 9 | 10 | COPY entrypoint.sh / 11 | 12 | RUN deluser www-data 13 | RUN useradd -u 1000 -ms /bin/bash www-data 14 | RUN usermod -a -G www-data www-data 15 | RUN mkdir /var/www/deploy 16 | -------------------------------------------------------------------------------- /tests/Helper/DatabaseTransaction.php: -------------------------------------------------------------------------------- 1 | get(IDBConnection::class); 13 | 14 | $db->beginTransaction(); 15 | } 16 | 17 | public function rollbackTransaction() { 18 | /* @var $db IDBConnection */ 19 | $db = OC::$server->get(IDBConnection::class); 20 | 21 | $db->rollBack(); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | addValidRoot(OC::$SERVERROOT . '/tests'); 12 | 13 | // Fix for "Autoload path not allowed: .../nextpod/tests/testcase.php" 14 | OC_App::loadApp('nextcloud-nextpod'); 15 | 16 | OC_Hook::clear(); 17 | -------------------------------------------------------------------------------- /.github/workflows/ci-js.yml: -------------------------------------------------------------------------------- 1 | name: "🚗 NPM build" 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | workflow_dispatch: 9 | 10 | env: 11 | APP_NAME: nextpod 12 | 13 | jobs: 14 | js: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | with: 21 | path: ${{ env.APP_NAME }} 22 | - name: Install NPM packages 23 | run: cd ${{ env.APP_NAME }} && npm ci 24 | - name: Build JS 25 | run: cd ${{ env.APP_NAME }} && npm run build 26 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ; 7 | 8 | return PhpCsFixer\Config::create() 9 | ->setRules([ 10 | '@Symfony' => true, 11 | '@PHP70Migration' => true, 12 | '@PHP71Migration' => true, 13 | '@PHP73Migration' => true, 14 | 'array_syntax' => ['syntax' => 'short'], 15 | 'yoda_style' => false, 16 | 'strict_comparison' => true, 17 | 'strict_param' => true, 18 | 'declare_strict_types' => true, 19 | ]) 20 | ->setRiskyAllowed(true) 21 | ->setFinder($finder) 22 | ; 23 | -------------------------------------------------------------------------------- /term.kdl: -------------------------------------------------------------------------------- 1 | // https://zellij.dev/documentation/creating-a-layout 2 | layout { 3 | pane split_direction="vertical" size="65%" { 4 | pane { 5 | command "lazygit" 6 | focus true 7 | } 8 | pane cwd="docker" command="docker" { 9 | args "compose" "up" 10 | start_suspended false 11 | } 12 | } 13 | pane split_direction="vertical" size="35%" { 14 | pane { 15 | command "npm" 16 | args "install" 17 | } 18 | pane { 19 | command "npm" 20 | args "run" "watch" 21 | } 22 | pane cwd="docker" 23 | } 24 | pane size=1 borderless=true { 25 | plugin location="zellij:status-bar" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/Core/SubscriptionChange/SubscriptionChangesReader.php: -------------------------------------------------------------------------------- 1 | url = $url; 16 | $this->isSubscribed = $isSubscribed; 17 | } 18 | 19 | /** 20 | * @return bool 21 | */ 22 | public function isSubscribed(): bool { 23 | return $this->isSubscribed; 24 | } 25 | 26 | /** 27 | * @return string 28 | */ 29 | public function getUrl(): string { 30 | return $this->url; 31 | } 32 | 33 | public function __toString() : String { 34 | return $this->url; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Integration/AppTest.php: -------------------------------------------------------------------------------- 1 | container = $app->getContainer(); 21 | } 22 | 23 | public function testAppInstalled() { 24 | $appManager = $this->container->query('OCP\App\IAppManager'); 25 | $this->assertTrue($appManager->isInstalled('nextpod')); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pbek/nextpod", 3 | "type": "project", 4 | "license": "AGPLv3", 5 | "authors": [ 6 | { 7 | "name": "Patrizio Bekerle", 8 | "email": "patrizio@bekerle.com" 9 | }, 10 | { 11 | "name": "thrillfall", 12 | "email": "thrillfall@disroot.org" 13 | }, 14 | { 15 | "name": "JohnUfUs", 16 | "email": "jonofus@flueren.eu" 17 | } 18 | ], 19 | "require-dev": { 20 | "phpunit/phpunit": "^9", 21 | "vimeo/psalm": "^4.29", 22 | "friendsofphp/php-cs-fixer": "^3.12", 23 | "phpstan/phpstan": "^1.8", 24 | "nextcloud/coding-standard": "^1.1" 25 | }, 26 | "config": { 27 | "platform": { 28 | "php": "7.4.33" 29 | } 30 | }, 31 | "require": { 32 | "ext-simplexml": "*" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Unit/Core/EpisodeAction/EpisodeActionTest.php: -------------------------------------------------------------------------------- 1 | 'podcast1', 15 | 'episode' => 'episode1', 16 | 'timestamp' => '2021-10-07T13:27:14', 17 | 'guid' => 'podcast1guid', 18 | 'position' => 120, 19 | 'started' => 15, 20 | 'total' => 500, 21 | 'action' => 'PLAY', 22 | ]; 23 | $this->assertSame($expected, $episodeAction->toArray()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/Db/SubscriptionChange/SubscriptionChangeEntity.php: -------------------------------------------------------------------------------- 1 | addType('id', 'integer'); 19 | $this->addType('subscribed', 'boolean'); 20 | } 21 | 22 | /** 23 | * @return array 24 | */ 25 | public function jsonSerialize(): array { 26 | return [ 27 | 'id' => $this->id, 28 | 'url' => $this->url, 29 | 'subscribed' => $this->subscribed, 30 | 'updated' => $this->updated, 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/Sections/NextPodPersonal.php: -------------------------------------------------------------------------------- 1 | l = $l; 15 | $this->urlGenerator = $urlGenerator; 16 | } 17 | 18 | public function getIcon(): string { 19 | return $this->urlGenerator->imagePath('core', 'actions/settings-dark.svg'); 20 | } 21 | 22 | public function getID(): string { 23 | return 'nextpod'; 24 | } 25 | 26 | public function getName(): string { 27 | return $this->l->t('NextPod'); 28 | } 29 | 30 | public function getPriority(): int { 31 | return 198; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { generateFilePath } from '@nextcloud/router' 2 | import { getRequestToken } from '@nextcloud/auth' 3 | import { translate as t, translatePlural as n } from '@nextcloud/l10n' 4 | 5 | import Vue from 'vue' 6 | import App from './App' 7 | import router from './router/index.js' 8 | 9 | // CSP config for webpack dynamic chunk loading 10 | // eslint-disable-next-line 11 | __webpack_nonce__ = btoa(getRequestToken()) 12 | 13 | // eslint-disable-next-line 14 | __webpack_public_path__ = generateFilePath(appName, '', 'js/') 15 | 16 | Vue.mixin({ methods: { t, n } }) 17 | 18 | // https://nextcloud-vue-components.netlify.app/#/Introduction 19 | Vue.prototype.OC = window.OC 20 | Vue.prototype.OCA = window.OCA 21 | 22 | export default new Vue({ 23 | el: '#content', 24 | router, 25 | render: h => h(App), 26 | }) 27 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Nextcloud Development Environment 2 | 3 | ## Installation / Running 4 | 5 | ```bash 6 | make build 7 | docker compose up 8 | ``` 9 | 10 | Afterward you should be able to open (admin/admin) to 11 | log in to your Nextcloud instance. 12 | 13 | Open to access the database with sqlite-web. 14 | 15 | ## Check nextcloud.log 16 | 17 | For debugging, you can show the `nextcloud.log`: 18 | 19 | ```bash 20 | make show-log 21 | ``` 22 | 23 | There also is a [logging web interface](http://localhost:8081/index.php/settings/admin/logging). 24 | 25 | ## Tip 26 | 27 | In case something is broken try to reset the container: 28 | 29 | ```bash 30 | docker compose build; docker compose down; docker volume rm nextcloud-nextpod_nextcloud 31 | ``` 32 | -------------------------------------------------------------------------------- /playwright/.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Install Playwright Browsers 19 | run: npx playwright install --with-deps 20 | - name: Run Playwright tests 21 | run: npx playwright test 22 | - uses: actions/upload-artifact@v3 23 | if: always() 24 | with: 25 | name: playwright-report 26 | path: playwright-report/ 27 | retention-days: 30 28 | -------------------------------------------------------------------------------- /.github/workflows/format-check.yml: -------------------------------------------------------------------------------- 1 | name: 📄 Check formatting 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - release 7 | tags-ignore: 8 | - "*" 9 | pull_request: 10 | workflow_dispatch: 11 | 12 | jobs: 13 | format-check: 14 | name: 📄 Check code formatting with "just format-all" 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 🧰 Checkout code 18 | uses: actions/checkout@v5 19 | - name: ⚙️ Install Nix 20 | uses: cachix/install-nix-action@v31 21 | with: 22 | nix_path: nixpkgs=channel:nixos-unstable 23 | - name: 🔒 Cache dependencies 24 | uses: cachix/cachix-action@v16 25 | with: 26 | name: devenv 27 | - name: 🔧 Install devenv.sh 28 | run: nix profile install nixpkgs#devenv 29 | - name: 🌳 Format code 30 | run: devenv shell "just format-all" 31 | -------------------------------------------------------------------------------- /vendor/autoload.php: -------------------------------------------------------------------------------- 1 | episodeActionMapper = $episodeActionMapper; 18 | } 19 | 20 | /** 21 | * @throws Exception 22 | */ 23 | public function save(EpisodeActionEntity $episodeActionEntity): EpisodeActionEntity { 24 | return $this->episodeActionMapper->insert($episodeActionEntity); 25 | } 26 | 27 | /** 28 | * @throws Exception 29 | */ 30 | public function update(EpisodeActionEntity $episodeActionEntity) { 31 | return $this->episodeActionMapper->update($episodeActionEntity); 32 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/Settings/NextPodPersonal.php: -------------------------------------------------------------------------------- 1 | addAllowedImageDomain('*') 19 | ->addAllowedMediaDomain('*') 20 | ->addAllowedConnectDomain('*'); 21 | $response->setContentSecurityPolicy($csp); 22 | 23 | return $response; 24 | } 25 | 26 | public function getSection(): string { 27 | return 'nextpod'; 28 | } 29 | 30 | public function getPriority(): int { 31 | return 198; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/Db/SubscriptionChange/SubscriptionChangeWriter.php: -------------------------------------------------------------------------------- 1 | subscriptionChangeMapper = $subscriptionChangeMapper; 16 | } 17 | 18 | 19 | public function purge() { 20 | foreach ($this->subscriptionChangeMapper->findAll() as $entity) { 21 | $this->subscriptionChangeMapper->delete($entity); 22 | } 23 | } 24 | 25 | public function create(SubscriptionChangeEntity $subscriptionChangeEntity): SubscriptionChangeEntity { 26 | return $this->subscriptionChangeMapper->insert($subscriptionChangeEntity); 27 | } 28 | 29 | public function update(SubscriptionChangeEntity $subscriptionChangeEntity): SubscriptionChangeEntity { 30 | return $this->subscriptionChangeMapper->update($subscriptionChangeEntity); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/Core/SubscriptionChange/SubscriptionChangeRequestParser.php: -------------------------------------------------------------------------------- 1 | subscriptionChangeReader = $subscriptionChangeReader; 16 | } 17 | 18 | /** 19 | * @param array $urlsSubscribed 20 | * @param array $urlsUnsubscribed 21 | * 22 | * @return SubscriptionChange[] 23 | */ 24 | public function createSubscriptionChangeList(array $urlsSubscribed, array $urlsUnsubscribed): array { 25 | $urlsToSubscribe = $this->subscriptionChangeReader::mapToSubscriptionsChanges($urlsSubscribed, true); 26 | $urlsToDelete = $this->subscriptionChangeReader::mapToSubscriptionsChanges($urlsUnsubscribed, false); 27 | 28 | /** @var SubscriptionChange[] $subscriptionChanges */ 29 | return array_merge($urlsToSubscribe, $urlsToDelete); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { generateUrl } from '@nextcloud/router' 2 | import Router from 'vue-router' 3 | import Vue from 'vue' 4 | 5 | const Actions = () => import('../views/Actions') 6 | const Podcasts = () => import('../views/Podcasts') 7 | 8 | const baseTitle = document.title 9 | 10 | Vue.use(Router) 11 | 12 | const router = new Router({ 13 | mode: 'history', 14 | // if index.php is in the url AND we got this far, then it's working: 15 | // let's keep using index.php in the url 16 | base: generateUrl('/apps/nextpod'), 17 | linkActiveClass: 'active', 18 | routes: [ 19 | { 20 | path: '/', 21 | component: Actions, 22 | name: 'actions' 23 | }, 24 | { 25 | path: '/podcasts', 26 | component: Podcasts, 27 | name: 'podcasts', 28 | }, 29 | { 30 | path: '/actions', 31 | component: Actions, 32 | name: 'actions', 33 | }, 34 | ], 35 | }) 36 | 37 | router.afterEach((to) => { 38 | const rootTitle = to.meta.rootTitle?.(to) 39 | 40 | if (rootTitle) { 41 | document.title = `${rootTitle} - ${baseTitle}` 42 | } else { 43 | document.title = baseTitle 44 | } 45 | }) 46 | 47 | export default router 48 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: nextcloud-nextpod 2 | 3 | services: 4 | app: 5 | build: . 6 | ports: 7 | - 8081:80 8 | environment: 9 | - NEXTCLOUD_ADMIN_USER=admin 10 | - NEXTCLOUD_ADMIN_PASSWORD=admin 11 | - SQLITE_DATABASE=mydb 12 | - NEXTCLOUD_TRUSTED_DOMAINS=localhost 127.0.0.1 13 | volumes: 14 | - nextcloud:/var/www/html 15 | - ..:/var/www/html/custom_apps/nextpod 16 | - ./nextpod.config.php:/var/www/html/config/nextpod.config.php 17 | - ./nextcloud/certificates:/var/www/.nextcloud/certificates 18 | - ./nextcloud/sign-app.sh:/var/www/sign-app.sh 19 | 20 | # https://github.com/coleifer/sqlite-web 21 | sqlite-web: 22 | image: ghcr.io/coleifer/sqlite-web:latest 23 | ports: 24 | - 8082:8080 25 | volumes: 26 | - sqlite-web:/data 27 | - nextcloud:/nextcloud 28 | environment: 29 | - SQLITE_DATABASE=/nextcloud/data/mydb.db 30 | 31 | # phpliteadmin: 32 | # image: shadowcodex/phpliteadmin 33 | # ports: 34 | # - 8082:2015 35 | # volumes: 36 | # - nextcloud:/db 37 | 38 | volumes: 39 | nextcloud: 40 | sqlite-web: 41 | -------------------------------------------------------------------------------- /lib/Db/SubscriptionChange/SubscriptionChangeRepository.php: -------------------------------------------------------------------------------- 1 | subscriptionChangeMapper = $subscriptionChangeMapper; 16 | } 17 | 18 | public function findAll() : array { 19 | return $this->subscriptionChangeMapper->findAll(); 20 | } 21 | 22 | public function findByUrl(string $episode, string $userId): ?SubscriptionChangeEntity { 23 | return $this->subscriptionChangeMapper->findByUrl($episode, $userId); 24 | } 25 | 26 | public function findAllSubscribed(\DateTime $sinceTimestamp, string $userId) { 27 | return $this->subscriptionChangeMapper->findAllSubscriptionState(true, $sinceTimestamp, $userId); 28 | } 29 | 30 | public function findAllUnSubscribed(\DateTime $sinceTimestamp, string $userId) { 31 | return $this->subscriptionChangeMapper->findAllSubscriptionState(false, $sinceTimestamp, $userId); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/Core/PodcastData/PodcastActionCounts.php: -------------------------------------------------------------------------------- 1 | delete++; 22 | break; 23 | case 'download': $this->download++; 24 | break; 25 | case 'flattr': $this->flattr++; 26 | break; 27 | case 'new': $this->new++; 28 | break; 29 | case 'play': $this->play++; 30 | break; 31 | } 32 | } 33 | 34 | /** 35 | * @return array 36 | */ 37 | public function toArray(): array { 38 | return [ 39 | 'delete' => $this->delete, 40 | 'download' => $this->download, 41 | 'flattr' => $this->flattr, 42 | 'new' => $this->new, 43 | 'play' => $this->play, 44 | ]; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function jsonSerialize(): mixed { 51 | return $this->toArray(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Unit/Core/SubscriptionChange/SubscriptionChangeReaderTest.php: -------------------------------------------------------------------------------- 1 | assertCount(2, $subscriptionChange); 14 | $this->assertSame("https://feeds.megaphone.fm/HSW8286374095", $subscriptionChange[0]->getUrl()); 15 | $this->assertSame("https://feeds.megaphone.fm/another", $subscriptionChange[1]->getUrl()); 16 | } 17 | 18 | 19 | public function testNonUrisAreOmmited(): void { 20 | $subscriptionChange = SubscriptionChangesReader::mapToSubscriptionsChanges([ 21 | "https://feeds.megaphone.fm/HSW8286374095", 22 | "antennapod_local:content://com.android.externalstorage.documents/tree/home:podcast" 23 | ], true); 24 | $this->assertCount(1, $subscriptionChange); 25 | $this->assertSame("https://feeds.megaphone.fm/HSW8286374095", $subscriptionChange[0]->getUrl()); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /tests/Unit/Core/SubscriptionChange/SubscriptionChangeRequestParserTest.php: -------------------------------------------------------------------------------- 1 | createSubscriptionChangeList(["https://feeds.simplecast.com/54nAGcIl", "https://feeds.simplecast.com/another"], ["https://i.am-removed/GcIl"]); 18 | $this->assertCount(3, $subscriptionChanges); 19 | $this->assertSame("https://feeds.simplecast.com/54nAGcIl", $subscriptionChanges[0]->getUrl()); 20 | $this->assertSame("https://feeds.simplecast.com/another", $subscriptionChanges[1]->getUrl()); 21 | $this->assertSame("https://i.am-removed/GcIl", $subscriptionChanges[2]->getUrl()); 22 | $this->assertTrue($subscriptionChanges[0]->isSubscribed()); 23 | $this->assertFalse($subscriptionChanges[2]->isSubscribed()); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /treefmt.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/numtide/treefmt 2 | # https://github.com/numtide/treefmt-nix 3 | 4 | on-unmatched = "info" 5 | excludes = ["config/secrets/**/*", "vendor/**"] 6 | 7 | [formatter.prettier] 8 | command = "prettier" 9 | options = ["--write"] 10 | includes = ["*.md", "*.yaml", "*.yml", "*.json"] 11 | excludes = ["appinfo/signature.json"] 12 | 13 | [formatter.just] 14 | command = "just" 15 | options = ["--fmt", "--unstable", "-f"] 16 | includes = ["justfile"] 17 | 18 | [formatter.taplo] 19 | command = "taplo" 20 | includes = ["*.toml"] 21 | options = ["format"] 22 | 23 | [formatter.nixfmt-rfc-style] 24 | command = "nixfmt" 25 | includes = ["*.nix"] 26 | 27 | # Statix doesn't support formatting single files, see https://github.com/oppiliappan/statix/issues/69 28 | # Workaround: https://github.com/numtide/treefmt/issues/241#issuecomment-1614563462 29 | [formatter.statix] 30 | command = "bash" 31 | options = ["-euc", "for file in \"$@\"; do statix fix \"$file\"; done"] 32 | includes = ["*.nix"] 33 | 34 | [formatter.php-cs-fixer] 35 | command = "php-cs-fixer" 36 | excludes = ["config/bundles.php"] 37 | includes = ["*.php"] 38 | options = ["fix", "--config", "./.php-cs-fixer.dist.php"] 39 | 40 | [formatter.shfmt] 41 | command = "shfmt" 42 | includes = ["*.sh", "*.bash", "*.envrc", "*.envrc.*"] 43 | options = ["-s", "-w", "-i", "4"] 44 | -------------------------------------------------------------------------------- /lib/Controller/PageController.php: -------------------------------------------------------------------------------- 1 | appManager = $appManager; 25 | $this->initialState = $initialState; 26 | } 27 | 28 | /** 29 | * @NoAdminRequired 30 | * @NoCSRFRequired 31 | */ 32 | public function index(): TemplateResponse { 33 | $this->initialState->provideInitialState('has-notes-app', $this->appManager->isEnabledForUser('notes') === true); 34 | $response = new TemplateResponse('nextpod', 'main', []); 35 | 36 | // Set CSP to allow images and media from anywhere 37 | $csp = new ContentSecurityPolicy(); 38 | $csp->addAllowedImageDomain('*') 39 | ->addAllowedMediaDomain('*') 40 | ->addAllowedConnectDomain('*'); 41 | $response->setContentSecurityPolicy($csp); 42 | 43 | return $response; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /appinfo/routes.php: -------------------------------------------------------------------------------- 1 | OCA\NextPod\Controller\PageController->index() 7 | * 8 | * The controller class has to be registered in the application.php file since 9 | * it's instantiated in there 10 | */ 11 | return [ 12 | 'routes' => [ 13 | ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], 14 | ['name' => 'page#index', 'url' => '/podcasts', 'verb' => 'GET', 'postfix' => 'podcasts'], 15 | ['name' => 'page#index', 'url' => '/actions', 'verb' => 'GET', 'postfix' => 'actions'], 16 | 17 | ['name' => 'episode_action#create', 'url' => '/episode_action/create', 'verb' => 'POST'], 18 | ['name' => 'episode_action#list', 'url' => '/episode_action', 'verb' => 'GET'], 19 | 20 | ['name' => 'subscription_change#list', 'url' => '/subscriptions', 'verb' => 'GET'], 21 | ['name' => 'subscription_change#create', 'url' => '/subscription_change/create', 'verb' => 'POST'], 22 | ['name' => 'personal_settings#metrics', 'url' => '/personal_settings/metrics', 'verb' => 'GET'], 23 | ['name' => 'personal_settings#podcastData', 'url' => '/personal_settings/podcast_data', 'verb' => 'GET'], 24 | ['name' => 'personal_settings#actionExtraData', 'url' => '/personal_settings/action_extra_data', 'verb' => 'GET'], 25 | ] 26 | ]; 27 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | ... 4 | }: 5 | 6 | { 7 | # https://devenv.sh/supported-languages/php/ 8 | languages.php.enable = true; 9 | 10 | # https://devenv.sh/supported-languages/javascript/ 11 | languages.javascript.enable = true; 12 | languages.javascript.npm.enable = true; 13 | 14 | # https://devenv.sh/packages/ 15 | packages = with pkgs; [ 16 | just 17 | zellij 18 | libxml2 # for xmllint 19 | ]; 20 | 21 | enterShell = '' 22 | echo "🛠️ Nextpod dev shell" 23 | echo "🐘 PHP version: $(php --version | head -n 1)" 24 | echo "📦 Node version: $(node --version | head -n 1)" 25 | ''; 26 | 27 | # https://devenv.sh/git-hooks/ 28 | git-hooks.hooks = { 29 | # https://devenv.sh/reference/options/#git-hookshookstreefmt 30 | # https://github.com/numtide/treefmt 31 | # https://github.com/numtide/treefmt-nix 32 | treefmt = { 33 | enable = true; 34 | settings.formatters = with pkgs; [ 35 | nodePackages.prettier 36 | shfmt 37 | nixfmt-rfc-style 38 | statix 39 | taplo 40 | php83Packages.php-cs-fixer 41 | ]; 42 | }; 43 | 44 | # https://devenv.sh/reference/options/#git-hookshooksdeadnix 45 | # https://github.com/astro/deadnix 46 | deadnix = { 47 | enable = true; 48 | settings = { 49 | edit = true; # Allow to edit the file if it is not formatted 50 | }; 51 | }; 52 | }; 53 | 54 | # See full reference at https://devenv.sh/reference/options/ 55 | } 56 | -------------------------------------------------------------------------------- /lib/Core/PodcastData/PodcastMetrics.php: -------------------------------------------------------------------------------- 1 | url = $url; 20 | $this->actionCounts = $actionCounts ?? new PodcastActionCounts; 21 | $this->listenedSeconds = $listenedSeconds; 22 | } 23 | 24 | /** 25 | * @return string 26 | */ 27 | public function getUrl(): string { 28 | return $this->url; 29 | } 30 | 31 | /** 32 | * @return PodcastActionCounts 33 | */ 34 | public function getActionCounts(): PodcastActionCounts { 35 | return $this->actionCounts; 36 | } 37 | 38 | /** 39 | * @return int 40 | */ 41 | public function getListenedSeconds(): int { 42 | return $this->listenedSeconds; 43 | } 44 | 45 | /** 46 | * @param int $seconds 47 | */ 48 | public function addListenedSeconds(int $seconds): void { 49 | $this->listenedSeconds += $seconds; 50 | } 51 | 52 | /** 53 | * @return array 54 | */ 55 | public function toArray(): array { 56 | return 57 | [ 58 | 'url' => $this->url, 59 | 'listenedSeconds' => $this->listenedSeconds, 60 | 'actionCounts' => $this->actionCounts->toArray(), 61 | ]; 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | public function jsonSerialize(): array { 68 | return $this->toArray(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/Db/EpisodeAction/EpisodeActionEntity.php: -------------------------------------------------------------------------------- 1 | addType('id', 'integer'); 45 | $this->addType('started', 'integer'); 46 | $this->addType('position', 'integer'); 47 | $this->addType('total', 'integer'); 48 | $this->addType('timestampEpoch', 'integer'); 49 | } 50 | 51 | public function jsonSerialize(): array { 52 | return [ 53 | 'id' => $this->id, 54 | 'podcast' => $this->podcast, 55 | 'episode' => $this->episode, 56 | 'guid' => $this->guid, 57 | 'action' => $this->action, 58 | 'position' => $this->position, 59 | 'started' => $this->started, 60 | 'total' => $this->total, 61 | 'timestamp' => $this->timestampEpoch, 62 | ]; 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextpod", 3 | "description": "Visualization of podcast subscriptions and episode downloads from GPodderSync", 4 | "version": "1.0.0", 5 | "author": "Patrizio Bekerle ", 6 | "contributors": [ 7 | "Thrillfall ", 8 | "Kalle Fagerberg " 9 | ], 10 | "bugs": { 11 | "url": "https://github.com/pbek/nextcloud-nextpod/issues" 12 | }, 13 | "repository": { 14 | "url": "https://github.com/pbek/nextcloud-nextpod", 15 | "type": "git" 16 | }, 17 | "homepage": "https://github.com/pbek/nextcloud-nextpod", 18 | "private": true, 19 | "scripts": { 20 | "build": "webpack --node-env production --progress", 21 | "dev": "webpack --node-env development --progress", 22 | "watch": "webpack --node-env development --progress --watch", 23 | "serve": "webpack --node-env development serve --progress", 24 | "lint": "eslint --ext .js,.vue src", 25 | "lint:fix": "eslint --ext .js,.vue src --fix", 26 | "stylelint": "stylelint css/*.css css/*.scss src/**/*.scss src/**/*.vue", 27 | "stylelint:fix": "stylelint css/*.css css/*.scss src/**/*.scss src/**/*.vue --fix" 28 | }, 29 | "dependencies": { 30 | "@nextcloud/auth": "^2.0.0", 31 | "@nextcloud/axios": "^1.11.0", 32 | "@nextcloud/dialogs": "^3.2.0", 33 | "@nextcloud/router": "^2.0.0", 34 | "@nextcloud/vue": "^7.7.1", 35 | "turndown": "^7.1.1", 36 | "vue": "^2.7.10", 37 | "vue-material-design-icons": "^5.1.2", 38 | "vue-router": "^3.6.5" 39 | }, 40 | "browserslist": [ 41 | "extends @nextcloud/browserslist-config" 42 | ], 43 | "engines": { 44 | "node": "^14.0.0", 45 | "npm": "^7.0.0" 46 | }, 47 | "devDependencies": { 48 | "@nextcloud/babel-config": "^1.0.0", 49 | "@nextcloud/browserslist-config": "^2.2.0", 50 | "@nextcloud/eslint-config": "^8.0.0", 51 | "@nextcloud/stylelint-config": "^2.1.2", 52 | "@nextcloud/webpack-vue-config": "^5.2.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /appinfo/info.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | nextpod 5 | NextPod 6 | Visualization of podcast subscriptions and episode downloads from GPodderSync 7 | 12 | 0.7.8 13 | agpl 14 | Patrizio Bekerle 15 | NextPod 16 | integration 17 | multimedia 18 | https://github.com/pbek/nextcloud-nextpod 19 | https://github.com/pbek/nextcloud-nextpod.git 20 | https://github.com/pbek/nextcloud-nextpod/issues 21 | 22 | https://github.com/pbek/nextcloud-nextpod 23 | 24 | 25 | 26 | 27 | 28 | https://raw.githubusercontent.com/pbek/nextcloud-nextpod/main/img/screenshots/episodes.png 29 | https://raw.githubusercontent.com/pbek/nextcloud-nextpod/main/img/screenshots/episode-description.png 30 | https://raw.githubusercontent.com/pbek/nextcloud-nextpod/main/img/screenshots/podcasts.png 31 | 32 | 33 | NextPod 34 | nextpod.page.index 35 | app.svg 36 | 77 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /docker/nextcloud/sign-app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ######################################################################## 3 | # Creates the signature.json for the Nextcloud application 4 | ######################################################################## 5 | 6 | APP_NAME=nextpod 7 | APP_SOURCE=/var/www/html/custom_apps/${APP_NAME} 8 | APP_DEST=/var/www/deploy/${APP_NAME} 9 | CERT_PATH=/var/www/.nextcloud/certificates 10 | DEPLOYMENT_FILE=${APP_SOURCE}/${APP_NAME}-nc.tar.gz 11 | 12 | rm -rf ${APP_DEST} && 13 | mkdir ${APP_DEST} && 14 | rsync -a --exclude .git* --exclude .gitlab-ci* --exclude .github --exclude screenshot* --exclude docs \ 15 | --exclude tests --exclude playwright --exclude vendor --exclude package.* --exclude Makefile \ 16 | --exclude *.db* --exclude docker --exclude *.phar --exclude *.gz --exclude node_modules \ 17 | --exclude .idea --exclude package-lock.json --exclude package.json --exclude composer.* \ 18 | --exclude=babel.config.js --exclude=.drone.yml --exclude=.eslintignore --exclude=.eslintrc.js \ 19 | --exclude=.gitattributes --exclude=jest.config.js --exclude=.l10nignore --exclude=mkdocs.yml \ 20 | --exclude=.php_cs.dist --exclude=.php_cs.cache --exclude=CHANGELOG.md --exclude=README.md \ 21 | --exclude=src --exclude=.stylelintignore --exclude=stylelint.config.js --exclude=.tx \ 22 | --exclude=releases --exclude=webpack.*.js --exclude=jsconfig.json \ 23 | --exclude=.envrc --exclude .direnv \ 24 | --exclude devenv.* --exclude treefmt.toml --exclude term.kdl --exclude phpunit.* \ 25 | --exclude psalm.* --exclude phpstan.* --exclude justfile --exclude .devenv \ 26 | --exclude .devenv.* --exclude .pre-commit-config.* \ 27 | ${APP_SOURCE}/ ${APP_DEST} && 28 | su -m -c "./occ integrity:sign-app \ 29 | --privateKey=${CERT_PATH}/${APP_NAME}.key \ 30 | --certificate=${CERT_PATH}/${APP_NAME}.crt --path=${APP_DEST}" www-data && 31 | cp ${APP_DEST}/appinfo/signature.json ${APP_SOURCE}/appinfo && 32 | tar cz ${APP_DEST}/.. >${DEPLOYMENT_FILE} && 33 | echo "\nSignature for your app archive:\n" && 34 | openssl dgst -sha512 -sign ${CERT_PATH}/${APP_NAME}.key ${DEPLOYMENT_FILE} | openssl base64 && 35 | echo 36 | -------------------------------------------------------------------------------- /lib/Db/SubscriptionChange/SubscriptionChangeMapper.php: -------------------------------------------------------------------------------- 1 | db->getQueryBuilder(); 19 | 20 | $qb->select('*') 21 | ->from($this->getTableName()) 22 | ->where( 23 | $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) 24 | ); 25 | 26 | return $this->findEntities($qb); 27 | } 28 | 29 | public function findByUrl(string $url, string $userId): ?SubscriptionChangeEntity { 30 | $qb = $this->db->getQueryBuilder(); 31 | 32 | $qb->select('*') 33 | ->from($this->getTableName()) 34 | ->where( 35 | $qb->expr()->eq('url', $qb->createNamedParameter($url)) 36 | ) 37 | ->andWhere( 38 | $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) 39 | ); 40 | 41 | try { 42 | return $this->findEntity($qb); 43 | } catch (DoesNotExistException $e) { 44 | } catch (MultipleObjectsReturnedException $e) { 45 | } 46 | return null; 47 | } 48 | 49 | public function remove(SubscriptionChangeEntity $subscriptionChangeEntity) { 50 | $this->delete($subscriptionChangeEntity); 51 | } 52 | 53 | public function findAllSubscriptionState(bool $subscribed, \DateTime $sinceTimestamp, string $userId) { 54 | $qb = $this->db->getQueryBuilder(); 55 | 56 | $qb->select('url') 57 | ->from($this->getTableName()) 58 | ->where( 59 | $qb->expr()->eq('subscribed', $qb->createNamedParameter($subscribed, IQueryBuilder::PARAM_BOOL)) 60 | )->andWhere( 61 | $qb->expr()->gt('updated', $qb->createNamedParameter($sinceTimestamp, IQueryBuilder::PARAM_DATE)) 62 | ) 63 | ->andWhere( 64 | $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) 65 | ); 66 | 67 | return $this->findEntities($qb); 68 | } 69 | 70 | 71 | } 72 | -------------------------------------------------------------------------------- /playwright/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e-tests", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "e2e-tests", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "@playwright/test": "^1.31.1" 13 | } 14 | }, 15 | "node_modules/@playwright/test": { 16 | "version": "1.31.1", 17 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.31.1.tgz", 18 | "integrity": "sha512-IsytVZ+0QLDh1Hj83XatGp/GsI1CDJWbyDaBGbainsh0p2zC7F4toUocqowmjS6sQff2NGT3D9WbDj/3K2CJiA==", 19 | "dev": true, 20 | "dependencies": { 21 | "@types/node": "*", 22 | "playwright-core": "1.31.1" 23 | }, 24 | "bin": { 25 | "playwright": "cli.js" 26 | }, 27 | "engines": { 28 | "node": ">=14" 29 | }, 30 | "optionalDependencies": { 31 | "fsevents": "2.3.2" 32 | } 33 | }, 34 | "node_modules/@types/node": { 35 | "version": "18.14.2", 36 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.2.tgz", 37 | "integrity": "sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==", 38 | "dev": true 39 | }, 40 | "node_modules/fsevents": { 41 | "version": "2.3.2", 42 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 43 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 44 | "dev": true, 45 | "hasInstallScript": true, 46 | "optional": true, 47 | "os": [ 48 | "darwin" 49 | ], 50 | "engines": { 51 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 52 | } 53 | }, 54 | "node_modules/playwright-core": { 55 | "version": "1.31.1", 56 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.31.1.tgz", 57 | "integrity": "sha512-JTyX4kV3/LXsvpHkLzL2I36aCdml4zeE35x+G5aPc4bkLsiRiQshU5lWeVpHFAuC8xAcbI6FDcw/8z3q2xtJSQ==", 58 | "dev": true, 59 | "bin": { 60 | "playwright": "cli.js" 61 | }, 62 | "engines": { 63 | "node": ">=14" 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/Controller/EpisodeActionController.php: -------------------------------------------------------------------------------- 1 | episodeActionRepository = $episodeActionRepository; 30 | $this->userId = $UserId ?? ''; 31 | $this->episodeActionSaver = $episodeActionSaver; 32 | $this->request = $request; 33 | } 34 | 35 | /** 36 | * 37 | * @NoAdminRequired 38 | * @NoCSRFRequired 39 | * 40 | * @return JSONResponse 41 | */ 42 | public function create(): JSONResponse { 43 | 44 | $episodeActionsArray = $this->filterEpisodesFromRequestParams($this->request->getParams()); 45 | $this->episodeActionSaver->saveEpisodeActions($episodeActionsArray, $this->userId); 46 | 47 | return new JSONResponse(["timestamp" => time()]); 48 | } 49 | 50 | /** 51 | * @NoAdminRequired 52 | * @NoCSRFRequired 53 | * 54 | * @param int $since 55 | * @return JSONResponse 56 | */ 57 | public function list(int $since = 0): JSONResponse { 58 | $episodeActions = $this->episodeActionRepository->findAll($since, $this->userId); 59 | $untypedEpisodeActionData = []; 60 | 61 | foreach ($episodeActions as $episodeAction) { 62 | $untypedEpisodeActionData[] = $episodeAction->toArray(); 63 | } 64 | 65 | return new JSONResponse([ 66 | "actions" => $untypedEpisodeActionData, 67 | "timestamp" => time() 68 | ]); 69 | } 70 | 71 | /** 72 | * @param array $data 73 | * @return array $episodeActionsArray 74 | */ 75 | public function filterEpisodesFromRequestParams(array $data): array { 76 | return array_filter($data, "is_numeric", ARRAY_FILTER_USE_KEY); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | # Deploying to the app stores 2 | 3 | ## Nextcloud 4 | 5 | ### Prerequisites 6 | 7 | - Copy your app certificate files to `./docker/nextcloud/certificates` 8 | 9 | ### Signing and releasing 10 | 11 | - Make sure the version in `appinfo/info.xml` and the `CHANGELOG.md` are updated 12 | - Build the app with `just build-release` 13 | - Sign the app with `cd docker && just sign-app` 14 | - You should now have a `nextpod-nc.tar.gz` in your git directory 15 | - Check the content of the archive for unwanted files (you can exclude more files in 16 | `docker/nextcloud/sign-app.sh`) 17 | - Commit and push your changes to GitHub 18 | - Create a tag with `just create-tag` 19 | - Create a new release on [NextPod releases](https://github.com/pbek/nextcloud-nextpod/releases) 20 | with the version like `v0.1.0` as _Tag name_ and _Release title_ and the changelog text of the current 21 | release as _Release notes_ 22 | - Alternatively you can push to the `release` branch and the GitHub action will create 23 | a draft release for you 24 | - In any case you also need to upload `nextpod-nc.tar.gz` to the release and get its url 25 | like `https://github.com/pbek/nextcloud-nextpod/releases/download/v0.1.0/nextpod-nc.tar.gz` 26 | - Take the text from _Signature for your app archive_, which was printed by the sign-app command and 27 | release the app at [Upload app release](https://apps.nextcloud.com/developer/apps/releases/new) 28 | - You need the download link to `nextpod-nc.tar.gz` from the GitHub release 29 | - The new version should then appear on the [NextPod store page](https://apps.nextcloud.com/apps/nextpod) 30 | 31 | 48 | -------------------------------------------------------------------------------- /lib/Core/PodcastData/PodcastMetricsReader.php: -------------------------------------------------------------------------------- 1 | subscriptionChangeRepository = $subscriptionChangeRepository; 23 | $this->episodeActionRepository = $episodeActionRepository; 24 | } 25 | 26 | /** 27 | * @param string $userId 28 | * 29 | * @return PodcastMetrics[] 30 | */ 31 | public function metrics(string $userId): array { 32 | $episodeActions = $this->episodeActionRepository->findAll(0, $userId); 33 | 34 | $metricsPerUrl = array(); 35 | foreach ($episodeActions as $ep) { 36 | $url = $ep->getPodcast(); 37 | /** @var PodcastMetrics */ 38 | $metrics = $metricsPerUrl[$url] ?? $this->createMetricsForUrl($url); 39 | 40 | $actionLower = strtolower($ep->getAction()); 41 | $metrics->getActionCounts()->incrementAction($actionLower); 42 | 43 | if ($actionLower == 'play') { 44 | $seconds = $ep->getPosition(); 45 | if ($seconds && $seconds != -1) { 46 | $metrics->addListenedSeconds($seconds); 47 | } 48 | } 49 | 50 | $metricsPerUrl[$url] = $metrics; 51 | } 52 | 53 | $sinceDatetime = (new DateTime)->setTimestamp(0); 54 | $subscriptionChanges = $this->subscriptionChangeRepository->findAllSubscribed($sinceDatetime, $userId); 55 | /** @var PodcastMetrics[] */ 56 | $subscriptions = array_map(function (SubscriptionChangeEntity $sub) use ($metricsPerUrl) { 57 | $url = $sub->getUrl(); 58 | $metrics = $metricsPerUrl[$url] ?? $this->createMetricsForUrl($url); 59 | return $metrics; 60 | }, $subscriptionChanges); 61 | 62 | return $subscriptions; 63 | } 64 | 65 | private function createMetricsForUrl(string $url): PodcastMetrics { 66 | return new PodcastMetrics( 67 | $url, 68 | 0, 69 | new PodcastActionCounts() 70 | ); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Use `just ` to run a recipe 2 | # https://just.systems/man/en/ 3 | 4 | # By default, run the `--list` command 5 | default: 6 | @just --list 7 | 8 | # Variables 9 | 10 | transferDir := `if [ -d "$HOME/NextcloudPrivate/Transfer" ]; then echo "$HOME/NextcloudPrivate/Transfer"; else echo "$HOME/Nextcloud/Transfer"; fi` 11 | version := `xmllint --xpath "string(/info/version)" appinfo/info.xml` 12 | projectName := 'nextcloud-nextpod' 13 | 14 | # Aliases 15 | 16 | alias fmt := format 17 | 18 | # Open a terminal with the project session 19 | [group('dev')] 20 | term-run: 21 | zellij --layout term.kdl attach {{ projectName }} -c 22 | 23 | # Kill the project session 24 | [group('dev')] 25 | term-kill: 26 | -zellij delete-session {{ projectName }} -f 27 | 28 | # Kill and run a terminal with the project session 29 | [group('dev')] 30 | term: term-kill term-run 31 | 32 | # Apply the patch to the project repository 33 | [group('patch')] 34 | git-apply-patch: 35 | git apply {{ transferDir }}/{{ projectName }}.patch 36 | 37 | # Create a patch from the staged changes in the project repository 38 | [group('patch')] 39 | @git-create-patch: 40 | echo "transferDir: {{ transferDir }}" 41 | git diff --no-ext-diff --staged --binary > {{ transferDir }}/{{ projectName }}.patch 42 | ls -l1t {{ transferDir }}/ | head -2 43 | 44 | # Create a tag for the release 45 | [group('dev')] 46 | create-tag: 47 | git tag -a v{{ version }} -m "Tagging the {{ version }} release." && git push origin v{{ version }} 48 | 49 | # Create a screenshot of the nextcloud-nextpod app 50 | [group('doc')] 51 | screenshots: 52 | cd playwright && npx playwright test tests/screenshots.spec.ts --project=chromium --headed 53 | 54 | # Build the nextcloud-nextpod app 55 | [group('dev')] 56 | build-release: 57 | rm -rf js/* 58 | composer install --no-dev 59 | npm install 60 | npm run build 61 | 62 | # Format all files 63 | [group('linter')] 64 | format args='': 65 | treefmt {{ args }} 66 | 67 | # Format all files using pre-commit 68 | [group('linter')] 69 | format-all args='': 70 | composer install 71 | pre-commit run --all-files {{ args }} 72 | 73 | # Add git commit hashes to the .git-blame-ignore-revs file 74 | [group('linter')] 75 | add-git-blame-ignore-revs: 76 | git log --pretty=format:"%H" --grep="^lint" >> .git-blame-ignore-revs 77 | sort .git-blame-ignore-revs | uniq > .git-blame-ignore-revs.tmp 78 | mv .git-blame-ignore-revs.tmp .git-blame-ignore-revs 79 | -------------------------------------------------------------------------------- /lib/Controller/SubscriptionChangeController.php: -------------------------------------------------------------------------------- 1 | subscriptionChangeSaver = $subscriptionChangeSaver; 31 | $this->subscriptionChangeRepository = $subscriptionChangeRepository; 32 | $this->userId = $UserId ?? ''; 33 | } 34 | 35 | /** 36 | * 37 | * @NoAdminRequired 38 | * @NoCSRFRequired 39 | * 40 | * @param array $add 41 | * @param array $remove 42 | * @return JSONResponse 43 | */ 44 | public function create(array $add, array $remove): JSONResponse { 45 | $this->subscriptionChangeSaver->saveSubscriptionChanges($add, $remove, $this->userId); 46 | 47 | return new JSONResponse(["timestamp" => time()]); 48 | } 49 | 50 | /** 51 | * 52 | * @NoAdminRequired 53 | * @NoCSRFRequired 54 | * 55 | * @param int|null $since 56 | * @return JSONResponse 57 | */ 58 | public function list(int $since = 0): JSONResponse { 59 | $sinceDatetime = (new DateTime)->setTimestamp($since); 60 | return new JSONResponse([ 61 | "add" => $this->extractUrlList($this->subscriptionChangeRepository->findAllSubscribed($sinceDatetime, $this->userId)), 62 | "remove" => $this->extractUrlList($this->subscriptionChangeRepository->findAllUnSubscribed($sinceDatetime, $this->userId)), 63 | "timestamp" => time() 64 | ]); 65 | } 66 | 67 | /** 68 | * @param array $allSubscribed 69 | * @return mixed 70 | */ 71 | private function extractUrlList(array $allSubscribed): array { 72 | return array_map(static function (SubscriptionChangeEntity $subscription) { 73 | return $subscription->getUrl(); 74 | }, $allSubscribed); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/Db/EpisodeAction/EpisodeActionRepository.php: -------------------------------------------------------------------------------- 1 | episodeActionMapper = $episodeActionMapper; 16 | } 17 | 18 | /** 19 | * @param int $sinceEpoch 20 | * @param string $userId 21 | * 22 | * @return EpisodeAction[] 23 | */ 24 | public function findAll(int $sinceEpoch, string $userId, $order = '', $sort = 'DESC') : array { 25 | $episodeActions = []; 26 | foreach ($this->episodeActionMapper->findAll($sinceEpoch, $userId, $order, $sort) as $entity) { 27 | $episodeActions[] = $this->mapEntityToEpisodeAction($entity); 28 | } 29 | return $episodeActions; 30 | } 31 | 32 | public function findByEpisodeUrl(string $episodeUrl, string $userId): ?EpisodeAction { 33 | $episodeActionEntity = $this->episodeActionMapper->findByEpisodeUrl($episodeUrl, $userId); 34 | 35 | if ($episodeActionEntity === null) { 36 | return null; 37 | } 38 | 39 | return $this->mapEntityToEpisodeAction( 40 | $episodeActionEntity 41 | ); 42 | } 43 | 44 | public function findByGuid(string $guid, string $userId): ?EpisodeAction { 45 | $episodeActionEntity = $this->episodeActionMapper->findByGuid($guid, $userId); 46 | 47 | if ($episodeActionEntity === null) { 48 | return null; 49 | } 50 | 51 | return $this->mapEntityToEpisodeAction( 52 | $episodeActionEntity 53 | ); 54 | } 55 | 56 | public function deleteEpisodeActionByEpisodeUrl(string $episodeUrl, string $userId) : void { 57 | $episodeAction = $this->episodeActionMapper->findByEpisodeUrl($episodeUrl, $userId); 58 | $this->episodeActionMapper->delete($episodeAction); 59 | } 60 | 61 | private function mapEntityToEpisodeAction(EpisodeActionEntity $episodeActionEntity): EpisodeAction { 62 | return new EpisodeAction( 63 | $episodeActionEntity->getPodcast(), 64 | $episodeActionEntity->getEpisode(), 65 | $episodeActionEntity->getAction(), 66 | DateTime::createFromFormat( 67 | "U", 68 | (string)$episodeActionEntity->getTimestampEpoch()) 69 | ->format("Y-m-d\TH:i:s"), 70 | $episodeActionEntity->getStarted(), 71 | $episodeActionEntity->getPosition(), 72 | $episodeActionEntity->getTotal(), 73 | $episodeActionEntity->getGuid(), 74 | $episodeActionEntity->getId(), 75 | ); 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /lib/Core/EpisodeAction/EpisodeAction.php: -------------------------------------------------------------------------------- 1 | podcast = $podcast; 30 | $this->episode = $episode; 31 | $this->action = $action; 32 | $this->timestamp = $timestamp; 33 | $this->started = $started; 34 | $this->position = $position; 35 | $this->total = $total; 36 | $this->guid = $guid; 37 | $this->id = $id; 38 | } 39 | 40 | /** 41 | * @return string 42 | */ 43 | public function getPodcast(): string { 44 | return $this->podcast; 45 | } 46 | 47 | /** 48 | * @return string 49 | */ 50 | public function getEpisode(): string { 51 | return $this->episode; 52 | } 53 | 54 | /** 55 | * @return string 56 | */ 57 | public function getAction(): string { 58 | return $this->action; 59 | } 60 | 61 | /** 62 | * @return string 63 | */ 64 | public function getTimestamp(): string { 65 | return $this->timestamp; 66 | } 67 | 68 | /** 69 | * @return int 70 | */ 71 | public function getStarted(): int { 72 | return $this->started; 73 | } 74 | 75 | /** 76 | * @return int 77 | */ 78 | public function getPosition(): int { 79 | return $this->position; 80 | } 81 | 82 | /** 83 | * @return int 84 | */ 85 | public function getTotal(): int { 86 | return $this->total; 87 | } 88 | 89 | public function getGuid() : ?string { 90 | return $this->guid; 91 | } 92 | 93 | /** 94 | * @return int 95 | */ 96 | public function getId(): int { 97 | return $this->id; 98 | } 99 | 100 | public function toArray(): array { 101 | return 102 | [ 103 | 'podcast' => $this->getPodcast(), 104 | 'episode' => $this->getEpisode(), 105 | 'timestamp' => $this->getTimestamp(), 106 | 'guid' => $this->getGuid(), 107 | 'position' => $this->getPosition(), 108 | 'started' => $this->getStarted(), 109 | 'total' => $this->getTotal(), 110 | 'action' => $this->getAction(), 111 | ]; 112 | } 113 | 114 | 115 | } 116 | -------------------------------------------------------------------------------- /lib/Db/EpisodeAction/EpisodeActionMapper.php: -------------------------------------------------------------------------------- 1 | db->getQueryBuilder(); 24 | 25 | $qb->select('*') 26 | ->from($this->getTableName()) 27 | ->where( 28 | $qb->expr()->gt('timestamp_epoch', $qb->createNamedParameter($sinceTimestamp, IQueryBuilder::PARAM_INT)) 29 | ) 30 | ->andWhere( 31 | $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) 32 | 33 | ); 34 | 35 | if ($sort !== '') { 36 | $qb->orderBy($sort, $order); 37 | } 38 | 39 | return $this->findEntities($qb); 40 | 41 | } 42 | 43 | /** 44 | * @param string $episodeIdentifier 45 | * @param string $userId 46 | * @return EpisodeActionEntity|null 47 | */ 48 | public function findByEpisodeUrl(string $episodeIdentifier, string $userId) : ?EpisodeActionEntity { 49 | $qb = $this->db->getQueryBuilder(); 50 | 51 | $qb->select('*') 52 | ->from($this->getTableName()) 53 | ->where( 54 | $qb->expr()->eq('episode', $qb->createNamedParameter($episodeIdentifier)) 55 | ) 56 | ->andWhere( 57 | $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) 58 | ); 59 | 60 | try { 61 | /** @var EpisodeActionEntity $episodeActionEntity */ 62 | return $this->findEntity($qb); 63 | } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { 64 | return null; 65 | } 66 | } 67 | 68 | public function findByGuid(string $guid, string $userId) : ?EpisodeActionEntity { 69 | $qb = $this->db->getQueryBuilder(); 70 | 71 | $qb->select('*') 72 | ->from($this->getTableName()) 73 | ->where( 74 | $qb->expr()->eq('guid', $qb->createNamedParameter($guid)) 75 | ) 76 | ->andWhere( 77 | $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) 78 | ); 79 | 80 | try { 81 | /** @var EpisodeActionEntity $episodeActionEntity */ 82 | return $this->findEntity($qb); 83 | } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { 84 | return null; 85 | } 86 | } 87 | 88 | 89 | } 90 | -------------------------------------------------------------------------------- /playwright/tests/screenshots.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('Create screenshots', async ({ page }) => { 4 | await page.goto('http://localhost:8081/index.php/login?redirect_url=/index.php/apps/nextpod/actions'); 5 | 6 | // Login 7 | await page.getByLabel('Account name or email').fill('admin'); 8 | // await page.getByLabel('Account name or email').press('Tab'); 9 | await page.getByLabel('Password').fill('admin'); 10 | await page.getByLabel('Password').press('Enter'); 11 | 12 | // 13 | // Take screenshot of "Episodes" page 14 | // 15 | 16 | //await page.locator('class=list-item-content__main').hover() 17 | //await page.locator('class=list-item-content__actions').hover() 18 | //await page.locator('class=list-item-content__actions').click() 19 | //await page.getByRole('button', { name: 'Actions for item with title "Leadership Lessons from a Disastrous Arctic Expedition"' }).hover() 20 | 21 | // Wait until page renders 22 | await page.waitForTimeout(3000); 23 | // Playwright tests run on a default viewport size of 1280x720 24 | // Try to aim for the 2nd element and open the description page 25 | await page.mouse.click(1000, 288); 26 | await page.waitForTimeout(2000); 27 | await page.screenshot({ path: '../img/screenshots/episode-description.png', fullPage: true }); 28 | 29 | // Try to open the context menu 30 | await page.mouse.click(1150, 288); 31 | await page.waitForTimeout(500); 32 | await page.mouse.click(1150, 288); 33 | // Wait until the context menu opens 34 | await page.waitForTimeout(1000); 35 | 36 | //await page.getByRole('link', { name: 'LL Leadership Lessons from a Disastrous Arctic Expedition The Art of Manliness Actions for item with title "Leadership Lessons from a Disastrous Arctic Expedition"' }).hover() 37 | //await page.getByRole('button', { name: 'Actions for item with title "Leadership Lessons from a Disastrous Arctic Expedition"' }).click(); 38 | await page.screenshot({ path: '../img/screenshots/episodes.png', fullPage: true }); 39 | 40 | // 41 | // Take screenshot of "Podcasts" page 42 | // 43 | await page.getByRole('link', { name: 'Podcasts' }).click(); 44 | // Wait until page renders 45 | await page.waitForTimeout(10000); 46 | // Playwright tests run on a default viewport size of 1280x720 47 | // Try to aim for the 2nd element 48 | await page.mouse.click(1150, 288); 49 | // Wait until the context menu opens 50 | await page.waitForTimeout(1000); 51 | // await page.getByRole('button', { name: 'Actions for item with title "3D Printing Today"' }).click(); 52 | await page.screenshot({ path: '../img/screenshots/podcasts.png', fullPage: true }); 53 | }); 54 | -------------------------------------------------------------------------------- /img/app.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 14 | 32 | 36 | 37 | 41 | 42 | -------------------------------------------------------------------------------- /lib/Controller/PersonalSettingsController.php: -------------------------------------------------------------------------------- 1 | userId = $UserId ?? ''; 33 | $this->metricsReader = $metricsReader; 34 | $this->dataReader = $dataReader; 35 | $this->actionsReader = $actionsReader; 36 | } 37 | 38 | /** 39 | * 40 | * @NoAdminRequired 41 | * @NoCSRFRequired 42 | * 43 | * @return JSONResponse 44 | */ 45 | public function metrics(): JSONResponse { 46 | $actions = $this->actionsReader->actions($this->userId, 'timestamp_epoch', 'DESC'); 47 | $metrics = $this->metricsReader->metrics($this->userId); 48 | return new JSONResponse([ 49 | 'actions' => $actions, 50 | 'subscriptions' => $metrics, 51 | ]); 52 | } 53 | 54 | /** 55 | * @NoAdminRequired 56 | * @NoCSRFRequired 57 | * 58 | * @param string $url 59 | * @return JsonResponse 60 | */ 61 | public function podcastData(string $url = ''): JsonResponse { 62 | if ($url === '') { 63 | return new JSONResponse([ 64 | 'message' => "Missing query parameter 'url'.", 65 | 'data' => null, 66 | ], Http::STATUS_BAD_REQUEST); 67 | } 68 | return new JsonResponse([ 69 | 'data' => $this->dataReader->getCachedOrFetchPodcastData($url, $this->userId), 70 | ]); 71 | } 72 | 73 | /** 74 | * @NoAdminRequired 75 | * @NoCSRFRequired 76 | * 77 | * @param string $episodeUrl 78 | * @return JsonResponse 79 | */ 80 | public function actionExtraData(string $episodeUrl = ''): JsonResponse { 81 | if ($episodeUrl === '') { 82 | return new JSONResponse([ 83 | 'message' => "Missing query parameter 'episodeUrl'.", 84 | 'data' => null, 85 | ], Http::STATUS_BAD_REQUEST); 86 | } 87 | return new JsonResponse([ 88 | 'data' => $this->actionsReader->getCachedOrFetchActionExtraData($episodeUrl, $this->userId), 89 | ]); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /playwright/playwright.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { defineConfig, devices } = require('@playwright/test'); 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * @see https://playwright.dev/docs/test-configuration 12 | */ 13 | module.exports = defineConfig({ 14 | testDir: './tests', 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: 'html', 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | // baseURL: 'http://localhost:3000', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: 'on-first-retry', 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: 'chromium', 49 | use: { ...devices['Desktop Chrome'] }, 50 | }, 51 | 52 | { 53 | name: 'firefox', 54 | use: { ...devices['Desktop Firefox'] }, 55 | }, 56 | 57 | { 58 | name: 'webkit', 59 | use: { ...devices['Desktop Safari'] }, 60 | }, 61 | 62 | /* Test against mobile viewports. */ 63 | // { 64 | // name: 'Mobile Chrome', 65 | // use: { ...devices['Pixel 5'] }, 66 | // }, 67 | // { 68 | // name: 'Mobile Safari', 69 | // use: { ...devices['iPhone 12'] }, 70 | // }, 71 | 72 | /* Test against branded browsers. */ 73 | // { 74 | // name: 'Microsoft Edge', 75 | // use: { channel: 'msedge' }, 76 | // }, 77 | // { 78 | // name: 'Google Chrome', 79 | // use: { channel: 'chrome' }, 80 | // }, 81 | ], 82 | 83 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 84 | // outputDir: 'test-results/', 85 | 86 | /* Run your local dev server before starting the tests */ 87 | // webServer: { 88 | // command: 'npm run start', 89 | // port: 3000, 90 | // }, 91 | }); 92 | 93 | -------------------------------------------------------------------------------- /devenv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devenv": { 4 | "locked": { 5 | "dir": "src/modules", 6 | "lastModified": 1755798632, 7 | "owner": "cachix", 8 | "repo": "devenv", 9 | "rev": "e70f1e0a7da7ab9e74f2ed300cbf05f407fb107b", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "dir": "src/modules", 14 | "owner": "cachix", 15 | "repo": "devenv", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-compat": { 20 | "flake": false, 21 | "locked": { 22 | "lastModified": 1747046372, 23 | "owner": "edolstra", 24 | "repo": "flake-compat", 25 | "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "edolstra", 30 | "repo": "flake-compat", 31 | "type": "github" 32 | } 33 | }, 34 | "git-hooks": { 35 | "inputs": { 36 | "flake-compat": "flake-compat", 37 | "gitignore": "gitignore", 38 | "nixpkgs": [ 39 | "nixpkgs" 40 | ] 41 | }, 42 | "locked": { 43 | "lastModified": 1755446520, 44 | "owner": "cachix", 45 | "repo": "git-hooks.nix", 46 | "rev": "4b04db83821b819bbbe32ed0a025b31e7971f22e", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "cachix", 51 | "repo": "git-hooks.nix", 52 | "type": "github" 53 | } 54 | }, 55 | "gitignore": { 56 | "inputs": { 57 | "nixpkgs": [ 58 | "git-hooks", 59 | "nixpkgs" 60 | ] 61 | }, 62 | "locked": { 63 | "lastModified": 1709087332, 64 | "owner": "hercules-ci", 65 | "repo": "gitignore.nix", 66 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 67 | "type": "github" 68 | }, 69 | "original": { 70 | "owner": "hercules-ci", 71 | "repo": "gitignore.nix", 72 | "type": "github" 73 | } 74 | }, 75 | "nixpkgs": { 76 | "locked": { 77 | "lastModified": 1755783167, 78 | "owner": "cachix", 79 | "repo": "devenv-nixpkgs", 80 | "rev": "4a880fb247d24fbca57269af672e8f78935b0328", 81 | "type": "github" 82 | }, 83 | "original": { 84 | "owner": "cachix", 85 | "ref": "rolling", 86 | "repo": "devenv-nixpkgs", 87 | "type": "github" 88 | } 89 | }, 90 | "root": { 91 | "inputs": { 92 | "devenv": "devenv", 93 | "git-hooks": "git-hooks", 94 | "nixpkgs": "nixpkgs", 95 | "pre-commit-hooks": [ 96 | "git-hooks" 97 | ] 98 | } 99 | } 100 | }, 101 | "root": "root", 102 | "version": 7 103 | } 104 | -------------------------------------------------------------------------------- /docker/justfile: -------------------------------------------------------------------------------- 1 | # Use `just ` to run a recipe 2 | # https://just.systems/man/en/ 3 | 4 | # By default, run the `--list` command 5 | default: 6 | @just --list 7 | 8 | # Variables 9 | # Try to use "docker compose" and fall back to "docker-compose" if not available 10 | 11 | dockerComposeCommand := `docker compose > /dev/null && echo docker compose || echo docker-compose` 12 | containerName := "nextcloud-nextpod-app-1" 13 | volumeName := "nextcloud-nextpod_nextcloud" 14 | 15 | # Aliases 16 | 17 | alias remove-volumes := reset 18 | 19 | # Check the code, run tests and sign the app 20 | all: check-code test sign-app 21 | 22 | # Build the docker image 23 | build: 24 | {{ dockerComposeCommand }} build 25 | 26 | # Build the docker image without cache 27 | build-force: 28 | {{ dockerComposeCommand }} build --no-cache 29 | 30 | # Show the directory listing of the database file in the container 31 | ls-db: 32 | {{ dockerComposeCommand }} run --rm app su -c "ls -hal data/mydb.db*" www-data 33 | 34 | # Fetch the database from the container 35 | fetch-db: 36 | {{ dockerComposeCommand }} run --rm app su -c "cp data/mydb.db apps/nextpod" www-data 37 | 38 | # Push the database to the container 39 | push-db: 40 | {{ dockerComposeCommand }} run --rm app su -c "cp apps/nextpod/mydb.db* data" www-data 41 | 42 | # Open a shell in the container 43 | bash: 44 | {{ dockerComposeCommand }} run --rm app su -c "bash" www-data 45 | 46 | # Open a shell in the container as root 47 | bash-root: 48 | {{ dockerComposeCommand }} run --rm app bash 49 | 50 | # Run a sidecar container with more debugging tools 51 | slim-shell: 52 | nix-shell -p docker-slim --run "slim debug --target {{ containerName }}" 53 | 54 | # Turn off maintenance mode 55 | maintenance-mode-off: 56 | {{ dockerComposeCommand }} run --rm app su -c "./occ maintenance:mode --off" www-data 57 | 58 | # Run the app:check-code command 59 | check-code: 60 | {{ dockerComposeCommand }} run --rm app su -c "./occ app:check-code nextpod" www-data 61 | 62 | # Run the app signing command for Nextcloud 63 | sign-app: 64 | {{ dockerComposeCommand }} run --rm app ../sign-app.sh www-data 65 | 66 | # Run the app signing command for Owncloud 67 | sign-app-owncloud: 68 | {{ dockerComposeCommand }} run --rm app ../sign-app-owncloud.sh 69 | 70 | # Run the tests 71 | test: 72 | {{ dockerComposeCommand }} run --rm app su -c "cd apps/nextpod && just test" www-data 73 | 74 | # Show Nextcloud logs 75 | show-log: 76 | {{ dockerComposeCommand }} run --rm app tail -f /var/www/html/data/nextcloud.log 77 | 78 | # Remove the docker volume 79 | reset: 80 | docker compose down 81 | docker volume rm {{ volumeName }} 82 | 83 | # Open a browser with the app 84 | open-browser-app: 85 | xdg-open http://localhost:8081 86 | 87 | # Open a browser with sqlite-web 88 | open-browser-sqlite: 89 | xdg-open http://localhost:8082 90 | -------------------------------------------------------------------------------- /lib/Core/EpisodeAction/EpisodeActionData.php: -------------------------------------------------------------------------------- 1 | guid = $guid; 29 | $this->podcastUrl = $podcastUrl; 30 | $this->episodeUrl = $episodeUrl; 31 | $this->action = $action; 32 | $this->position = $position; 33 | $this->started = $started; 34 | $this->total = $total; 35 | } 36 | 37 | /** 38 | * @return string|null 39 | */ 40 | public function getEpisodeUrl(): ?string { 41 | return $this->episodeUrl; 42 | } 43 | 44 | /** 45 | * @return string 46 | */ 47 | public function __toString() : string { 48 | return $this->episodeUrl ?? '/no episodeUrl/'; 49 | } 50 | 51 | /** 52 | * @return array 53 | */ 54 | public function toArray(): array { 55 | return 56 | [ 57 | 'guid' => $this->guid, 58 | 'podcastUrl' => $this->podcastUrl, 59 | 'episodeUrl' => $this->episodeUrl, 60 | 'action' => $this->action, 61 | 'position' => $this->position, 62 | 'started' => $this->started, 63 | 'total' => $this->total, 64 | ]; 65 | } 66 | 67 | /** 68 | * @return array 69 | */ 70 | public function jsonSerialize(): array { 71 | return $this->toArray(); 72 | } 73 | 74 | /** 75 | * @return EpisodeActionData 76 | */ 77 | public static function fromArray(array $data): EpisodeActionData { 78 | return new EpisodeActionData( 79 | $data['guid'], 80 | $data['podcastUrl'], 81 | $data['episodeUrl'], 82 | $data['action'], 83 | $data['position'], 84 | $data['started'], 85 | $data['total'], 86 | ); 87 | } 88 | 89 | /** 90 | * @return string|null 91 | */ 92 | public function getPodcastUrl(): ?string { 93 | return $this->podcastUrl; 94 | } 95 | 96 | /** 97 | * @return string|null 98 | */ 99 | public function getAction(): ?string { 100 | return $this->action; 101 | } 102 | 103 | /** 104 | * @return int 105 | */ 106 | public function getPosition(): int { 107 | return $this->position; 108 | } 109 | 110 | /** 111 | * @return int 112 | */ 113 | public function getStarted(): int { 114 | return $this->started; 115 | } 116 | 117 | /** 118 | * @return int 119 | */ 120 | public function getTotal(): int { 121 | return $this->total; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /lib/Core/SubscriptionChange/SubscriptionChangeSaver.php: -------------------------------------------------------------------------------- 1 | subscriptionChangeRepository = $subscriptionChangeRepository; 24 | $this->subscriptionChangeWriter = $subscriptionChangeWriter; 25 | $this->subscriptionChangeRequestParser = $subscriptionChangeRequestParser; 26 | } 27 | 28 | public function saveSubscriptionChanges(array $urlsSubscribed, array $urlsUnsubscribed, string $userId): void { 29 | $subscriptionChanges = $this->subscriptionChangeRequestParser->createSubscriptionChangeList($urlsSubscribed, $urlsUnsubscribed); 30 | foreach ($subscriptionChanges as $urlChangedSubscriptionStatus) { 31 | $subscriptionChangeEntity = new SubscriptionChangeEntity(); 32 | $subscriptionChangeEntity->setUrl($urlChangedSubscriptionStatus->getUrl()); 33 | $subscriptionChangeEntity->setSubscribed($urlChangedSubscriptionStatus->isSubscribed()); 34 | $subscriptionChangeEntity->setUpdated((new \DateTime())->format("Y-m-d\TH:i:s")); 35 | $subscriptionChangeEntity->setUserId($userId); 36 | 37 | try { 38 | $this->subscriptionChangeWriter->create($subscriptionChangeEntity); 39 | } catch (UniqueConstraintViolationException $uniqueConstraintViolationException) { 40 | $this->updateSubscription($subscriptionChangeEntity, $userId); 41 | } catch (Exception $exception) { 42 | if ($exception->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { 43 | $this->updateSubscription($subscriptionChangeEntity, $userId); 44 | } 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * @param SubscriptionChangeEntity $subscriptionChangeEntity 51 | * @param string $userId 52 | * 53 | * @return void 54 | */ 55 | private function updateSubscription(SubscriptionChangeEntity $subscriptionChangeEntity, string $userId): void { 56 | $idEpisodeActionEntityToUpdate = $this->subscriptionChangeRepository->findByUrl($subscriptionChangeEntity->getUrl(), $userId)->getId(); 57 | $subscriptionChangeEntity->setId($idEpisodeActionEntityToUpdate); 58 | $this->subscriptionChangeWriter->update($subscriptionChangeEntity); 59 | } 60 | 61 | 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: "🗃️ Create empty release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | tags-ignore: 8 | - "*" 9 | workflow_dispatch: 10 | 11 | env: 12 | APP_NAME: nextpod 13 | 14 | jobs: 15 | create_release: 16 | name: "🗃️️ Prepare release" 17 | permissions: 18 | contents: write # for actions/create-release to create a release 19 | runs-on: ubuntu-latest 20 | outputs: 21 | upload_url: ${{ steps.create_release.outputs.upload_url }} 22 | release_id: ${{ steps.create_release.outputs.id }} 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Install libxml2-utils 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get install libxml2-utils 29 | - name: Set Env 30 | run: | 31 | export VERSION=$(xmllint --xpath "string(/info/version)" appinfo/info.xml) 32 | export TAG=v${VERSION} 33 | export RELEASE_TEXT=$(grep -Pzo "## ${VERSION}\n(\n|.)+?\n##" CHANGELOG.md | sed '$ d') 34 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 35 | echo "TAG=${TAG}" >> $GITHUB_ENV 36 | # add multiline release text 37 | echo "RELEASE_TEXT<> $GITHUB_ENV 38 | echo "${RELEASE_TEXT}" >> $GITHUB_ENV 39 | echo "EOF" >> $GITHUB_ENV 40 | - name: Printenv 41 | run: | 42 | echo "VERSION=${VERSION}" 43 | echo "TAG=${TAG}" 44 | echo "RELEASE_TEXT=${RELEASE_TEXT}" 45 | - name: Create release 46 | id: create_release 47 | uses: actions/create-release@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | tag_name: ${{ env.TAG }} 52 | release_name: Release v${{ env.VERSION }} 53 | body: ${{ env.RELEASE_TEXT }} 54 | draft: true 55 | prerelease: false 56 | # build_and_publish: 57 | # runs-on: ubuntu-latest 58 | # steps: 59 | # - name: Checkout 60 | # uses: actions/checkout@v3 61 | # with: 62 | # path: ${{ env.APP_NAME }} 63 | # - name: Install NPM packages 64 | # run: cd ${{ env.APP_NAME }} && make npm-init 65 | # - name: Build JS 66 | # run: cd ${{ env.APP_NAME }} && make build-js-production 67 | # - name: Create release tarball 68 | # run: cd ${{ env.APP_NAME }} && make appstore 69 | # - name: Upload app tarball to release 70 | # uses: svenstaro/upload-release-action@v2 71 | # id: attach_to_release 72 | # with: 73 | # repo_token: ${{ secrets.GITHUB_TOKEN }} 74 | # file: ${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }}.tar.gz 75 | # asset_name: ${{ env.APP_NAME }}.tar.gz 76 | # tag: ${{ github.ref }} 77 | # overwrite: true 78 | # - name: Upload app to Nextcloud appstore 79 | # uses: R0Wi/nextcloud-appstore-push-action@v1.0.3 80 | # with: 81 | # app_name: ${{ env.APP_NAME }} 82 | # appstore_token: ${{ secrets.APPSTORE_TOKEN }} 83 | # download_url: ${{ steps.attach_to_release.outputs.browser_download_url }} 84 | # app_private_key: ${{ secrets.APP_PRIVATE_KEY }} 85 | # nightly: false 86 | -------------------------------------------------------------------------------- /lib/Core/PodcastData/PodcastDataReader.php: -------------------------------------------------------------------------------- 1 | isLocalCacheAvailable()) { 26 | $this->cache = $cacheFactory->createLocal('NextPod-Podcasts'); 27 | } 28 | $this->httpClient = $httpClientService->newClient(); 29 | $this->subscriptionChangeRepository = $subscriptionChangeRepository; 30 | } 31 | 32 | public function getCachedOrFetchPodcastData(string $url, string $userId): ?PodcastData { 33 | if ($this->cache == null) { 34 | return $this->fetchPodcastData($url, $userId); 35 | } 36 | $oldData = $this->tryGetCachedPodcastData($url); 37 | if ($oldData) { 38 | return $oldData; 39 | } 40 | $newData = $this->fetchPodcastData($url, $userId); 41 | $this->trySetCachedPodcastData($url, $newData); 42 | return $newData; 43 | } 44 | 45 | private function userHasPodcast(string $url, string $userId): bool { 46 | $subscriptionChanges = $this->subscriptionChangeRepository->findByUrl($url, $userId); 47 | return $subscriptionChanges !== null; 48 | } 49 | 50 | public function fetchPodcastData(string $url, string $userId): ?PodcastData { 51 | if (!$this->userHasPodcast($url, $userId)) { 52 | return null; 53 | } 54 | $resp = $this->fetchUrl($url); 55 | $data = PodcastData::parseRssXml($resp->getBody()); 56 | $blob = $this->tryFetchImageBlob($data); 57 | if ($blob) { 58 | $data->setImageBlob($blob); 59 | } 60 | return $data; 61 | } 62 | 63 | private function tryFetchImageBlob(PodcastData $data): ?string { 64 | if (!$data->getImageUrl()) { 65 | return null; 66 | } 67 | try { 68 | $resp = $this->fetchUrl($data->getImageUrl()); 69 | $contentType = $resp->getHeader('Content-Type'); 70 | $body = $resp->getBody(); 71 | $bodyBase64 = base64_encode($body); 72 | return "data:$contentType;base64,$bodyBase64"; 73 | } catch (Exception $e) { 74 | return null; 75 | } 76 | } 77 | 78 | private function fetchUrl(string $url): IResponse { 79 | $resp = $this->httpClient->get($url); 80 | $statusCode = $resp->getStatusCode(); 81 | if ($statusCode < 200 || $statusCode >= 300) { 82 | throw new \ErrorException("Web request returned non-2xx status code: $statusCode"); 83 | } 84 | return $resp; 85 | } 86 | 87 | public function tryGetCachedPodcastData(string $url): ?PodcastData { 88 | $oldData = $this->cache->get($url); 89 | if (!$oldData) { 90 | return null; 91 | } 92 | return PodcastData::fromArray($oldData); 93 | } 94 | 95 | public function trySetCachedPodcastData(string $url, PodcastData $data): bool { 96 | return $this->cache->set($url, $data->toArray()); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/components/SubscriptionListItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 115 | 116 | 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NextPod Nextcloud App 2 | 3 | [GitHub](https://github.com/pbek/nextcloud-nextpod) | 4 | [Nextcloud App Store](https://apps.nextcloud.com/apps/nextpod) | 5 | [Changelog](https://github.com/pbek/nextcloud-nextpod/blob/main/CHANGELOG.md) 6 | 7 | [![PHPUnit](https://github.com/pbek/nextcloud-nextpod/actions/workflows/ci.yml/badge.svg)](https://github.com/pbek/nextcloud-nextpod/actions/workflows/ci.yml) 8 | [![NPM build](https://github.com/pbek/nextcloud-nextpod/actions/workflows/ci-js.yml/badge.svg)](https://github.com/pbek/nextcloud-nextpod/actions/workflows/ci-js.yml) 9 | 10 | This Nextcloud app lets you visualize your podcast subscriptions and episode downloads from 11 | [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync), which acts as a basic gpodder.net 12 | api to sync podcast consumer apps (podcatchers) like AntennaPod. 13 | 14 | You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app! 15 | 16 | ## Features 17 | 18 | - List of all your podcast subscriptions 19 | - List of all your downloaded episodes 20 | - Click an episode to show the description of the episode 21 | - Create a note of an episode in [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) 22 | - Play episodes in the browser (with or without syncing the progress with gpoddersync) 23 | - Download episodes 24 | - Open episode website and RSS feed 25 | 26 | ## Screenshots 27 | 28 | ### Episode List 29 | 30 | ![episodes](./img/screenshots/episodes.png) 31 | 32 | ### Episode Description 33 | 34 | ![episodes](./img/screenshots/episode-description.png) 35 | 36 | ### Podcast Subscriptions 37 | 38 | ![podcasts](./img/screenshots/podcasts.png) 39 | 40 | ## Clients supporting sync of GPodderSync 41 | 42 | | client | support status | 43 | | :----------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 44 | | [AntennaPod](https://antennapod.org) | Initial purpose for this project, as a synchronization endpoint for this client.
Support is available [as of version 2.5.1](https://github.com/AntennaPod/AntennaPod/pull/5243/). | 45 | | [KDE Kasts](https://apps.kde.org/de/kasts/) | Supported since version 21.12 | 46 | | [Garmin Podcasts](https://lucasasselli.github.io/garmin-podcasts/) | Only for [compatible Garmin watches](https://apps.garmin.com/en-US/apps/b5b85600-0625-43b6-89e9-1245bd44532c), supported since version 3.3.4 | 47 | 48 | ## Installation 49 | 50 | Either from the official Nextcloud app store ([link to app page](https://apps.nextcloud.com/apps/nextpod)) or by 51 | downloading the [latest release](https://github.com/pbek/nextcloud-nextpod/releases/latest) and extracting it into 52 | your Nextcloud `apps/` directory. 53 | 54 | ## Development 55 | 56 | See [docker development](./docker/README.md) for development instructions. 57 | -------------------------------------------------------------------------------- /src/views/HeaderNavigation.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 83 | 84 | 100 | 101 | 152 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.7.8 4 | 5 | - Updated and tested app for Nextcloud 32 (for [#16](https://github.com/pbek/nextcloud-nextpod/issues/16)) 6 | 7 | ## 0.7.7 8 | 9 | - Removed opacity in the app logo for better visibility in Nextcloud 10 | (for [#12](https://github.com/pbek/nextcloud-nextpod/issues/12)) 11 | - Updated and tested app for Nextcloud 31 (for [#14](https://github.com/pbek/nextcloud-nextpod/issues/14)) 12 | - Updated dependencies 13 | 14 | ## 0.7.6 15 | 16 | - Updated and tested app for Nextcloud 30 (for [#11](https://github.com/pbek/nextcloud-nextpod/issues/11)) 17 | - Updated dependencies 18 | 19 | ## 0.7.5 20 | 21 | - Updated and tested app for Nextcloud 29 (for [#9](https://github.com/pbek/nextcloud-nextpod/issues/9)) 22 | - Updated dependencies 23 | 24 | ## 0.7.4 25 | 26 | - Updated and tested app for Nextcloud 28 27 | - Updated dependencies 28 | 29 | ## 0.7.3 30 | 31 | - The episode and podcast names are now shown in the episode description modal dialog 32 | and the cursor was changed to `text` to indicate that the text is selectable 33 | - Dependencies were updated 34 | 35 | ## 0.7.2 36 | 37 | - You can now select text in the episode description modal dialog 38 | 39 | ## 0.7.1 40 | 41 | - Updated and tested app for Nextcloud 27 42 | 43 | ## 0.7.0 44 | 45 | - The styling of the episode description was improved 46 | - Images will now be scaled down to fit the screen width 47 | - The links in the description of the episode are now better visible 48 | - The parsing of the extra data of actions (like name and description) 49 | is now more fault-tolerant to be able to show the data even if the 50 | podcast changes its item URLs all the time (for [#7](https://github.com/pbek/nextcloud-nextpod/issues/7)) 51 | 52 | ## 0.6.2 53 | 54 | - The links in the description of the episode actions are now opened 55 | in a new browser window/tab when clicked 56 | 57 | ## 0.6.1 58 | 59 | - The styling of the episode description was improved 60 | 61 | ## 0.6.0 62 | 63 | - The play status is now optionally stored while playing an episode 64 | (for [#4](https://github.com/pbek/nextcloud-nextpod/issues/4)) 65 | - The action menu order of episode items was changed to make more sense 66 | 67 | ## 0.5.1 68 | 69 | - The episode extra data fetching for podcasts added in AntennaPod is now fixed 70 | (for [#4](https://github.com/pbek/nextcloud-nextpod/issues/4)) 71 | 72 | ## 0.5.0 73 | 74 | - The episode player now automatically starts playing at the synced position 75 | (for [#3](https://github.com/pbek/nextcloud-nextpod/issues/3)) 76 | 77 | ## 0.4.1 78 | 79 | - Updated and tested app for Nextcloud 26 80 | 81 | ## 0.4.0 82 | 83 | - Episode and podcast lists are now reloaded automatically every 10 minutes 84 | 85 | ## 0.3.1 86 | 87 | - An issue with the note headline of created notes of an episode in 88 | [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) was fixed 89 | (for [#1](https://github.com/pbek/nextcloud-nextpod/issues/1)) 90 | 91 | ## 0.3.0 92 | 93 | - You now can create a note of an episode in [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) 94 | (for [#1](https://github.com/pbek/nextcloud-nextpod/issues/1)) 95 | 96 | ## 0.2.0 97 | 98 | - You can now click an episode to show the description of the episode 99 | - The episode image detection was now made more fault-tolerant to always be able to show an episode image 100 | 101 | ## 0.1.1 102 | 103 | - Extra data of actions and podcast data of subscriptions are now side-loaded correctly 104 | when clicking the navigation headlines 105 | 106 | ## 0.1.0 107 | 108 | - Initial release with sync server and UI for episodes and podcasts 109 | -------------------------------------------------------------------------------- /vendor/bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | realpath = realpath($opened_path) ?: $opened_path; 35 | $opened_path = 'phpvfscomposer://'.$this->realpath; 36 | $this->handle = fopen($this->realpath, $mode); 37 | $this->position = 0; 38 | 39 | return (bool) $this->handle; 40 | } 41 | 42 | public function stream_read($count) 43 | { 44 | $data = fread($this->handle, $count); 45 | 46 | if ($this->position === 0) { 47 | $data = preg_replace('{^#!.*\r?\n}', '', $data); 48 | } 49 | $data = str_replace('__DIR__', var_export(dirname($this->realpath), true), $data); 50 | $data = str_replace('__FILE__', var_export($this->realpath, true), $data); 51 | 52 | $this->position += strlen($data); 53 | 54 | return $data; 55 | } 56 | 57 | public function stream_cast($castAs) 58 | { 59 | return $this->handle; 60 | } 61 | 62 | public function stream_close() 63 | { 64 | fclose($this->handle); 65 | } 66 | 67 | public function stream_lock($operation) 68 | { 69 | return $operation ? flock($this->handle, $operation) : true; 70 | } 71 | 72 | public function stream_seek($offset, $whence) 73 | { 74 | if (0 === fseek($this->handle, $offset, $whence)) { 75 | $this->position = ftell($this->handle); 76 | return true; 77 | } 78 | 79 | return false; 80 | } 81 | 82 | public function stream_tell() 83 | { 84 | return $this->position; 85 | } 86 | 87 | public function stream_eof() 88 | { 89 | return feof($this->handle); 90 | } 91 | 92 | public function stream_stat() 93 | { 94 | return array(); 95 | } 96 | 97 | public function stream_set_option($option, $arg1, $arg2) 98 | { 99 | return true; 100 | } 101 | 102 | public function url_stat($path, $flags) 103 | { 104 | $path = substr($path, 17); 105 | if (file_exists($path)) { 106 | return stat($path); 107 | } 108 | 109 | return false; 110 | } 111 | } 112 | } 113 | 114 | if ( 115 | (function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true)) 116 | || (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper')) 117 | ) { 118 | return include("phpvfscomposer://" . __DIR__ . '/..'.'/phpunit/phpunit/phpunit'); 119 | } 120 | } 121 | 122 | return include __DIR__ . '/..'.'/phpunit/phpunit/phpunit'; 123 | -------------------------------------------------------------------------------- /lib/Core/PodcastData/PodcastData.php: -------------------------------------------------------------------------------- 1 | title = $title; 30 | $this->author = $author; 31 | $this->link = $link; 32 | $this->description = $description; 33 | $this->imageUrl = $imageUrl; 34 | $this->fetchedAtUnix = $fetchedAtUnix; 35 | $this->imageBlob = $imageBlob; 36 | } 37 | 38 | /** 39 | * @return PodcastData 40 | * @throws Exception if the XML data could not be parsed. 41 | */ 42 | public static function parseRssXml(string $xmlString, ?int $fetchedAtUnix = null): PodcastData { 43 | $xml = new SimpleXMLElement($xmlString); 44 | $channel = $xml->channel; 45 | return new PodcastData( 46 | self::stringOrNull($channel->title), 47 | self::getXPathContent($xml, '/rss/channel/itunes:author'), 48 | self::stringOrNull($channel->link), 49 | self::stringOrNull($channel->description), 50 | self::getXPathContent($xml, '/rss/channel/image/url') 51 | ?? self::getXPathAttribute($xml, '/rss/channel/itunes:image/@href'), 52 | $fetchedAtUnix ?? (new DateTime())->getTimestamp() 53 | ); 54 | } 55 | 56 | private static function stringOrNull($value): ?string { 57 | if ($value) { 58 | return (string)$value; 59 | } 60 | return null; 61 | } 62 | 63 | private static function getXPathContent(SimpleXMLElement $xml, string $xpath): ?string { 64 | $match = $xml->xpath($xpath); 65 | if ($match) { 66 | return (string)$match[0]; 67 | } 68 | return null; 69 | } 70 | 71 | private static function getXPathAttribute(SimpleXMLElement $xml, string $xpath): ?string { 72 | $match = $xml->xpath($xpath); 73 | if ($match) { 74 | return (string)$match[0][0]; 75 | } 76 | return null; 77 | } 78 | 79 | /** 80 | * @return string|null 81 | */ 82 | public function getTitle(): ?string { 83 | return $this->title; 84 | } 85 | 86 | /** 87 | * @return string|null 88 | */ 89 | public function getAuthor(): ?string { 90 | return $this->author; 91 | } 92 | 93 | /** 94 | * @return string|null 95 | */ 96 | public function getLink(): ?string { 97 | return $this->link; 98 | } 99 | 100 | /** 101 | * @return string|null 102 | */ 103 | public function getDescription(): ?string { 104 | return $this->description; 105 | } 106 | 107 | /** 108 | * @return string|null 109 | */ 110 | public function getImageUrl(): ?string { 111 | return $this->imageUrl; 112 | } 113 | 114 | /** 115 | * @return int|null 116 | */ 117 | public function getFetchedAtUnix(): ?int { 118 | return $this->fetchedAtUnix; 119 | } 120 | 121 | /** 122 | * @return string|null 123 | */ 124 | public function getImageBlob(): ?string { 125 | return $this->imageBlob; 126 | } 127 | 128 | /** 129 | * @param string $blob 130 | * @return void 131 | */ 132 | public function setImageBlob(?string $blob): void { 133 | $this->imageBlob = $blob; 134 | } 135 | 136 | /** 137 | * @return string 138 | */ 139 | public function __toString() : string { 140 | return $this->title ?? '/no title/'; 141 | } 142 | 143 | /** 144 | * @return array 145 | */ 146 | public function toArray(): array { 147 | return 148 | [ 149 | 'title' => $this->title, 150 | 'author' => $this->author, 151 | 'link' => $this->link, 152 | 'description' => $this->description, 153 | 'imageUrl' => $this->imageUrl, 154 | 'imageBlob' => $this->imageBlob, 155 | 'fetchedAtUnix' => $this->fetchedAtUnix, 156 | ]; 157 | } 158 | 159 | /** 160 | * @return array 161 | */ 162 | public function jsonSerialize(): array { 163 | return $this->toArray(); 164 | } 165 | 166 | /** 167 | * @return PodcastData 168 | */ 169 | public static function fromArray(array $data): PodcastData { 170 | return new PodcastData( 171 | $data['title'], 172 | $data['author'], 173 | $data['link'], 174 | $data['description'], 175 | $data['imageUrl'], 176 | $data['fetchedAtUnix'], 177 | $data['imageBlob'] 178 | ); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/Unit/Core/PodcastData/PodcastDataTest.php: -------------------------------------------------------------------------------- 1 | 'title1', 15 | 'author' => 'author1', 16 | 'link' => 'http://example.com/', 17 | 'description' => 'description1', 18 | 'imageUrl' => 'http://example.com/image.jpg', 19 | 'imageBlob' => null, 20 | 'fetchedAtUnix' => 1337, 21 | ]; 22 | $this->assertSame($expected, $podcastData->toArray()); 23 | } 24 | 25 | public function testFromArray(): void { 26 | $podcastData = new PodcastData('title1', 'author1', 'http://example.com/', 'description1', 'http://example.com/image.jpg', 1337); 27 | $expected = $podcastData->toArray(); 28 | $fromArray = PodcastData::fromArray($expected); 29 | $this->assertSame($expected, $fromArray->toArray()); 30 | } 31 | 32 | public function testParseRssXml(): void { 33 | $xml = ' 34 | 41 | 42 | The title of this Podcast 43 | All rights reserved 44 | http://example.com 45 | 46 | 47 | en-us 48 | Some long description 49 | The Podcast Author 50 | Some long description 51 | no 52 | 53 | nextcloud, nextpod 54 | 55 | Owner of the podcast 56 | editors@example.com 57 | 58 | 59 | 60 | 61 | Support our work 62 | thrillfall 63 | jilleJr 64 | 65 | 66 | '; 67 | 68 | $podcastData = PodcastData::parseRssXml($xml, 1337); 69 | $expected = [ 70 | 'title' => 'The title of this Podcast', 71 | 'author' => 'The Podcast Author', 72 | 'link' => 'http://example.com', 73 | 'description' => 'Some long description', 74 | 'imageUrl' => 'https://example.com/image.jpg', 75 | 'imageBlob' => null, 76 | 'fetchedAtUnix' => 1337, 77 | ]; 78 | $this->assertSame($expected, $podcastData->toArray()); 79 | } 80 | 81 | public function testParseRssXmlPartial(): void { 82 | $xml = ' 83 | 90 | 91 | The title of this Podcast 92 | All rights reserved 93 | http://example.com 94 | The Podcast Author 95 | 96 | Some image 97 | 98 | 99 | 100 | 101 | '; 102 | 103 | $podcastData = PodcastData::parseRssXml($xml, 1337); 104 | $expected = [ 105 | 'title' => 'The title of this Podcast', 106 | 'author' => 'The Podcast Author', 107 | 'link' => 'http://example.com', 108 | 'description' => null, 109 | 'imageUrl' => null, 110 | 'imageBlob' => null, 111 | 'fetchedAtUnix' => 1337, 112 | ]; 113 | $this->assertSame($expected, $podcastData->toArray()); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 119 | 120 | 125 | -------------------------------------------------------------------------------- /src/views/Podcasts.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 121 | 122 | 139 | -------------------------------------------------------------------------------- /lib/Core/EpisodeAction/EpisodeActionReader.php: -------------------------------------------------------------------------------- 1 | isLocalCacheAvailable()) { 27 | $this->cache = $cacheFactory->createLocal('NextPod-Actions-Per7Cn'); 28 | } 29 | $this->httpClient = $httpClientService->newClient(); 30 | $this->episodeActionRepository = $episodeActionRepository; 31 | } 32 | 33 | public function getCachedOrFetchActionExtraData(string $url, string $userId): ?EpisodeActionExtraData { 34 | // Set cache to null for debugging only 35 | // $this->cache = null; 36 | 37 | if ($this->cache == null) { 38 | return $this->fetchActionExtraData($url, $userId); 39 | } 40 | $oldData = $this->tryGetCachedActionExtraData($url); 41 | if ($oldData) { 42 | return $oldData; 43 | } 44 | $newData = $this->fetchActionExtraData($url, $userId); 45 | $this->trySetCachedActionExtraData($url, $newData); 46 | return $newData; 47 | } 48 | 49 | public function tryGetCachedActionExtraData(string $url): ?EpisodeActionExtraData { 50 | $oldData = $this->cache->get($url); 51 | if (!$oldData) { 52 | return null; 53 | } 54 | return EpisodeActionExtraData::fromArray($oldData); 55 | } 56 | 57 | public function trySetCachedActionExtraData(string $url, EpisodeActionExtraData $data): bool { 58 | return $this->cache->set($url, $data->toArray()); 59 | } 60 | 61 | /** 62 | * @param array $episodeActionsArray [] 63 | * @return EpisodeAction[] 64 | * @throws InvalidArgumentException 65 | */ 66 | public function fromArray(array $episodeActionsArray): array { 67 | $episodeActions = []; 68 | 69 | foreach ($episodeActionsArray as $episodeAction) { 70 | if ($this->hasRequiredProperties($episodeAction) === false) { 71 | throw new InvalidArgumentException(sprintf('Client sent incomplete or invalid data: %s', json_encode($episodeAction, JSON_THROW_ON_ERROR))); 72 | } 73 | $episodeActions[] = new EpisodeAction( 74 | $episodeAction["podcast"], 75 | $episodeAction["episode"], 76 | strtoupper($episodeAction["action"]), 77 | $episodeAction["timestamp"], 78 | $episodeAction["started"] ?? -1, 79 | $episodeAction["position"] ?? -1, 80 | $episodeAction["total"] ?? -1, 81 | $episodeAction["guid"] ?? null, 82 | null 83 | ); 84 | } 85 | 86 | return $episodeActions; 87 | } 88 | 89 | /** 90 | * @param string $userId 91 | * 92 | * @return EpisodeActionData[] 93 | */ 94 | public function actions(string $userId, $sort = '', $order = 'DESC'): array { 95 | $episodeActions = $this->episodeActionRepository->findAll(0, $userId, $sort, $order); 96 | $episodeActionDataList = []; 97 | 98 | foreach ($episodeActions as $episodeAction) { 99 | $episodeActionData = new EpisodeActionData( 100 | $episodeAction->getGuid(), 101 | $episodeAction->getPodcast(), 102 | $episodeAction->getEpisode(), 103 | $episodeAction->getAction(), 104 | $episodeAction->getPosition(), 105 | $episodeAction->getStarted(), 106 | $episodeAction->getTotal() 107 | ); 108 | $episodeActionDataList[] = $episodeActionData; 109 | } 110 | 111 | return $episodeActionDataList; 112 | } 113 | 114 | /** 115 | * @param array $episodeAction 116 | * @return bool 117 | */ 118 | private function hasRequiredProperties(array $episodeAction): bool { 119 | return (count(array_intersect($this->requiredProperties, array_keys($episodeAction))) === count($this->requiredProperties)); 120 | } 121 | 122 | public function fetchActionExtraData(string $episodeUrl, string $userId): ?EpisodeActionExtraData { 123 | if (!$this->userHasAction($episodeUrl, $userId)) { 124 | return null; 125 | } 126 | 127 | $episodeAction = $this->episodeActionRepository->findByEpisodeUrl($episodeUrl, $userId); 128 | 129 | $resp = $this->fetchUrl($episodeAction->getPodcast()); 130 | $data = EpisodeActionExtraData::parseRssXml($resp->getBody(), $episodeUrl); 131 | 132 | return $data; 133 | } 134 | 135 | private function userHasAction(string $url, string $userId): bool { 136 | $episodeAction = $this->episodeActionRepository->findByEpisodeUrl($url, $userId); 137 | return $episodeAction !== null; 138 | } 139 | 140 | private function fetchUrl(string $url): IResponse { 141 | $resp = $this->httpClient->get($url); 142 | $statusCode = $resp->getStatusCode(); 143 | if ($statusCode < 200 || $statusCode >= 300) { 144 | throw new \ErrorException("Web request returned non-2xx status code: $statusCode"); 145 | } 146 | return $resp; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/Core/EpisodeAction/EpisodeActionSaver.php: -------------------------------------------------------------------------------- 1 | episodeActionRepository = $episodeActionRepository; 27 | $this->episodeActionWriter = $episodeActionWriter; 28 | $this->episodeActionReader = $episodeActionReader; 29 | } 30 | 31 | public function saveEpisodeActions(array $episodeActionsArray, string $userId): array { 32 | $episodeActions = $this->episodeActionReader->fromArray($episodeActionsArray); 33 | 34 | $episodeActionEntities = []; 35 | 36 | foreach ($episodeActions as $episodeAction) { 37 | $episodeActionEntity = $this->hydrateEpisodeActionEntity($episodeAction, $userId); 38 | 39 | try { 40 | $episodeActionEntities[] = $this->episodeActionWriter->save($episodeActionEntity); 41 | } catch (Exception $exception) { 42 | if ($exception->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { 43 | $episodeActionEntities[] = $this->updateEpisodeAction($episodeActionEntity, $userId); 44 | } 45 | } 46 | } 47 | return $episodeActionEntities; 48 | } 49 | 50 | private function convertTimestampToUnixEpoch(string $timestamp): string { 51 | return DateTime::createFromFormat(self::DATETIME_FORMAT, $timestamp) 52 | ->format("U"); 53 | } 54 | 55 | private function updateEpisodeAction( 56 | EpisodeActionEntity $episodeActionEntity, 57 | string $userId 58 | ): EpisodeActionEntity { 59 | $episodeActionToUpdate = $this->findEpisodeActionToUpdate($episodeActionEntity, $userId); 60 | 61 | $episodeActionEntity->setId($episodeActionToUpdate->getId()); 62 | 63 | $this->ensureGuidDoesNotGetNulledWithOldData($episodeActionToUpdate, $episodeActionEntity); 64 | 65 | try { 66 | return $this->episodeActionWriter->update($episodeActionEntity); 67 | } catch (Exception $exception) { 68 | if ($exception->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { 69 | $this->deleteConflictingEpisodeAction($episodeActionEntity, $userId); 70 | } 71 | } 72 | return $this->episodeActionWriter->update($episodeActionEntity); 73 | 74 | } 75 | 76 | private function ensureGuidDoesNotGetNulledWithOldData(EpisodeAction $episodeActionToUpdate, EpisodeActionEntity $episodeActionEntity): void { 77 | $existingGuid = $episodeActionToUpdate->getGuid(); 78 | if ($existingGuid !== null && $episodeActionEntity->getGuid() === null) { 79 | $episodeActionEntity->setGuid($existingGuid); 80 | } 81 | } 82 | 83 | private function hydrateEpisodeActionEntity(EpisodeAction $episodeAction, string $userId): EpisodeActionEntity { 84 | $episodeActionEntity = new EpisodeActionEntity(); 85 | $episodeActionEntity->setPodcast($episodeAction->getPodcast()); 86 | $episodeActionEntity->setEpisode($episodeAction->getEpisode()); 87 | $episodeActionEntity->setGuid($episodeAction->getGuid()); 88 | $episodeActionEntity->setAction($episodeAction->getAction()); 89 | $episodeActionEntity->setPosition($episodeAction->getPosition()); 90 | $episodeActionEntity->setStarted($episodeAction->getStarted()); 91 | $episodeActionEntity->setTotal($episodeAction->getTotal()); 92 | $episodeActionEntity->setTimestampEpoch($this->convertTimestampToUnixEpoch($episodeAction->getTimestamp())); 93 | $episodeActionEntity->setUserId($userId); 94 | 95 | return $episodeActionEntity; 96 | } 97 | 98 | private function findEpisodeActionToUpdate(EpisodeActionEntity $episodeActionEntity, string $userId): ?EpisodeAction { 99 | $episodeAction = null; 100 | if ($episodeActionEntity->getGuid() !== null) { 101 | $episodeAction = $this->episodeActionRepository->findByGuid( 102 | $episodeActionEntity->getGuid(), 103 | $userId 104 | ); 105 | } 106 | 107 | if ($episodeAction === null) { 108 | $episodeAction = $this->episodeActionRepository->findByEpisodeUrl( 109 | $episodeActionEntity->getEpisode(), 110 | $userId 111 | ); 112 | } 113 | 114 | return $episodeAction; 115 | } 116 | 117 | /** 118 | * @param EpisodeActionEntity $episodeActionEntity 119 | * @param string $userId 120 | * @return void 121 | */ 122 | private function deleteConflictingEpisodeAction(EpisodeActionEntity $episodeActionEntity, string $userId): void { 123 | $collidingEpisodeActionId = $this->episodeActionRepository->findByEpisodeUrl($episodeActionEntity->getGuid(), $userId)->getId(); 124 | if ($collidingEpisodeActionId !== $episodeActionEntity->getId()) { 125 | $this->episodeActionRepository->deleteEpisodeActionByEpisodeUrl($episodeActionEntity->getGuid(), $userId); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/views/Actions.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 144 | 145 | 162 | -------------------------------------------------------------------------------- /tests/Unit/Core/EpisodeAction/EpisodeActionReaderTest.php: -------------------------------------------------------------------------------- 1 | clientService = $this->createMock(IClientService::class); 26 | $this->episodeActionRepository = $this->createMock(EpisodeActionRepository::class); 27 | $this->iCacheFactory = $this->createMock(ICacheFactory::class); 28 | } 29 | 30 | public function testCreateFromArray(): void { 31 | $reader = new EpisodeActionReader($this->clientService, $this->episodeActionRepository, $this->iCacheFactory); 32 | $episodeActions = $reader->fromArray([["podcast" => "https://example.org/feed.xml", "episode" => "https://example.org/episode1.mp3", "action" => "PLAY", "timestamp" => "2021-10-03T12:03:17", "started" => 0, "position" => 50, "total" => 3422]]); 33 | 34 | $this->assertSame("https://example.org/feed.xml", $episodeActions[0]->getPodcast()); 35 | $this->assertSame("https://example.org/episode1.mp3", $episodeActions[0]->getEpisode()); 36 | $this->assertSame("PLAY", $episodeActions[0]->getAction()); 37 | $this->assertSame("2021-10-03T12:03:17", $episodeActions[0]->getTimestamp()); 38 | $this->assertSame(0, $episodeActions[0]->getStarted()); 39 | $this->assertSame(50, $episodeActions[0]->getPosition()); 40 | $this->assertSame(3422, $episodeActions[0]->getTotal()); 41 | } 42 | 43 | public function testCreateFromMultipleEpisodesArray(): void { 44 | $reader = new EpisodeActionReader($this->clientService, $this->episodeActionRepository, $this->iCacheFactory); 45 | $episodeActions = $reader->fromArray([ 46 | ["podcast" => "https://example.org/feed.xml", "episode" => "https://example.org/episode1.mp3", "guid" => "episode1", "action" => "PLAY", "timestamp" => "2021-10-03T12:03:17", "started" => 0, "position" => 50, "total" => 3422], 47 | ["podcast" => "https://example.org/feed.xml", "episode" => "https://example.org/episode2.mp3", "guid" => "episode2", "action" => "download", "timestamp" => "2021-10-03T12:03:17"], 48 | ["podcast" => "https://example.com/feed.xml", "episode" => "https://chrt.fm/track/47G541/injector.simplecastaudio.com/f16c3da7-cf46-4a42-99b7-8467255c6086/episodes/e8e24c01-6157-40e8-9b5a-45d539aeb7e6/audio/128/default.mp3?aid=rss_feed&awCollectionId=f16c3da7-cf46-4a42-99b7-8467255c6086&awEpisodeId=e8e24c01-6157-40e8-9b5a-45d539aeb7e6&feed=wEl4UUJZ", "guid" => "EPISODE-001-EXAMPLE-COM", "action" => "PLAY", "timestamp" => "2021-10-03T12:03:17", "started" => 50, "position" => 221, "total" => 450] 49 | ]); 50 | 51 | $this->assertSame("https://example.org/feed.xml", $episodeActions[0]->getPodcast()); 52 | $this->assertSame("https://example.org/episode1.mp3", $episodeActions[0]->getEpisode()); 53 | $this->assertSame("episode1", $episodeActions[0]->getGuid()); 54 | $this->assertSame("PLAY", $episodeActions[0]->getAction()); 55 | $this->assertSame("2021-10-03T12:03:17", $episodeActions[0]->getTimestamp()); 56 | $this->assertSame(0, $episodeActions[0]->getStarted()); 57 | $this->assertSame(50, $episodeActions[0]->getPosition()); 58 | $this->assertSame(3422, $episodeActions[0]->getTotal()); 59 | 60 | $this->assertSame("https://example.org/feed.xml", $episodeActions[1]->getPodcast()); 61 | $this->assertSame("https://example.org/episode2.mp3", $episodeActions[1]->getEpisode()); 62 | $this->assertSame("episode2", $episodeActions[1]->getGuid()); 63 | $this->assertSame("DOWNLOAD", $episodeActions[1]->getAction()); 64 | $this->assertSame("2021-10-03T12:03:17", $episodeActions[1]->getTimestamp()); 65 | $this->assertSame(-1, $episodeActions[1]->getStarted()); 66 | $this->assertSame(-1, $episodeActions[1]->getPosition()); 67 | $this->assertSame(-1, $episodeActions[1]->getTotal()); 68 | 69 | $this->assertSame("https://example.com/feed.xml", $episodeActions[2]->getPodcast()); 70 | $this->assertSame("https://chrt.fm/track/47G541/injector.simplecastaudio.com/f16c3da7-cf46-4a42-99b7-8467255c6086/episodes/e8e24c01-6157-40e8-9b5a-45d539aeb7e6/audio/128/default.mp3?aid=rss_feed&awCollectionId=f16c3da7-cf46-4a42-99b7-8467255c6086&awEpisodeId=e8e24c01-6157-40e8-9b5a-45d539aeb7e6&feed=wEl4UUJZ", $episodeActions[2]->getEpisode()); 71 | $this->assertSame("EPISODE-001-EXAMPLE-COM", $episodeActions[2]->getGuid()); 72 | $this->assertSame("PLAY", $episodeActions[2]->getAction()); 73 | $this->assertSame("2021-10-03T12:03:17", $episodeActions[2]->getTimestamp()); 74 | $this->assertSame(50, $episodeActions[2]->getStarted()); 75 | $this->assertSame(221, $episodeActions[2]->getPosition()); 76 | $this->assertSame(450, $episodeActions[2]->getTotal()); 77 | } 78 | 79 | public function testCreateWithFaultyData(): void { 80 | $this->expectException(\InvalidArgumentException::class); 81 | $this->expectExceptionMessage('Client sent incomplete or invalid data: {"podcast":"https:\/\/example.org\/feed.xml","action":"download","timestamp":"2021-10-03T12:03:17"}'); 82 | (new EpisodeActionReader($this->clientService, $this->episodeActionRepository, $this->iCacheFactory))->fromArray([ 83 | ["podcast" => "https://example.org/feed.xml", "action" => "download", "timestamp" => "2021-10-03T12:03:17"], 84 | ["podcast" => "https://example.org/feed.xml", "episode" => "https://example.org/episode2.mp3", "guid" => "episode2", "action" => "download", "timestamp" => "2021-10-03T12:03:17"], 85 | ]); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /lib/Core/EpisodeAction/EpisodeActionExtraData.php: -------------------------------------------------------------------------------- 1 | episodeUrl = $episodeUrl; 32 | $this->podcastName = $podcastName; 33 | $this->episodeName = $episodeName; 34 | $this->episodeLink = $episodeLink; 35 | $this->episodeImage = $episodeImage; 36 | $this->episodeDescription = $episodeDescription; 37 | $this->fetchedAtUnix = $fetchedAtUnix; 38 | } 39 | 40 | /** 41 | * @return string|null 42 | */ 43 | public function getEpisodeUrl(): ?string { 44 | return $this->episodeUrl; 45 | } 46 | 47 | /** 48 | * @return string 49 | */ 50 | public function __toString() : string { 51 | return $this->episodeUrl ?? '/no episodeUrl/'; 52 | } 53 | 54 | /** 55 | * @return array 56 | */ 57 | public function toArray(): array { 58 | return 59 | [ 60 | 'podcastName' => $this->podcastName, 61 | 'episodeUrl' => $this->episodeUrl, 62 | 'episodeName' => $this->episodeName, 63 | 'episodeLink' => $this->episodeLink, 64 | 'episodeImage' => $this->episodeImage, 65 | 'episodeDescription' => $this->episodeDescription, 66 | 'fetchedAtUnix' => $this->fetchedAtUnix, 67 | ]; 68 | } 69 | 70 | /** 71 | * @return array 72 | */ 73 | public function jsonSerialize(): array { 74 | return $this->toArray(); 75 | } 76 | 77 | /** 78 | * @return EpisodeActionExtraData 79 | */ 80 | public static function fromArray(array $data): EpisodeActionExtraData { 81 | return new EpisodeActionExtraData( 82 | $data['episodeUrl'], 83 | $data['podcastName'], 84 | $data['episodeName'], 85 | $data['episodeLink'], 86 | $data['episodeImage'], 87 | $data['episodeDescription'], 88 | $data['fetchedAtUnix'] 89 | ); 90 | } 91 | 92 | /** 93 | * @return string|null 94 | */ 95 | public function getPodcastName(): ?string { 96 | return $this->podcastName; 97 | } 98 | 99 | /** 100 | * @return string|null 101 | */ 102 | public function getEpisodeName(): ?string { 103 | return $this->episodeName; 104 | } 105 | 106 | /** 107 | * @return string|null 108 | */ 109 | public function getEpisodeLink(): ?string { 110 | return $this->episodeLink; 111 | } 112 | 113 | /** 114 | * @return PodcastData 115 | * @throws Exception if the XML data could not be parsed. 116 | */ 117 | public static function parseRssXml(string $xmlString, string $episodeUrl, ?int $fetchedAtUnix = null): EpisodeActionExtraData { 118 | $xml = new SimpleXMLElement($xmlString); 119 | $channel = $xml->channel; 120 | $episodeName = null; 121 | $episodeLink = null; 122 | $episodeImage = null; 123 | $episodeDescription = null; 124 | $episodeUrlPath = parse_url($episodeUrl, PHP_URL_PATH); 125 | 126 | // Find episode by url and add data for it 127 | foreach ($channel->item as $item) { 128 | $url = (string)$item->enclosure['url']; 129 | 130 | // First try to match the url directly 131 | if (strpos($episodeUrl, $url) === false) { 132 | // Then try to match the path only 133 | // The podcast http://feeds.feedburner.com/abcradio/10percenthappier has a "rss_browser" query parameter 134 | // for every item that changed all the time, so we can't match the full url 135 | $path = parse_url($url, PHP_URL_PATH); 136 | 137 | if ($episodeUrlPath !== $path) { 138 | continue; 139 | } 140 | } 141 | 142 | // Get episode name 143 | $episodeName = self::stringOrNull($item->title); 144 | 145 | // Get episode link 146 | $episodeLink = self::stringOrNull($item->link); 147 | 148 | // 149 | // Get episode image 150 | // 151 | 152 | $episodeImageAttributes = (array) $item->children('http://www.itunes.com/dtds/podcast-1.0.dtd')->image->attributes(); 153 | $episodeImage = self::stringOrNull(array_key_exists('href', $episodeImageAttributes) ? (string) $episodeImageAttributes['href'] : ''); 154 | 155 | if (!$episodeImage) { 156 | $episodeImage = self::stringOrNull((string) $item->children('itunes', true)->image['href']); 157 | } 158 | 159 | if (!$episodeImage) { 160 | $episodeImage = self::stringOrNull($channel->image->url); 161 | } 162 | 163 | if (!$episodeImage) { 164 | $episodeImage = self::stringOrNull((string) $channel->children('itunes', true)->image['href']); 165 | } 166 | 167 | if (!$episodeImage) { 168 | $episodeImageAttributes = (array) $channel->children('http://www.itunes.com/dtds/podcast-1.0.dtd')->image->attributes(); 169 | $episodeImage = self::stringOrNull(array_key_exists('href', $episodeImageAttributes) ? (string) $episodeImageAttributes['href'] : ''); 170 | } 171 | 172 | if (!$episodeImage) { 173 | preg_match('/children('content', true)->encoded); 182 | 183 | if (!$episodeDescription) { 184 | $episodeDescription = self::stringOrNull($item->description); 185 | } 186 | 187 | // Open links in new browser window/tab 188 | $episodeDescription = str_replace('title), 196 | $episodeName, 197 | $episodeLink, 198 | $episodeImage, 199 | $episodeDescription, 200 | $fetchedAtUnix ?? (new DateTime())->getTimestamp() 201 | ); 202 | } 203 | 204 | private static function stringOrNull($value): ?string { 205 | if ($value) { 206 | return (string)$value; 207 | } 208 | return null; 209 | } 210 | 211 | /** 212 | * @return int 213 | */ 214 | public function getFetchedAtUnix(): int { 215 | return $this->fetchedAtUnix; 216 | } 217 | 218 | /** 219 | * @return string|null 220 | */ 221 | public function getEpisodeImage(): ?string { 222 | return $this->episodeImage; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "🚃 PHPUnit" 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | workflow_dispatch: 9 | 10 | env: 11 | APP_NAME: nextpod 12 | 13 | jobs: 14 | php: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | # do not stop on another job's failure 19 | fail-fast: false 20 | matrix: 21 | php-versions: ["8.1", "8.2"] 22 | databases: ["sqlite"] 23 | server-versions: 24 | ["stable27", "stable28", "stable29", "stable30", "stable31"] 25 | 26 | name: php${{ matrix.php-versions }}-${{ matrix.databases }}-${{ matrix.server-versions }} 27 | 28 | steps: 29 | - name: Checkout server 30 | uses: actions/checkout@v4 31 | with: 32 | repository: nextcloud/server 33 | ref: ${{ matrix.server-versions }} 34 | 35 | - name: Checkout submodules 36 | shell: bash 37 | run: | 38 | auth_header="$(git config --local --get http.https://github.com/.extraheader)" 39 | git submodule sync --recursive 40 | git -c "http.extraheader=$auth_header" -c protocol.version=2 submodule update --init --force --recursive --depth=1 41 | 42 | - name: Checkout app 43 | uses: actions/checkout@v4 44 | with: 45 | path: apps/${{ env.APP_NAME }} 46 | 47 | - name: Set up php ${{ matrix.php-versions }} 48 | uses: shivammathur/setup-php@v2 49 | with: 50 | php-version: ${{ matrix.php-versions }} 51 | extensions: mbstring, iconv, fileinfo, intl, sqlite, pdo_sqlite, gd, zip 52 | coverage: none 53 | 54 | - name: Set up PHPUnit 55 | working-directory: apps/${{ env.APP_NAME }} 56 | run: composer i 57 | 58 | - name: Set up Nextcloud 59 | env: 60 | DB_PORT: 4444 61 | run: | 62 | mkdir data 63 | ./occ maintenance:install --verbose --database=${{ matrix.databases }} --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass password 64 | php -f index.php 65 | ./occ app:enable --force ${{ env.APP_NAME }} 66 | php -S localhost:8080 & 67 | 68 | - name: PHPUnit 69 | working-directory: apps/${{ env.APP_NAME }} 70 | run: ./vendor/bin/phpunit -c tests/phpunit.xml 71 | 72 | mysql: 73 | runs-on: ubuntu-latest 74 | 75 | strategy: 76 | # do not stop on another job's failure 77 | fail-fast: false 78 | matrix: 79 | php-versions: ["8.1", "8.2"] 80 | databases: ["mysql"] 81 | server-versions: 82 | ["stable27", "stable28", "stable29", "stable30", "stable31"] 83 | 84 | name: php${{ matrix.php-versions }}-${{ matrix.databases }}-${{ matrix.server-versions }} 85 | 86 | services: 87 | mysql: 88 | image: mariadb:10.5 89 | ports: 90 | - 4444:3306/tcp 91 | env: 92 | MYSQL_ROOT_PASSWORD: rootpassword 93 | options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 5 94 | 95 | steps: 96 | - name: Checkout server 97 | uses: actions/checkout@v4 98 | with: 99 | repository: nextcloud/server 100 | ref: ${{ matrix.server-versions }} 101 | 102 | - name: Checkout submodules 103 | shell: bash 104 | run: | 105 | auth_header="$(git config --local --get http.https://github.com/.extraheader)" 106 | git submodule sync --recursive 107 | git -c "http.extraheader=$auth_header" -c protocol.version=2 submodule update --init --force --recursive --depth=1 108 | 109 | - name: Checkout app 110 | uses: actions/checkout@v4 111 | with: 112 | path: apps/${{ env.APP_NAME }} 113 | 114 | - name: Set up php ${{ matrix.php-versions }} 115 | uses: shivammathur/setup-php@v2 116 | with: 117 | php-version: ${{ matrix.php-versions }} 118 | extensions: mbstring, iconv, fileinfo, intl, mysql, pdo_mysql, gd, zip 119 | coverage: none 120 | 121 | - name: Set up PHPUnit 122 | working-directory: apps/${{ env.APP_NAME }} 123 | run: composer i 124 | 125 | - name: Set up Nextcloud 126 | env: 127 | DB_PORT: 4444 128 | run: | 129 | mkdir data 130 | ./occ maintenance:install --verbose --database=${{ matrix.databases }} --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass password 131 | php -f index.php 132 | ./occ app:enable --force ${{ env.APP_NAME }} 133 | php -S localhost:8080 & 134 | 135 | - name: PHPUnit 136 | working-directory: apps/${{ env.APP_NAME }} 137 | run: ./vendor/bin/phpunit -c tests/phpunit.xml 138 | 139 | pgsql: 140 | runs-on: ubuntu-latest 141 | 142 | strategy: 143 | # do not stop on another job's failure 144 | fail-fast: false 145 | matrix: 146 | php-versions: ["8.1", "8.2"] 147 | databases: ["pgsql"] 148 | server-versions: 149 | ["stable27", "stable28", "stable29", "stable30", "stable31"] 150 | 151 | name: php${{ matrix.php-versions }}-${{ matrix.databases }}-${{ matrix.server-versions }} 152 | 153 | services: 154 | postgres: 155 | image: postgres:14.5 156 | ports: 157 | - 4444:5432/tcp 158 | env: 159 | POSTGRES_USER: root 160 | POSTGRES_PASSWORD: rootpassword 161 | POSTGRES_DB: nextcloud 162 | options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 163 | 164 | steps: 165 | - name: Checkout server 166 | uses: actions/checkout@v4 167 | with: 168 | repository: nextcloud/server 169 | ref: ${{ matrix.server-versions }} 170 | 171 | - name: Checkout submodules 172 | shell: bash 173 | run: | 174 | auth_header="$(git config --local --get http.https://github.com/.extraheader)" 175 | git submodule sync --recursive 176 | git -c "http.extraheader=$auth_header" -c protocol.version=2 submodule update --init --force --recursive --depth=1 177 | 178 | - name: Checkout app 179 | uses: actions/checkout@v4 180 | with: 181 | path: apps/${{ env.APP_NAME }} 182 | 183 | - name: Set up php ${{ matrix.php-versions }} 184 | uses: shivammathur/setup-php@v2 185 | with: 186 | php-version: ${{ matrix.php-versions }} 187 | extensions: mbstring, iconv, fileinfo, intl, pgsql, pdo_pgsql, gd, zip 188 | coverage: none 189 | 190 | - name: Set up PHPUnit 191 | working-directory: apps/${{ env.APP_NAME }} 192 | run: composer i 193 | 194 | - name: Set up Nextcloud 195 | env: 196 | DB_PORT: 4444 197 | run: | 198 | mkdir data 199 | ./occ maintenance:install --verbose --database=${{ matrix.databases }} --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass password 200 | php -f index.php 201 | ./occ app:enable --force ${{ env.APP_NAME }} 202 | php -S localhost:8080 & 203 | 204 | - name: PHPUnit 205 | working-directory: apps/${{ env.APP_NAME }} 206 | run: ./vendor/bin/phpunit -c tests/phpunit.xml 207 | -------------------------------------------------------------------------------- /src/AppExample.vue: -------------------------------------------------------------------------------- 1 | 156 | 157 | 218 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | chown www-data:root /var/www/deploy 5 | chown www-data:root /var/www/html/custom_apps 6 | chown www-data:root /var/www/html/config 7 | 8 | # version_greater A B returns whether A > B 9 | version_greater() { 10 | [ "$(printf '%s\n' "$@" | sort -t '.' -n -k1,1 -k2,2 -k3,3 -k4,4 | head -n 1)" != "$1" ] 11 | } 12 | 13 | # return true if specified directory is empty 14 | directory_empty() { 15 | [ -z "$(ls -A "$1/")" ] 16 | } 17 | 18 | run_as() { 19 | if [ "$(id -u)" = 0 ]; then 20 | su -p www-data -s /bin/sh -c "$1" 21 | else 22 | sh -c "$1" 23 | fi 24 | } 25 | 26 | # usage: file_env VAR [DEFAULT] 27 | # ie: file_env 'XYZ_DB_PASSWORD' 'example' 28 | # (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of 29 | # "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature) 30 | file_env() { 31 | local var="$1" 32 | local fileVar="${var}_FILE" 33 | local def="${2:-}" 34 | local varValue=$(env | grep -E "^${var}=" | sed -E -e "s/^${var}=//") 35 | local fileVarValue=$(env | grep -E "^${fileVar}=" | sed -E -e "s/^${fileVar}=//") 36 | if [ -n "${varValue}" ] && [ -n "${fileVarValue}" ]; then 37 | echo >&2 "error: both $var and $fileVar are set (but are exclusive)" 38 | exit 1 39 | fi 40 | if [ -n "${varValue}" ]; then 41 | export "$var"="${varValue}" 42 | elif [ -n "${fileVarValue}" ]; then 43 | export "$var"="$(cat "${fileVarValue}")" 44 | elif [ -n "${def}" ]; then 45 | export "$var"="$def" 46 | fi 47 | unset "$fileVar" 48 | } 49 | 50 | if expr "$1" : "apache" 1>/dev/null; then 51 | if [ -n "${APACHE_DISABLE_REWRITE_IP+x}" ]; then 52 | a2disconf remoteip 53 | fi 54 | fi 55 | 56 | if expr "$1" : "apache" 1>/dev/null || [ "$1" = "php-fpm" ] || [ "${NEXTCLOUD_UPDATE:-0}" -eq 1 ]; then 57 | if [ -n "${REDIS_HOST+x}" ]; then 58 | 59 | echo "Configuring Redis as session handler" 60 | { 61 | echo 'session.save_handler = redis' 62 | # check if redis host is an unix socket path 63 | if [ "$(echo "$REDIS_HOST" | cut -c1-1)" = "/" ]; then 64 | if [ -n "${REDIS_HOST_PASSWORD+x}" ]; then 65 | echo "session.save_path = \"unix://${REDIS_HOST}?auth=${REDIS_HOST_PASSWORD}\"" 66 | else 67 | echo "session.save_path = \"unix://${REDIS_HOST}\"" 68 | fi 69 | # check if redis password has been set 70 | elif [ -n "${REDIS_HOST_PASSWORD+x}" ]; then 71 | echo "session.save_path = \"tcp://${REDIS_HOST}:${REDIS_HOST_PORT:=6379}?auth=${REDIS_HOST_PASSWORD}\"" 72 | else 73 | echo "session.save_path = \"tcp://${REDIS_HOST}:${REDIS_HOST_PORT:=6379}\"" 74 | fi 75 | } >/usr/local/etc/php/conf.d/redis-session.ini 76 | fi 77 | 78 | installed_version="0.0.0.0" 79 | if [ -f /var/www/html/version.php ]; then 80 | # shellcheck disable=SC2016 81 | installed_version="$(php -r 'require "/var/www/html/version.php"; echo implode(".", $OC_Version);')" 82 | fi 83 | # shellcheck disable=SC2016 84 | image_version="$(php -r 'require "/usr/src/nextcloud/version.php"; echo implode(".", $OC_Version);')" 85 | 86 | if version_greater "$installed_version" "$image_version"; then 87 | echo "Can't start Nextcloud because the version of the data ($installed_version) is higher than the docker image version ($image_version) and downgrading is not supported. Are you sure you have pulled the newest image version?" 88 | exit 1 89 | fi 90 | 91 | if version_greater "$image_version" "$installed_version"; then 92 | echo "Initializing nextcloud $image_version ..." 93 | if [ "$installed_version" != "0.0.0.0" ]; then 94 | echo "Upgrading nextcloud from $installed_version ..." 95 | run_as 'php /var/www/html/occ app:list' | sed -n "/Enabled:/,/Disabled:/p" >/tmp/list_before 96 | fi 97 | if [ "$(id -u)" = 0 ]; then 98 | rsync_options="-rlDog --chown www-data:root" 99 | else 100 | rsync_options="-rlD" 101 | fi 102 | rsync $rsync_options --delete --exclude-from=/upgrade.exclude /usr/src/nextcloud/ /var/www/html/ 103 | 104 | for dir in config data custom_apps themes; do 105 | if [ ! -d "/var/www/html/$dir" ] || directory_empty "/var/www/html/$dir"; then 106 | rsync $rsync_options --include "/$dir/" --exclude '/*' /usr/src/nextcloud/ /var/www/html/ 107 | fi 108 | done 109 | rsync $rsync_options --include '/version.php' --exclude '/*' /usr/src/nextcloud/ /var/www/html/ 110 | echo "Initializing finished" 111 | 112 | #install 113 | if [ "$installed_version" = "0.0.0.0" ]; then 114 | echo "New nextcloud instance" 115 | 116 | file_env NEXTCLOUD_ADMIN_PASSWORD 117 | file_env NEXTCLOUD_ADMIN_USER 118 | 119 | if [ -n "${NEXTCLOUD_ADMIN_USER+x}" ] && [ -n "${NEXTCLOUD_ADMIN_PASSWORD+x}" ]; then 120 | # shellcheck disable=SC2016 121 | install_options='-n --admin-user "$NEXTCLOUD_ADMIN_USER" --admin-pass "$NEXTCLOUD_ADMIN_PASSWORD"' 122 | if [ -n "${NEXTCLOUD_DATA_DIR+x}" ]; then 123 | # shellcheck disable=SC2016 124 | install_options=$install_options' --data-dir "$NEXTCLOUD_DATA_DIR"' 125 | fi 126 | 127 | file_env MYSQL_DATABASE 128 | file_env MYSQL_PASSWORD 129 | file_env MYSQL_USER 130 | file_env POSTGRES_DB 131 | file_env POSTGRES_PASSWORD 132 | file_env POSTGRES_USER 133 | 134 | install=false 135 | if [ -n "${SQLITE_DATABASE+x}" ]; then 136 | echo "Installing with SQLite database" 137 | # shellcheck disable=SC2016 138 | install_options=$install_options' --database-name "$SQLITE_DATABASE"' 139 | install=true 140 | elif [ -n "${MYSQL_DATABASE+x}" ] && [ -n "${MYSQL_USER+x}" ] && [ -n "${MYSQL_PASSWORD+x}" ] && [ -n "${MYSQL_HOST+x}" ]; then 141 | echo "Installing with MySQL database" 142 | # shellcheck disable=SC2016 143 | install_options=$install_options' --database mysql --database-name "$MYSQL_DATABASE" --database-user "$MYSQL_USER" --database-pass "$MYSQL_PASSWORD" --database-host "$MYSQL_HOST"' 144 | install=true 145 | elif [ -n "${POSTGRES_DB+x}" ] && [ -n "${POSTGRES_USER+x}" ] && [ -n "${POSTGRES_PASSWORD+x}" ] && [ -n "${POSTGRES_HOST+x}" ]; then 146 | echo "Installing with PostgreSQL database" 147 | # shellcheck disable=SC2016 148 | install_options=$install_options' --database pgsql --database-name "$POSTGRES_DB" --database-user "$POSTGRES_USER" --database-pass "$POSTGRES_PASSWORD" --database-host "$POSTGRES_HOST"' 149 | install=true 150 | fi 151 | 152 | if [ "$install" = true ]; then 153 | echo "starting nextcloud installation" 154 | max_retries=10 155 | try=0 156 | until run_as "php /var/www/html/occ maintenance:install $install_options" || [ "$try" -gt "$max_retries" ]; do 157 | echo "retrying install..." 158 | try=$((try + 1)) 159 | sleep 10s 160 | done 161 | if [ "$try" -gt "$max_retries" ]; then 162 | echo "installing of nextcloud failed!" 163 | exit 1 164 | fi 165 | if [ -n "${NEXTCLOUD_TRUSTED_DOMAINS+x}" ]; then 166 | echo "setting trusted domains…" 167 | NC_TRUSTED_DOMAIN_IDX=1 168 | for DOMAIN in $NEXTCLOUD_TRUSTED_DOMAINS; do 169 | DOMAIN=$(echo "$DOMAIN" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') 170 | run_as "php /var/www/html/occ config:system:set trusted_domains $NC_TRUSTED_DOMAIN_IDX --value=$DOMAIN" 171 | NC_TRUSTED_DOMAIN_IDX=$((NC_TRUSTED_DOMAIN_IDX + 1)) 172 | done 173 | fi 174 | else 175 | echo "running web-based installer on first connect!" 176 | fi 177 | fi 178 | #upgrade 179 | else 180 | run_as 'php /var/www/html/occ upgrade' 181 | 182 | run_as 'php /var/www/html/occ app:list' | sed -n "/Enabled:/,/Disabled:/p" >/tmp/list_after 183 | echo "The following apps have been disabled:" 184 | diff /tmp/list_before /tmp/list_after | grep '<' | cut -d- -f2 | cut -d: -f1 185 | rm -f /tmp/list_before /tmp/list_after 186 | 187 | fi 188 | fi 189 | 190 | # if we get an error "ln: /var/www/html/apps/nextpod: cannot overwrite directory" we need to remove that directory in the container 191 | run_as 'rm -Rf /var/www/html/apps/nextpod' 192 | 193 | run_as 'ln -sfT /var/www/html/custom_apps/nextpod /var/www/html/apps/nextpod' 194 | run_as "php /var/www/html/occ app:enable nextpod" 195 | fi 196 | 197 | # Install and enable gpoddersync 198 | run_as "php /var/www/html/occ app:install gpoddersync || true" 199 | run_as "php /var/www/html/occ app:enable gpoddersync || true" 200 | 201 | # Install and enable Nextcloud Notes 202 | run_as "php /var/www/html/occ app:install notes || true" 203 | run_as "php /var/www/html/occ app:enable notes || true" 204 | 205 | exec "$@" 206 | -------------------------------------------------------------------------------- /appinfo/signature.json: -------------------------------------------------------------------------------- 1 | { 2 | "hashes": { 3 | ".php-cs-fixer.dist.php": "4824001d3c77f77915f01bb32c550ddc413b6a63b1add9e541734e81effd9555d886645e861b0e69de8a05ba776d4d1b31b80e3914394e9e2e20e03f27b8c6f3", 4 | "LICENSE": "5c4c4e351be428767a0f30cde8bf2687bc31580c408c4b8c6e3a6cf9e4fa0412aabb9037f216e46ad93b85481941dcabe726c1da3714ab5a737be71b12d2ff2d", 5 | "appinfo\/info.xml": "aa30d8540d3c9fe126eb26545e4286eea36e8fd8923a038be2dbf4abd7772bde338f0fcdce0ff9e41d5b201ed62a76c63c29f73000983ef68c1031e66c6b8131", 6 | "appinfo\/routes.php": "3e2bc9974607f290e9801fe19765baa86d6ec5a31f36c409b2fecf10095617755a28007107e4659d39adc27f4d07a57534b7b2e939aedab6dbdb0b223e3ec504", 7 | "composer.lock": "22d183ddac01d7ece15e10ef2ddb0888df530f0399b2cebfe8bd78b814bf7e28af65d028697d6f680b651a2e34df0007a8ef9582b4dd9e0f0126624c619bbf4f", 8 | "img\/app.svg": "d03da4abc53646e893cd55aa0610410344c0b6126aea8145dc4339b2f8083038001c2f55546532dc1491a9ebad00c8461cd57c404ef79fce667f179f01d9fcca", 9 | "js\/nextpod-main.js": "256912d7d18af1422538d89c8cc1801806158bf3b942ee8426efd7ada545f7ac49decf053ef0d415efc2183984d35d59a4a24bc09b6eedbb3343fae3c3e1bcc4", 10 | "js\/nextpod-main.js.map": "3b8822845591ed0a6030bbf600fca3356eb1cf7981c1c116c1f63d87b38aafe2017b5c5311cd44718e7e18e893b0160bd6b8fc79a491b5aea9b6d589e84187f9", 11 | "js\/nextpod-src_views_Actions_vue.js": "8cbe4fb9d69dbaae50cf36355208ef32a2315a5ac1694259cdf39a58d0a291f553411ece5d502b25a4dd9d7106e13d5b9021e630811b62f5fbcc47caef8cd645", 12 | "js\/nextpod-src_views_Actions_vue.js.map": "90024c56dcaf9b48000055e510e4318c31fd70ddceded174488ccb3a49f6b32cdf89d3d3132480c8d0131a89bf5f5f075085f06d1f32ac1f90d89ea01b73316d", 13 | "js\/nextpod-src_views_HeaderNavigation_vue.js": "527de6dc0bf7896fcfa82a7c221bff545b83a2bec3ecc7b5550a513b5f0202994753a423e17b6df12437afb6452733cd83d497e13ef61be5bff8add046d4a4d0", 14 | "js\/nextpod-src_views_HeaderNavigation_vue.js.map": "ef26fab967e99e80cd259a8f31a1f8af9120bf2a5d04a6634f30c2100668edf1bc3560b9df3d748ba9c65c2837d6e7f24135425330ffdb6ede2dffb1e0dde773", 15 | "js\/nextpod-src_views_Podcasts_vue.js": "3e8a85460eb5b58e5a85730448538bd7becbd2b6fc111bfb1d35a224a61f8a80d3e99868b445bbb58cb09968c018aeabb13ed4dfb3dec0815bb81a8156c3c03c", 16 | "js\/nextpod-src_views_Podcasts_vue.js.map": "2ea933ff755558db7b7a970fc59fed7c9d04aa69e5c9aa6c3da6a5390ac88eabde14e4354e6f24d6ae602b0de0627f470b7cadc5ad8bb5b3d8f0d8d55bfff95f", 17 | "js\/nextpod-vendors-node_modules_nextcloud_dialogs_dist_index_es_js-node_modules_nextcloud_vue_dist_index-6cb9cc.js": "b860170f75d078fe7cf77cddcbb4e20c74bce994c61260c62926ffabaa5a151ce2c424613555b44d2e66bf8e87807fcc19fb21424146e32bce04e350f258d31a", 18 | "js\/nextpod-vendors-node_modules_nextcloud_dialogs_dist_index_es_js-node_modules_nextcloud_vue_dist_index-6cb9cc.js.map": "28ea431cf5043ec5e39d722712fb3c1917d7a20abe12bbb7d92656308ae347bac7df821ab6a11665064db6ab146f481bce355a0173021949e373be278d5cfb88", 19 | "lib\/Controller\/EpisodeActionController.php": "50cf75e1920092ae2e60e16b0212e81c8bb624e4d2fbbf6c29964bbc9db845280dedd80385f6dc1025f78074ec035eca5fe29cda656bd0c5ca618f8a80a09de2", 20 | "lib\/Controller\/PageController.php": "afd9e87d2161c176cd5b9e009baf2297ba2811e219ab8674bd005de24ec897cdd1507fa0019a8a4d48032c5c6183b45184da64395ffcd496321dac379877b9e7", 21 | "lib\/Controller\/PersonalSettingsController.php": "84d98cda25fcea3151b85cc801820fcb6905b183a947bd2a028998b3bce662c79646c8ace1f491347a9211000178262c6c64be3f00b6ab83f6145001666203e1", 22 | "lib\/Controller\/SubscriptionChangeController.php": "452dacb647d140798578cd2b13762d5174bbe9d4b77011396d431ec7fb6732a4fcb384329999da299e0cbd82dfa2064f1400ce889c13014cb9dba944ef53ebf1", 23 | "lib\/Core\/EpisodeAction\/EpisodeAction.php": "5b5102f0ea661ce3d2b52fe4e7d2ac60b46d5b62e6b6333b53d2f876aac8123b8a52abf1e9d08cff1604a81d7bb77952593718f9c1d29334a69969ceaf5186b8", 24 | "lib\/Core\/EpisodeAction\/EpisodeActionData.php": "b7a7acb614692695430383e6f26f91b82012e296ffdd7d64a22a3240bccf167672186025b9f26d80cb5cd2d81d647343ac5f48c2810ad124aacdce5db05e6c69", 25 | "lib\/Core\/EpisodeAction\/EpisodeActionExtraData.php": "6642b2a164e80a0bc73bd606dba6f49827ba1fb54b986d025ba4660fbd91430bc68c5e7cb144512e9605719dc7e39ebf6698c085d4ee923270767e4f7f49714f", 26 | "lib\/Core\/EpisodeAction\/EpisodeActionReader.php": "872a02ba8a8c63e230f8b7baeb8ea8bdcb21676f5e88966c4f8811d7d4d3aa68d9dcc57c31b8f067f4b2b28b62de60849b5d5e63a1447472edd569940de545fb", 27 | "lib\/Core\/EpisodeAction\/EpisodeActionSaver.php": "879072bc8a5fe474f8609c2598308ed9d2d7f2755e4dc24816b67b3abd5772c29c4b8fb8ab47776b5f61a8e613c3bd30e3bd076678aa672fdf17abaa942b6a42", 28 | "lib\/Core\/PodcastData\/PodcastActionCounts.php": "0fffd8a0520cb7fdd99d1dc9a4aa380cd6b27f5d27d911b6689bfd48f59ee52ce003fa0fb208f920c65ade9f76521380e183c98a3f8df4d9643ec973f7d8f29e", 29 | "lib\/Core\/PodcastData\/PodcastData.php": "2c52caaaf06940a21bdc5a747fc25e02cab1905799d966bdac95af1d87e9958fcb8670b7c9df4738b97267705d1139843fa7ce570aed4c125be151473328c262", 30 | "lib\/Core\/PodcastData\/PodcastDataReader.php": "8ad40bef425140de69d4f713e02c3aafddab55f473f51c7f5b3069a43889e0dd9b2a897e0f6bbb5541e540533798c3b1097db63991d0753beb29da7f3e55f723", 31 | "lib\/Core\/PodcastData\/PodcastMetrics.php": "d2fc57ef97d90fc138d77933fbae2902659764a0494a16b3fef841f1dbfff779be47fc653973b2f1f62623e33269e3875e8c104d352ed5f4e564d919d8bb78d6", 32 | "lib\/Core\/PodcastData\/PodcastMetricsReader.php": "a602db9eb480ebb74ded5013602edb03a0f763c41508b8a02f5d61d1d044fbee6cff9befbbfff5d7b64e02b68719b108f00d773338d2cc17c2134e1c6aad2c77", 33 | "lib\/Core\/SubscriptionChange\/SubscriptionChange.php": "bbb9a36c5b08b2c06c224d743f5b8ae6765215fc6ada1555712c87eb097bc2f93966675d4449366f3f34f5cdd942d9e3681dfe07f14ee4bfa04795c497bc4f24", 34 | "lib\/Core\/SubscriptionChange\/SubscriptionChangeRequestParser.php": "1490cbc9dd31fbd2bfb4edc07aab96c588973a8ce3567d64117570027f010a7460a3ec5c6d8c52f447d3166126242858881a36fb9aa3bca485ec5a5d6ef0eb52", 35 | "lib\/Core\/SubscriptionChange\/SubscriptionChangeSaver.php": "a080381c9274cee64b6cd778abd159c296e30ef125b256e212116d1fe2cf1002c286a0d3fb93747789dd89edd400e56669f340f9c48d6a9c384d0e31fdfaa521", 36 | "lib\/Core\/SubscriptionChange\/SubscriptionChangesReader.php": "263bab6aa5955525397fcb0dae0b110fc3172237f5bb281e06d319e4c86df522d985b3cf5de347f70c5bb64d18c93aa482ae3908cbe0b054e1ca24b334fbf470", 37 | "lib\/Db\/EpisodeAction\/EpisodeActionEntity.php": "1dd94daf7cf8a03ac42bd5fcd45e9195ca4b04898d369b4b859b748ef9c04ec58b459bc7d67b61aebbb4287f82781bf9c6134925d83ec63e533eeb163b465a0d", 38 | "lib\/Db\/EpisodeAction\/EpisodeActionMapper.php": "3a6dc8cb2a4d9828501f3f4ca2c1af81c375332744c3fb0094f19fb6980d6bba82d138dd67e5f83fc5d38b6c10394c7cf9f981b5c6b3a2f0d10002f255ea86e4", 39 | "lib\/Db\/EpisodeAction\/EpisodeActionRepository.php": "e9960ba163fb41f07213590118f8d5bd9bb277609fa220c16f91d667f6c407ddce3a9e61ad3691c001c7c2b582279c68cd867d018f40fa4f79783d40adefd5dd", 40 | "lib\/Db\/EpisodeAction\/EpisodeActionWriter.php": "36837a663187568e7951c80dcc4aa251915cc7669abf44da155b8ac724626b6478fa4bf3fbbcf0a5c778f74741fd78547853f4c140293057dda72b4f50ad0ecb", 41 | "lib\/Db\/SubscriptionChange\/SubscriptionChangeEntity.php": "41ad83a190d214c4fc89efff59df488a466986ef19c2c50e46140b02040ac36b3ae4095617d42539df021bbb62abdab4b901307c65fc2e992c5b21d4dd689236", 42 | "lib\/Db\/SubscriptionChange\/SubscriptionChangeMapper.php": "12b74fbba86d93536c2cd3b2559b6326c49c898ce55f0a0ce127b107efcf28f85b50570672082a7bc02cceb51ec9eae199de19f7ca16e19ba2d569ccd191588e", 43 | "lib\/Db\/SubscriptionChange\/SubscriptionChangeRepository.php": "7640f399a726a979312f3470fad74e1a438e7a93ea92e1908cdd0da262ed2a7915f7aa3eee2aebeb6d77e76307e50a78a0946248716262e6067216b9a65ef85f", 44 | "lib\/Db\/SubscriptionChange\/SubscriptionChangeWriter.php": "560ce0eb4b83f49b148c6b5d90740620352de3227046e5e20203f8aed302a68d4f6a1703f5b6c3543599e65580cd76e218f8d2af8387809ea9a516d96caaae87", 45 | "lib\/Sections\/NextPodPersonal.php": "0993cbe51b5de488b90ad44e20aca29614840fb7b1d08f809e7be93d072eee5aef9c69efc87ed45b2cc7ea93600a8e982793a1a16d2cb86921b1f4a8b7a6f8ed", 46 | "lib\/Settings\/NextPodPersonal.php": "38d71c5ec8c3ef3282a681206e32fad1631b6cab9b54a731d9df64781b6325b0a8235b8a8aa3cc38f8e657e16382f86921fa07d478fefea4cd9fd7cd0ec948f8", 47 | "templates\/main.php": "c0040683171294722f1bd312760f9517cb5147c2c249e3988a7bfae7d53f332fab781bbc82fcf1f80311f2a426dde1533749be59e0d55a9c6af9a877c4ad0971" 48 | }, 49 | "signature": "mdZF0ZpUYDkGAbziD9FInz2Ylh1ZD4cABS9y2hHKLgWfInE89vc5NyCcx98z73layvhR9jfb\/Yqzh9WabXHVj7o9Tcis1TyTXtDxMKrqCBLOAfh\/z1lUUVbJEldXif9GsfvMpl1ZA19qP6yd3taDAOg2cB3XOUVIQT3u1I90BePOsBHLJYfzXqtmfF6YkiiOoN2r\/DS4heubgeBNrOfyg5Xb\/C7eHE7Xrc3pYKDoM\/wu4J67h9x10s+kmvJtel4FQ2KpHhl6PGMrAK3hyKVifu5NjblB53vUrU\/JBAli1Da2l2HgMdY2I\/0LL\/EebZpGY3GKX4SfQ++\/PpPgmdV1hmlmzGrycsciXuCN28YbmKt\/mOjRKEK4zCV5LiHHZpLD7Eeei\/7YCFKbvtVYnVSkDxovDI9AoEXZGmEo0JSO68hx5O12LFbrv01Z3+F3Opwetx7H\/Pufejx\/Hyde7diwYyItHuL8bAaRmmXRGxgVMkZsRUcdwj\/snCT04QdfY4ocqVfKeLF8WzRWZge0XqBB1OZbwvRSH+pbfrreCScw7ExFF3zZJdQGYdVUl0v6Rqb5E2EKdiYK3Chhg2Yh\/6UAgQquMaKFDYJCjO8kJxmOe09i1k44a3ILJbsohBzCxxYuWTp6NxnqR7dPQ75dZ2z3+lXJkHsfwy3TYwNU2eJ\/QMY=", 50 | "certificate": "-----BEGIN CERTIFICATE-----\r\nMIIEAjCCAuoCAhIFMA0GCSqGSIb3DQEBCwUAMHsxCzAJBgNVBAYTAkRFMRswGQYD\r\nVQQIDBJCYWRlbi1XdWVydHRlbWJlcmcxFzAVBgNVBAoMDk5leHRjbG91ZCBHbWJI\r\nMTYwNAYDVQQDDC1OZXh0Y2xvdWQgQ29kZSBTaWduaW5nIEludGVybWVkaWF0ZSBB\r\ndXRob3JpdHkwHhcNMjMwMjI3MjMwNDA0WhcNMzMwNjA0MjMwNDA0WjASMRAwDgYD\r\nVQQDDAduZXh0cG9kMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvPZy\r\ntVjxxqP6kPjZQcQZJCO\/FZPA0D6TeqqmwWdj1WYDasnTGrty3lvNSc\/jm4bmeWBe\r\ngFvNIEPGe8bR89ZQKYT\/\/7yUmOW0k4l+RLORBxG8bCSnIHsUnUB\/yoT11XFOcI6h\r\nF4PYxOqj02LBz9FXDDncAOFLzpBycaQ55AM5Ot0hcYUOpia+wmerY08Vud85Ebll\r\nRMdnan2bbdTjFJ0GKEarjaiFAddMdIYLmGDc0s4o1BcFkWCGS3p7pgfH2tfcOK15\r\nAceOHLoBuIXLgsQj\/dIMQpPS7bvMyv0mPGEJs+TDeso+KNcSmqLusugZKJJg57\/f\r\nqeOntvEh6xTRuOLFBA0muLhNmlxEGm6G4ZNfgmSAdsvNMujUXPyqEQswxR4q6tJv\r\nEuccsplj\/cKe+5To5gWcctWhBxH13zaBikFu5C45OS91+d2O7KK03VFjZc+vf\/Og\r\njfzSUACARv6UjOcQMaQOc957epzlf9LpKFXQBx3vczcNsLE0r7Q60FEKZD4F495B\r\nFZgqwV6ECU0d9jYnFjsB50NAEUBUhZggx7GU0+iCtA6Ulk2pOPYwLbNm33wxeUHp\r\nwHEVzR\/j4wEof0NoGkIBELLbJSWW\/K8RBLYvxGi2KPWhldsEv016KPeq9e2JYvHA\r\n4gmg9NFoaZiYgrMgnpLy5y7SFY9QfDwBk7coJqECAwEAATANBgkqhkiG9w0BAQsF\r\nAAOCAQEAUnwWymQIyD8BrVA\/ETN1PM9JFivALJ\/kqYZFvTqMdYu9ZFfPw4GnN8MK\r\nWt3cmhjeOOwEgQHftZwrgrcb+BTJJdGS8mZpAsM03IQODuTRnXbfBufysHaiGG\/H\r\nGkFNlco+gdGPp1x9yMVvo1y+hxNmDB+6iGhLWf4XM18VcyI0C8\/2fYDSyD+ovwEE\r\n34gsi+Gw7TJJTxL+cP\/0Il0ZCub1i\/fRdUvss8ZzOyzoje6mGxOsqVQLZX7TgxSW\r\n\/+eDlbl3+3IC+MOyk3AAGxpZt3H7d25XwAzTlUDHKMaba599uw8TWDDRpDM9slWR\r\n9+S5cqLSYaIoa28R1rjCUPTWxmQvuQ==\r\n-----END CERTIFICATE-----" 51 | } -------------------------------------------------------------------------------- /src/components/ActionListItem.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 279 | 280 | 295 | 296 | 297 | 315 | --------------------------------------------------------------------------------