kiwilan/php-opds

PHP package to create OPDS feed for eBooks.

Fund package maintenance!
kiwilan

0.3.12 2023-05-09 14:56 UTC

README

php version downloads license

tests codecov

PHP package to create OPDS feed (Open Publication Distribution System) for eBooks.

Requirements

  • PHP >= 8.1

About

OPDS is like RSS feeds but adapted for eBooks, it's a standard to share eBooks between libraries, bookstores, publishers, and readers. Developed by Hadrien Gardeur and Leonard Richardson.

This package has been created to be used with bookshelves-project/bookshelves, an open source eBook web app.

The Open Publication Distribution System (OPDS) catalog format is a syndication format for electronic publications based on Atom and HTTP. OPDS catalogs enable the aggregation, distribution, discovery, and acquisition of electronic publications. OPDS catalogs use existing or emergent open standards and conventions, with a priority on simplicity.

The Open Publication Distribution System specification is prepared by an informal grouping of partners, combining Internet Archive, O'Reilly Media, Feedbooks, OLPC, and others.

From Wikipedia

Supported versions

Version Supported Latest Draft Date
0.9 May 25, 2010
1.0 August 30, 2010
1.1 June 27, 2011
1.2 November 11, 2018
2.0

Resources

Installation

You can install the package via composer:

composer require kiwilan/php-opds

Usage

Response

You can use the Opds::response() method to create an OPDS response, default response is XML with OPDS version 1.2.

use Kiwilan\Opds\Opds;
use Kiwilan\Opds\OpdsConfig;
use Kiwilan\Opds\OpdsVersionEnum;

class OpdsController
{
  public function index()
  {
    return Opds::response(
      config: new OpdsConfig(),
      entries: [], // OpdsEntry[]|OpdsEntryBook[]
      title: 'My feed',
      url: 'https://example.com/opds', // Can be null to be set automatically
      version: OpdsVersionEnum::v1_2, // OPDS version
      asString: false, // Output as string
      isSearch: false, // Is search feed
    );
  }
}
use Kiwilan\Opds\OpdsConfig;

new OpdsConfig(
  name: 'My OPDS Catalog',
  author: 'John Doe',
  authorUrl: 'https://example.com',
  iconUrl: 'https://example.com/icon.png',
  startUrl: 'https://example.com/opds',
  searchUrl: 'https://example.com/opds/search',
  updated: new DateTime(),
  usePagination: true,
  maxItemsPerPage: 32,
);

Basic usage

Example of a simple OPDS feed into controller (like Laravel).

use Kiwilan\Opds\Opds;
use Kiwilan\Opds\OpdsConfig;
use Kiwilan\Opds\Entries\OpdsEntry;
use Kiwilan\Opds\Entries\OpdsEntryBook;
use Kiwilan\Opds\Entries\OpdsEntryBookAuthor;

class OpdsController
{
  public function index()
  {
    return Opds::response(
      config: new OpdsConfig(
        name: 'My OPDS Catalog',
        author: 'John Doe',
        authorUrl: 'https://example.com',
        startUrl: 'https://example.com/opds',
        searchUrl: 'https://example.com/opds/search',
        updated: new DateTime(),
      ),
      entries: [
        new OpdsEntry(
          id: 'authors',
          title: 'Authors',
          route: 'http://localhost:8000/opds/authors',
          summary: 'Authors, 1 available',
          media: 'https://user-images.githubusercontent.com/48261459/201463225-0a5a084e-df15-4b11-b1d2-40fafd3555cf.svg',
          updated: new DateTime(),
        ),
        new OpdsEntry(
          id: 'series',
          title: 'Series',
          route: 'http://localhost:8000/opds/series',
          summary: 'Series, 1 available',
          media: 'https://user-images.githubusercontent.com/48261459/201463225-0a5a084e-df15-4b11-b1d2-40fafd3555cf.svg',
          updated: new DateTime(),
        ),
      ],
    );
  }

  public function books()
  {
    return Opds::response(
      config: new OpdsConfig(
        name: 'My OPDS Catalog',
        author: 'John Doe',
        authorUrl: 'https://example.com',
        startUrl: 'https://example.com/opds',
        searchUrl: 'https://example.com/opds/search',
        updated: new DateTime(),
      ),
      entries: [
        new OpdsEntryBook(
          id: 'the-clan-of-the-cave-bear-epub-en',
          title: 'The Clan of the Cave Bear',
          route: 'http://localhost:8000/opds/books/the-clan-of-the-cave-bear-epub-en',
          summary: 'The Clan of the Cave Bear is an epic work of prehistoric fiction by Jean M. Auel.',
          content: 'The Clan of the Cave Bear is an epic work of prehistoric fiction by Jean M. Auel about prehistoric times. It is the first book in the Earth\'s Children book series which speculates on the possibilities of interactions between Neanderthal and modern Cro-Magnon humans.',
          media: 'https://user-images.githubusercontent.com/48261459/201463225-0a5a084e-df15-4b11-b1d2-40fafd3555cf.svg',
          updated: new DateTime(),
          download: 'http://localhost:8000/api/download/books/the-clan-of-the-cave-bear-epub-en',
          mediaThumbnail: 'https://user-images.githubusercontent.com/48261459/201463225-0a5a084e-df15-4b11-b1d2-40fafd3555cf.svg',
          categories: ['category'],
          authors: [
              new OpdsEntryBookAuthor(
                  name: 'Jean M. Auel',
                  uri: 'http://localhost:8000/opds/authors/jean-m-auel',
              ),
          ],
          published: new DateTime(),
          volume: 1,
          serie: 'Earth\'s Children',
          language: 'English',
        ),
      ],
    );
  }
}

Real world example

Note This example use Laravel but you could use kiwilan/php-opds with any PHP framework.

You could create a file like MyOpds.php to store all your OPDS configuration.

  • config() is the OPDS config configuration
  • home() is the OPDS home page
  • bookToEntry() is a function to convert a book to an OPDS entry
<?php

namespace App\Opds;

use App\Models\Author;
use App\Models\Book;
use App\Models\Serie;
use Closure;
use Illuminate\Support\Facades\Cache;
use Kiwilan\Opds\OpdsConfig;
use Kiwilan\Opds\Entries\OpdsEntry;
use Kiwilan\Opds\Entries\OpdsEntryBook;
use Kiwilan\Opds\Entries\OpdsEntryBookAuthor;

class MyOpds
{
    public static function config(): OpdsConfig
    {
        return new OpdsConfig(
            name: config('app.name'),
            author: 'Bookshelves',
            authorUrl: config('app.url'),
            startUrl: route('opds.index'),
            searchUrl: route('opds.search'),
            updated: Book::orderBy('updated_at', 'desc')->first()->updated_at,
        );
    }

    /**
     * @return array<OpdsEntry>
     */
    public static function home(): array
    {
        $authors = self::cache('opds.authors', fn () => Author::all());
        $series = self::cache('opds.series', fn () => Serie::all());

        return [
            new OpdsEntry(
                id: 'authors',
                title: 'Authors',
                route: route('opds.authors.index'),
                summary: "Authors, {$authors->count()} available",
                media: asset('vendor/images/opds/authors.png'),
                updated: Author::orderBy('updated_at', 'desc')->first()->updated_at,
            ),
            new OpdsEntry(
                id: 'series',
                title: 'Series',
                route: route('opds.series.index'),
                summary: "Series, {$series->count()} available",
                media: asset('vendor/images/opds/series.png'),
                updated: Serie::orderBy('updated_at', 'desc')->first()->updated_at,
            ),
        ];
    }

    public static function cache(string $name, Closure $closure): mixed
    {
        if (config('app.env') === 'local') {
            Cache::forget($name);
        }

        $cache = 60 * 60 * 24;

        return Cache::remember($name, $cache, $closure);
    }

    public static function bookToEntry(Book $book): OpdsEntryBook
    {
        $book = $book->load('authors', 'serie', 'tags');
        $series = null;
        $seriesContent = null;

        if ($book->serie) {
            $seriesTitle = $book->serie->title;

            $series = " ({$seriesTitle} vol. {$book->volume})";
            $seriesContent = "<strong>Series {$seriesTitle} {$book->volume}</strong><br>";
        }

        $authors = [];

        foreach ($book->authors as $author) {
            $authors[] = new OpdsEntryBookAuthor(
                name: $author->name,
                uri: route('opds.authors.show', ['author' => $author->slug]),
            );
        }

        return new OpdsEntryBook(
            id: $book->slug,
            title: "{$book->title}{$series}",
            summary: "{$seriesContent}{$book->description}",
            updated: $book->updated_at,
            route: route('opds.books.show', ['author' => $book->meta_author, 'book' => $book->slug]),
            download: route('api.download.book', ['author_slug' => $book->meta_author, 'book_slug' => $book->slug]),
            media: $book->cover_og,
            mediaThumbnail: $book->cover_thumbnail,
            categories: $book->tags->pluck('name')->toArray(),
            authors: $authors,
            published: $book->released_on,
            volume: $book->volume,
            serie: $book->serie?->title,
            language: $book->language?->name,
        );
    }
}

And then you can use it into any controller.

<?php

namespace App\Http\Controllers\Opds;

use App\Opds\MyOpds;
use App\Engines\SearchEngine;
use App\Http\Controllers\Controller;
use App\Models\Book;
use Illuminate\Http\Request;
use Kiwilan\Opds\OpdsConfig;
use Kiwilan\Opds\Entries\OpdsEntry;
use Kiwilan\Opds\Entries\OpdsEntryBook;
use Kiwilan\Opds\Entries\OpdsEntryBookAuthor;

class IndexController extends Controller
{
    public function index()
    {
        return Opds::response(
            config: MyOpds::config(),
            entries: MyOpds::home(),
        );
    }

    public function search(Request $request)
    {
        $query = $request->input('q');
        $search = SearchEngine::make(q: $query, relevant: false, opds: true, types: ['books']);

        $entries = [];

        foreach ($search->results_opds as $result) {
            /** @var Book $result */
            $entries[] = MyOpds::bookToEntry($result);
        }

        return Opds::response(
            config: MyOpds::config(),
            entries: $entries,
            title: "Search for {$query}",
            isSearch: true,
        );
    }
}

You could create book OPDS page.

<?php

namespace App\Http\Controllers\Opds;

use App\Opds\MyOpds;
use App\Http\Controllers\Controller;
use App\Models\Author;
use App\Models\Book;
use Kiwilan\Opds\OpdsConfig;
use Kiwilan\Opds\Entries\OpdsEntry;
use Kiwilan\Opds\Entries\OpdsEntryBook;
use Kiwilan\Opds\Entries\OpdsEntryBookAuthor;

class BookController extends Controller
{
    public function show(string $author_slug, string $book_slug)
    {
        $author = Author::whereSlug($author_slug)->firstOrFail();
        $book = Book::whereAuthorMainId($author->id)
            ->whereSlug($book_slug)
            ->firstOrFail()
        ;

        return Opds::response(
            config: MyOpds::config(),
            entries: [
                MyOpds::bookToEntry($book),
            ],
            title: "Book {$book->title}",
        );
    }
}

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Credits

License

The MIT License (MIT). Please see License File for more information.