Compare commits

...

115 Commits

Author SHA1 Message Date
advplyr
ebedaeb3b0 Version bump 2.2.12 2023-01-08 10:48:25 -06:00
advplyr
62aec63d1d Fix:Backups to not backup temp db files 2023-01-08 09:59:24 -06:00
advplyr
3c25e87e8d Update:Cleanup audio player 2023-01-08 09:38:37 -06:00
advplyr
08d16ce7c2 Silence remove invalid sessions debug log 2023-01-08 09:15:11 -06:00
advplyr
2cb3808326 Fix:Loading backups catching failed backups 2023-01-08 09:11:55 -06:00
advplyr
bdb6f0c0aa Update:Sync session API endpoint to not respond with a payload 2023-01-07 17:33:05 -06:00
advplyr
5255bf13cc Update:Libraries table using context menu instead of hover buttons. Cleanup mobile view #1342 2023-01-07 17:14:55 -06:00
advplyr
3588e1e8d3 Update:Handle badly formatted series sequence from Audible #1339 2023-01-07 16:33:20 -06:00
advplyr
8fa8360e99 Update:Manual match tab prefer using ASIN with audible providers #1352 2023-01-07 16:22:59 -06:00
advplyr
b305cfd268 Update max playback speed to 10x 2023-01-07 16:18:52 -06:00
advplyr
ff10287d05 Fix:Force AAC when transcoding ALAC audio file streams #1372 2023-01-07 15:58:57 -06:00
advplyr
7a7708403f Update:Item metadata utils tag and genre loading indicator visible in viewport #1346 2023-01-07 15:44:59 -06:00
advplyr
ddabd0ee75 Update:Library folder browser to not save folders from request #1371 2023-01-07 15:31:51 -06:00
advplyr
5a26704c32 Add:Option to disable backup of audio files in embed metadata tool #1370 2023-01-07 15:16:52 -06:00
advplyr
7ccf36a896 Merge pull request #1374 from lkiesow/gentium-book-basic
Update Gentium Book Basic Font
2023-01-07 13:35:06 -06:00
Lars Kiesow
e9a84dd7dd Update Gentium Book Basic Font
This patch updates the Gentium Book Basic font file [1]. While I
couldn't get any client to use the previous file, it doesn't seem to be
a problem with this file and now the text is being rendered correctly.

[1] https://gwfh.mranftl.com/fonts/gentium-book-basic?subsets=latin
2023-01-07 20:25:11 +01:00
advplyr
b00510855e Fix:Gentium Book Basic font 2023-01-07 13:06:44 -06:00
advplyr
2cd9079692 Add MusicBrainz provider 2023-01-07 13:05:33 -06:00
advplyr
3e4b1652fc Fix disc/track metadata mapping 2023-01-06 17:39:15 -06:00
advplyr
878330b4fb Fix filePathToPOSIX used in scan, updates for music track page 2023-01-06 17:10:55 -06:00
advplyr
9a85ad1f6b Fix:Check if Windows before cleaning file path for POSIX separators #1254 2023-01-05 17:45:27 -06:00
advplyr
f76f9c7f84 Merge pull request #1367 from lkiesow/log-source
Add Source to Logging
2023-01-05 16:51:37 -06:00
advplyr
3426832f2b Fix for windows, update regex to only include line number, move to end of log 2023-01-05 16:44:34 -06:00
Lars Kiesow
10fd51498c Add Source to Logging
The Audiobookshelf logs sometimes contain information about the source
of the log statement, but sometimes they don't This really depends on
developers adding these information to the log messages.

But even then, the information is usually just a hint about the module
logging this, like `[Db]` or [Watcher]`, and finding the exact line can
be hard.

This patch automatically adds the source of the log statement to the
logs. This means if someone calls `Logger.info(…)` in line `22` of
`foo.js`, the log statement will contain this file and line:

```
[2023-01-05 19:04:12[ (LogManager.js:85:18) DEBUG: Daily Log file found 2023-01-05.txt
[2023-01-05 19:04:12] (LogManager.js:59:12)  INFO: [LogManager] Init current daily log filename: 2023-01-05.txt
```

This should make it much easier to identify the code where the log
statement originated from.

Long-term, this also means that we can probably remove the manually set
identifiers contained in the log messages, like the `[LogManager]` in
the example above.
2023-01-05 19:13:31 +01:00
advplyr
49c581ed35 Add:Podcast option to quick match all unmatched episodes 2023-01-04 18:13:46 -06:00
advplyr
1609f1a499 Add:Global library search also searches on podcast episode titles #1363 2023-01-04 17:43:15 -06:00
advplyr
88bd51e2da Fix:Update authors in different order #1361 2023-01-04 17:21:25 -06:00
advplyr
74388fe0b9 Fix:Series sequence parsed from metadata.abs allow non-numerical characters #1128 #1360 2023-01-04 15:55:02 -06:00
advplyr
7f5356100d Bookshelf updates for music tracks 2023-01-03 18:00:01 -06:00
advplyr
84d2d00a30 Merge pull request #1353 from tomazed/translation-fr
Update fr.json
2023-01-03 15:37:30 -06:00
Tomazed
31dddfbb60 Update fr.json 2023-01-03 10:53:27 +01:00
advplyr
d6da161b13 Music albums grouping and page 2023-01-02 18:02:04 -06:00
advplyr
9de7be1cb4 Update scanner, music meta tags and fix issue with force update 2023-01-02 16:35:39 -06:00
advplyr
5410aae8fc Remove old scanner setting from ServerSettings 2023-01-02 12:07:26 -06:00
advplyr
86bf6bfc62 Remove scannerMaxThreads from ServerSettings 2023-01-02 12:05:58 -06:00
advplyr
0807146aab Cleanup scanner 2023-01-02 12:05:07 -06:00
advplyr
591d8a8ab1 Add:OPF file pulls ASIN and subtitle #1330 2023-01-02 10:47:13 -06:00
advplyr
b1d4e28027 Merge pull request #1350 from lkiesow/settings-menu
Fix Hidden Settings Menu
2023-01-02 10:40:24 -06:00
advplyr
44363f05ac Start of new epub reader 2023-01-01 18:09:00 -06:00
Lars Kiesow
452af43916 Fix Hidden Settings Menu
This patch fixes several problems of the settings menu related to
display on mobile devices or small(ish) windows:

- The `isMobileLandscape` is now calculated correctly. Previously, this
  was set to `true` if a device was in portrait mode.

- Showing the button to collapse the settings menu and making the menu
  collapsible now use the same mechanism. Previously, it could happen
  that the menu was opened and not fixed, but no button to close it
  again was shown.

- The icons fore opening and closing the settings menu are now both
  arrows, indicating that their functionality is reversed.

- The button to open the menu now always has the string “Settings”,
  instead of using the name of the current page. The current page hader
  is listed below that anyway and this is the action component to open
  the settings menu after all.

This fixes #1334
2023-01-01 19:49:43 +01:00
advplyr
70ba2f7850 Add:RSS feed for series & cleanup empty series from db #1265 2022-12-31 16:58:19 -06:00
advplyr
a364fe5031 Merge RSS feed modals into a universal one 2022-12-31 15:26:37 -06:00
advplyr
ca6765c8e7 Add translations for series #1166 2022-12-31 15:04:37 -06:00
advplyr
6bfa281dc5 Update:Series page toolbar add context menu and confirm dialog for marking series as finished 2022-12-31 14:56:18 -06:00
advplyr
d8ee61bfab Update:Personalized API endpoint include query string to add rssFeed to entities 2022-12-31 14:31:38 -06:00
advplyr
c6763dee2d Remove invalid RSS feeds on init and remove feeds when associated entity is removed 2022-12-31 14:08:34 -06:00
advplyr
0e6b0d3eff Update:Remove RSS feeds from login response payload and include feeds from library items request 2022-12-31 10:59:12 -06:00
advplyr
8bbfee334c Update:Show RSS feed icon on collection card & update API endpoint for fetching collections 2022-12-31 10:33:38 -06:00
advplyr
f806e4cce3 Merge pull request #1343 from lkiesow/a11y-main-settings
Accessibility Improvements for Main Settings
2022-12-31 09:30:48 -06:00
advplyr
209ba308bd Merge branch 'master' into a11y-main-settings 2022-12-31 08:43:26 -06:00
advplyr
4cd9088a66 Add translations for aria labels #1166 2022-12-30 16:27:21 -06:00
advplyr
ac5e2e5c73 Merge pull request #1341 from lkiesow/a11y-user-settings
Fix keyboard navigation in user settings
2022-12-30 16:26:07 -06:00
Lars Kiesow
f1329d2847 Accessibility Improvements for Main Settings
This patch fixes some accessibility problems on the main settings page.
Most notably, it makes sure that the different options have labels which
are picked up by screen readers.

As a more generic addition, this also makes sure that the dropdown
component will always have a proper label constructed, explaining what
the dropdown is for and what its current value is.
2022-12-30 19:14:04 +01:00
advplyr
27faefc64d Merge pull request #1338 from naleo/master
Fix incorrect series and seriespart tag codes, they were swapped
2022-12-29 18:05:41 -06:00
advplyr
0fa7e61dc1 Merge pull request #1336 from lkiesow/user-settings-screenreader
Make User Settings Accessible via Screen Reader
2022-12-29 18:03:40 -06:00
advplyr
5a3f14ae51 Remove extra space from label 2022-12-29 18:03:05 -06:00
Lars Kiesow
4e61185136 Fix keyboard navigation in user settings
This patch makes sure that the option in the user settings are
accessible via keyboard navigation and that the labels, if users use a
screen reader, actually make sense.

This patch introduces new strings which need to be translated. Although
I did already provide a German translation.
2022-12-29 21:36:42 +01:00
Naleo
6ee06d5dae Fix incorrent series and seriespart tag codes, they were swapped 2022-12-29 08:41:46 -10:00
Lars Kiesow
2c344a0bc0 Make User Settings Accessible via Screen Reader
This patch should fix most of the problems for users trying to access
the user settings via screen reader. It makes sure user interface
elements can be reached via keyboard and provides proper labels, roles
and values so you not only can interact with elements but also know what
you are actually changing.

While not focused on other views, this should also already fix a number
of accessibility issues with other settings pages.
2022-12-29 05:00:40 +01:00
advplyr
315c83e4c3 RSS feed for collection to update when any item in the collection is updated #606 2022-12-28 18:08:03 -06:00
advplyr
9e4bc582cb Merge pull request #1335 from lkiesow/keyboard-navigation-libraries
Fix keyboard navigation in library selection
2022-12-28 17:18:35 -06:00
Lars Kiesow
fc6aa1f91f Fix keyboard navigation in library selection
This patch fixes the keyboard navigation in the library selection of the
main app bar. Without this patch, no options are selectable via keyboard
and selecting an option and hitting return has no effect.
2022-12-29 00:09:22 +01:00
advplyr
d4bea34423 Merge pull request #1333 from lkiesow/keynoard-navigation-border
Highlight items when navigating via keyboard
2022-12-28 17:01:17 -06:00
advplyr
a551a2d288 Merge pull request #1332 from lkiesow/home-img-alt
Text description of home link
2022-12-28 16:40:21 -06:00
Lars Kiesow
4b0c59b174 Highlight items when navigating via keyboard
This patch highlights items in the app bar if a user uses the keyboard
to navigate in audiobookshelf. This ensures that users actually know
which item they have selected.

This also modifies the text for the library selector, so that users
which are using a screen reader understand that it is a selector for
libraries and not only a button related to the current library.
2022-12-28 22:59:27 +01:00
Lars Kiesow
a0840d2a08 Text description of home link
This patch adds the missing alt attribute to the image linking the home
page of audiobookshelf. This allows screen readers to explain to users
where this link leads to.
2022-12-28 22:55:11 +01:00
advplyr
308ccf470f Add:Open RSS feed for collection #606 #1265 2022-12-27 18:03:31 -06:00
advplyr
4021b6eca1 Merge pull request #1320 from lkiesow/undefined-default
Fix undefined string assignment
2022-12-27 15:37:31 -06:00
advplyr
061695f922 Add:API endpoint for opening RSS feed for collection #606 #1265 2022-12-26 17:48:39 -06:00
advplyr
e803dcd325 Update:RSS feed API routes 2022-12-26 16:58:36 -06:00
Lars Kiesow
128796bd36 Fix undefined string assignment
Assigning something to `process.env.profile`, Node stringifies the value. This
means that assigning `undefined` to an environment variable in Node will result
in it holding the string `undefined`.

This means, for example, that `module.exports.FFPROBE_PATH || 'ffprobe'` in
`server/libs/nodeFfprobe/index.js` will actually result in the string
`undefined`.

This patch fixes several such assignments in the `index.js`, potentially
causing problems in the development mode.
2022-12-26 23:55:14 +01:00
advplyr
775dedc338 Cleanup and remove more vars 2022-12-26 16:08:53 -06:00
advplyr
45c9038954 Fix:Manually updating author image path & realtime update author image #1317 2022-12-26 15:45:42 -06:00
advplyr
8acf962864 Update:Remove relImagePath from Author entity 2022-12-26 15:29:45 -06:00
advplyr
c3fc38639e Merge pull request #1297 from Eschguy/master
Add Caddyfile example to readme
2022-12-25 16:24:57 -06:00
Austin Eschweiler
b60b75c8da Update readme.md 2022-12-25 11:32:12 -06:00
advplyr
0f7edec73b Add note in readme about subfolder 2022-12-24 13:32:45 -06:00
advplyr
321277826f Readme updates 2022-12-24 11:32:40 -06:00
advplyr
6e752af2c0 Update readme with new docs 2022-12-24 11:29:58 -06:00
advplyr
0717ae39db Fix music fine file with inode 2022-12-24 11:12:39 -06:00
advplyr
7bc5902ea8 Merge pull request #1312 from Machou/master
Update fr.json
2022-12-24 06:58:04 -06:00
Machou
a28e1ed5e0 Update fr.json 2022-12-24 07:49:22 +01:00
advplyr
43d9e129a6 Merge pull request #1310 from k9withabone/socket-fixes
Socket fixes
2022-12-23 07:47:32 -06:00
advplyr
b516019ddd Merge branch 'socket-fixes' of https://github.com/k9withabone/audiobookshelf into socket-fixes 2022-12-23 07:34:15 -06:00
advplyr
e4c20d677c Update server/controllers/SeriesController.js
Co-authored-by: Paul Nettleton <paulnett7@hotmail.com>
2022-12-23 07:33:33 -06:00
advplyr
33e183b802 Merge branch 'master' into socket-fixes 2022-12-23 07:27:14 -06:00
advplyr
b884f8fe11 Laying the groundwork for music media type #964 2022-12-22 16:38:55 -06:00
Paul Nettleton
2cba83f1dd Server socket event fixes 2022-12-22 16:26:11 -06:00
Paul Nettleton
a9ee9031c3 Add rss feed minified 2022-12-22 16:04:42 -06:00
advplyr
c3717f6979 Merge pull request #1306 from burghy86/patch-7
Update it.json
2022-12-21 07:22:43 -06:00
advplyr
657d4dd705 Update:Trim whitespace from audio file meta tag values #1305 2022-12-21 07:13:28 -06:00
burghy86
17356ffd79 Update it.json
fix
2022-12-21 10:46:21 +01:00
advplyr
c4be75b5bd Fix:Backups cron scheduler modal #1304 2022-12-20 12:35:31 -06:00
advplyr
57422d0759 Fix:PWA manifest add PNG icon for desktop browsers #1300 2022-12-20 11:57:52 -06:00
advplyr
d2454201b4 Merge pull request #1302 from Hallo951/master
Update de.json
2022-12-20 09:43:43 -06:00
Hallo951
3a92a69693 Update de.json
- minor fixes
- Item translated with medium or media
2022-12-20 09:36:50 +01:00
Austin Eschweiler
d733c9ccc6 Add Caddyfile example to readme
An example Caddyfile based on what I use
2022-12-19 18:10:55 -06:00
advplyr
3e15e09c07 Fix:Get libraries endpoint #1296 2022-12-19 17:46:32 -06:00
advplyr
0592a41d4f Version bump 2.2.11 2022-12-19 17:16:58 -06:00
advplyr
c32e33f804 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-19 17:16:48 -06:00
advplyr
616ffb8f79 Add:M4b tool configurable options bitrate/channels/codec #1029 #1257 2022-12-19 17:13:04 -06:00
advplyr
bc771a3a44 Delete DownloadManager.js 2022-12-19 16:20:18 -06:00
advplyr
539d1a2d4f Merge pull request #1294 from tomazed/translation-fr
Update fr.json regarding new Metadata strings
2022-12-19 16:16:56 -06:00
advplyr
4d8cea0bb4 Merge pull request #1293 from springsunx/patch-1
Update zh-cn.json
2022-12-19 16:16:37 -06:00
advplyr
8b46262e93 Merge pull request #1292 from Hallo951/master
Update de.json
2022-12-19 16:16:17 -06:00
advplyr
eb9a077520 Fix scroll listener for multi select inputs 2022-12-19 16:10:45 -06:00
advplyr
3d3a224402 Fix:Edit modal dropdown menus hidden #1295 2022-12-19 15:32:17 -06:00
advplyr
e1397a6dda Update:Author cover image API endpoint to get raw cover image #1291 2022-12-19 15:06:43 -06:00
advplyr
8f49aae979 Fix:Adding podcast and filename sanitize func #1290 2022-12-19 15:02:31 -06:00
Tomazed
c0a13f01d4 Update fr.json regarding new Metadata strings 2022-12-19 16:39:46 +01:00
SunX
efcebc616c Update zh-cn.json 2022-12-19 22:08:54 +08:00
Hallo951
902867c3bc Update de.json 2022-12-19 09:02:17 +01:00
advplyr
b7abd372e4 Version bump 2.2.10 2022-12-18 18:38:00 -06:00
advplyr
147ffc0210 Fix:Cover size widget behind home page arrow #1288 2022-12-18 18:37:03 -06:00
advplyr
1b2ccb6cee Fix:Series inner input behind details modal #1289 2022-12-18 18:35:05 -06:00
152 changed files with 5086 additions and 1960 deletions

View File

@@ -54,17 +54,6 @@
font-weight: 400;
font-display: swap;
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */

View File

@@ -3,7 +3,7 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="~static/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
</nuxt-link>
<nuxt-link to="/">
@@ -24,25 +24,25 @@
<google-cast-launcher></google-cast-launcher>
</div>
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span>
</span>
@@ -58,13 +58,13 @@
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }}
</ui-btn>
<ui-tooltip v-if="userIsAdminOrUp && !isPodcastLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcastLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
<ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
<ui-tooltip v-if="userCanUpdate && isBookLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip>
<template v-if="userCanUpdate">
@@ -103,6 +103,9 @@ export default {
isPodcastLibrary() {
return this.libraryMediaType === 'podcast'
},
isBookLibrary() {
return this.libraryMediaType === 'book'
},
isHome() {
return this.$route.name === 'library-library'
},
@@ -181,12 +184,15 @@ export default {
const queueItems = []
libraryItems.forEach((item) => {
let subtitle = ''
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
queueItems.push({
libraryItemId: item.id,
libraryId: item.libraryId,
episodeId: null,
title: item.media.metadata.title,
subtitle: item.media.metadata.authors.map((au) => au.name).join(', '),
subtitle,
caption: '',
duration: item.media.duration || null,
coverPath: item.media.coverPath || null

View File

@@ -1,7 +1,7 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
@@ -136,7 +136,7 @@ export default {
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else {
@@ -147,7 +147,7 @@ export default {
const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
}
@@ -167,8 +167,8 @@ export default {
this.loaded = true
},
async fetchCategories() {
var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
.then((data) => {
return data
})

View File

@@ -16,17 +16,17 @@
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
<span v-else class="material-icons-outlined text-lg">collections_bookmark</span>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
<path
@@ -50,18 +50,13 @@
</div>
<div class="flex-grow" />
<ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
<ui-btn v-if="!isBatchSelecting" color="primary" small :loading="processingSeries" class="items-center ml-1 sm:ml-4 hidden md:flex" @click="markSeriesFinished">
<div class="h-5 w-5">
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div>
<span class="pl-2"> {{ $strings.LabelMarkSeries }} {{ isSeriesFinished ? $strings.LabelNotFinished : $strings.LabelFinished }}</span>
</ui-btn>
<ui-btn v-if="isSeriesRemovedFromContinueListening && !isBatchSelecting" small :loading="processingSeries" @click="reAddSeriesToContinueListening" class="hidden md:block ml-2"> Re-Add Series to Continue Listening </ui-btn>
<!-- RSS feed -->
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" />
</ui-tooltip>
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
</template>
<!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
@@ -69,7 +64,7 @@
<div class="flex-grow hidden sm:inline-block" />
<ui-checkbox v-if="isLibraryPage && !isPodcastLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
@@ -118,6 +113,32 @@ export default {
}
},
computed: {
seriesContextMenuItems() {
if (!this.selectedSeries) return []
const items = [
{
text: this.isSeriesFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished,
action: 'mark-series-finished'
}
]
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'open-rss-feed'
})
}
if (this.isSeriesRemovedFromContinueListening) {
items.push({
text: 'Re-Add Series to Continue Listening',
action: 're-add-to-continue-listening'
})
}
return items
},
seriesSortItems() {
return [
{
@@ -153,9 +174,15 @@ export default {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isLibraryPage() {
return this.page === ''
},
@@ -180,10 +207,16 @@ export default {
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isAlbumsPage() {
return this.page === 'albums'
},
numShowing() {
return this.totalEntities
},
entityName() {
if (this.isAlbumsPage) return 'Albums'
if (this.isMusicLibrary) return 'Tracks'
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
if (!this.page) return this.$strings.LabelBooks
if (this.isSeriesPage) return this.$strings.LabelSeries
@@ -200,6 +233,9 @@ export default {
seriesProgress() {
return this.selectedSeries ? this.selectedSeries.progress : null
},
seriesRssFeed() {
return this.selectedSeries ? this.selectedSeries.rssFeed : null
},
seriesLibraryItemIds() {
if (!this.seriesProgress) return []
return this.seriesProgress.libraryItemIds || []
@@ -222,6 +258,31 @@ export default {
}
},
methods: {
seriesContextMenuAction(action) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.reAddSeriesToContinueListening()
} else if (action === 'mark-series-finished') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.markSeriesFinished()
}
},
showOpenSeriesRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.selectedSeries.id,
name: this.selectedSeries.name,
type: 'series',
feed: this.selectedSeries.rssFeed
})
},
reAddSeriesToContinueListening() {
this.processingSeries = true
this.$axios
@@ -286,27 +347,38 @@ export default {
}
},
markSeriesFinished() {
var newIsFinished = !this.isSeriesFinished
this.processingSeries = true
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
libraryItemId: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Series update success')
this.selectedSeries.progress.isFinished = newIsFinished
this.processingSeries = false
})
.catch((error) => {
this.$toast.error('Series update failed')
console.error('Failed to batch update read/not read', error)
this.processingSeries = false
})
const newIsFinished = !this.isSeriesFinished
const payload = {
message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,
callback: (confirmed) => {
if (confirmed) {
this.processingSeries = true
const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
libraryItemId: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
this.selectedSeries.progress.isFinished = newIsFinished
})
.catch((error) => {
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
console.error('Failed to batch update read/not read', error)
})
.finally(() => {
this.processingSeries = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
updateOrder() {
this.saveSettings()
@@ -339,16 +411,32 @@ export default {
},
setBookshelfTotalEntities(totalEntities) {
this.totalEntities = totalEntities
},
rssFeedOpen(data) {
if (data.entityId === this.seriesId) {
console.log('RSS Feed Opened', data)
this.selectedSeries.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.seriesId) {
console.log('RSS Feed Closed', data)
this.selectedSeries.rssFeed = null
}
}
},
mounted() {
this.init()
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
},
beforeDestroy() {
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
</div>
@@ -114,7 +114,7 @@ export default {
var classes = []
if (this.drawerOpen) classes.push('translate-x-0')
else classes.push('-translate-x-44')
if (this.isMobile) classes.push('z-50')
if (this.isMobilePortrait) classes.push('z-50')
else classes.push('z-40')
return classes.join(' ')
},
@@ -124,9 +124,11 @@ export default {
isMobileLandscape() {
return this.$store.state.globals.isMobileLandscape
},
isMobilePortrait() {
return this.$store.state.globals.isMobilePortrait
},
drawerOpen() {
if (this.isMobile) return this.isOpen
return true
return !this.isMobilePortrait || this.isOpen
},
routeName() {
return this.$route.name

View File

@@ -6,7 +6,7 @@
</div>
</template>
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
@@ -16,12 +16,12 @@
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
<p class="text-xl text-center">{{ emptyMessage }}</p>
<!-- Clear filter only available on Library bookshelf -->
<div v-if="entityName === 'books'" class="flex justify-center mt-2">
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
</div>
</div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
</div>
</template>
@@ -81,8 +81,11 @@ export default {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
return this.libraryMediaType === 'podcast'
},
emptyMessage() {
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
@@ -96,7 +99,7 @@ export default {
return this.$strings.MessageNoResults
},
entityName() {
if (!this.page) return 'books'
if (!this.page) return 'items'
return this.page
},
seriesSortBy() {
@@ -158,11 +161,8 @@ export default {
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
isEntityBook() {
return this.entityName === 'series-books' || this.entityName === 'books'
},
bookWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
return coverSize
},
@@ -192,7 +192,8 @@ export default {
},
shelfHeight() {
if (this.isAlternativeBookshelfView) {
var extraTitleSpace = this.isEntityBook ? 80 : 40
const isItemEntity = this.entityName === 'series-books' || this.entityName === 'items'
const extraTitleSpace = isItemEntity ? 80 : this.entityName === 'albums' ? 60 : 40
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
}
return this.entityHeight + 40
@@ -205,7 +206,7 @@ export default {
return this.$store.state.globals.selectedMediaItems || []
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize
}
},
@@ -214,8 +215,8 @@ export default {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
},
editEntity(entity) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
var bookIds = this.entities.map((e) => e.id)
if (this.entityName === 'items' || this.entityName === 'series-books') {
const bookIds = this.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', entity)
} else if (this.entityName === 'collections') {
@@ -229,7 +230,7 @@ export default {
this.isSelectionMode = false
},
selectEntity(entity, shiftKey) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
if (this.entityName === 'items' || this.entityName === 'series-books') {
const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
@@ -273,9 +274,8 @@ export default {
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
console.log('Setting media item selected', mediaItem, 'Num Selected=', this.selectedMediaItems.length)
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
@@ -285,7 +285,7 @@ export default {
const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
}
@@ -308,7 +308,7 @@ export default {
}
},
async fetchEntites(page = 0) {
var startIndex = page * this.booksPerFetch
const startIndex = page * this.booksPerFetch
this.isFetchingEntities = true
@@ -316,9 +316,9 @@ export default {
this.currentSFQueryString = this.buildSearchParams()
}
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? 'items' : this.entityName
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error)
@@ -340,7 +340,7 @@ export default {
}
for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex
const index = i + startIndex
this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
@@ -517,7 +517,7 @@ export default {
},
libraryItemUpdated(libraryItem) {
console.log('Item updated', libraryItem)
if (this.entityName === 'books' || this.entityName === 'series-books') {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities[indexOf] = libraryItem
@@ -528,7 +528,7 @@ export default {
}
},
libraryItemRemoved(libraryItem) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)

View File

@@ -4,7 +4,7 @@
<h1 class="text-xl">{{ headerText }}</h1>
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
<span class="material-icons" style="font-size: 1.4rem">add</span>
<button class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
</div>
</div>

View File

@@ -31,7 +31,7 @@
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
@@ -41,7 +41,7 @@
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
@@ -49,7 +49,7 @@
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path
fill="currentColor"
@@ -70,6 +70,14 @@
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-xl">album</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
@@ -132,15 +140,24 @@ export default {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isMusicAlbumsPage() {
return this.paramId === 'albums'
},
homePage() {
return this.$route.name === 'library-library'
},

View File

@@ -1,10 +1,10 @@
<template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-50 bg-primary px-4 pb-1 md:pb-4 pt-2">
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</nuxt-link>
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
{{ title }}
@@ -12,6 +12,7 @@
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
<p v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</p>
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
@@ -85,12 +86,15 @@ export default {
coverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookCoverWidth() {
return 88
isSquareCover() {
return this.coverAspectRatio === 1
},
bookCoverPosTop() {
if (this.coverAspectRatio == 1) return -10
return -64
isMobile() {
return this.$store.state.globals.isMobile
},
bookCoverWidth() {
if (this.isMobile) return 64 / this.coverAspectRatio
return 77 / this.coverAspectRatio
},
cover() {
if (this.media.coverPath) return this.media.coverPath
@@ -122,6 +126,9 @@ export default {
isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
},
isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
},
mediaMetadata() {
return this.media.metadata || {}
},
@@ -145,6 +152,10 @@ export default {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
},
musicArtists() {
if (!this.isMusic) return null
return this.mediaMetadata.artists.join(', ')
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
}
@@ -405,8 +416,8 @@ export default {
}
},
async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId || null
const libraryItemId = payload.libraryItemId
const episodeId = payload.episodeId || null
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
if (payload.startTime !== null && !isNaN(payload.startTime)) {
@@ -417,11 +428,12 @@ export default {
return
}
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to fetch full item', error)
return null
})
if (!libraryItem) return
this.$store.commit('setMediaPlaying', {
libraryItem,
episodeId,

View File

View File

@@ -10,7 +10,7 @@
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div>
</div>
</template>
@@ -67,6 +67,7 @@ export default {
// but with removing commas periods etc this is no longer plausible
const html = this.matchText
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`

View File

@@ -0,0 +1,114 @@
<template>
<div ref="card" :id="`album-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ artist || '&nbsp;' }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
albumMount: {
type: Object,
default: () => null
}
},
data() {
return {
album: null,
isSelectionMode: false,
selected: false,
isHovering: false
}
},
computed: {
coverSrc() {
const config = this.$config || this.$nuxt.$config
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
},
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
return this.width / baseSize
},
title() {
return this.album ? this.album.title : ''
},
artist() {
return this.album ? this.album.artist : ''
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
}
},
methods: {
setEntity(album) {
this.album = album
},
setSelectionMode(val) {
this.isSelectionMode = val
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickCard() {
if (!this.album) return
// const router = this.$router || this.$nuxt.$router
// router.push(`/album/${this.$encode(this.title)}`)
},
clickEdit() {
this.$emit('edit', this.album)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.albumMount) {
this.setEntity(this.albumMount)
}
}
}
</script>

View File

@@ -70,7 +70,7 @@
</div>
<!-- More Menu Icon -->
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
</div>
@@ -190,6 +190,9 @@ export default {
isPodcast() {
return this.mediaType === 'podcast'
},
isMusic() {
return this.mediaType === 'music'
},
placeholderUrl() {
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
@@ -257,7 +260,7 @@ export default {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
const baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
title() {
@@ -273,6 +276,10 @@ export default {
authorLF() {
return this.mediaMetadata.authorNameLF
},
artist() {
const artists = this.mediaMetadata.artists || []
return artists.join(', ')
},
displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
@@ -282,6 +289,7 @@ export default {
displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author
if (this.isMusic) return this.artist
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
@@ -305,6 +313,7 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() {
if (this.isMusic) return null
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
@@ -341,7 +350,7 @@ export default {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
@@ -366,7 +375,7 @@ export default {
if (this.isPodcast) return 'Podcast has no episodes'
return 'Item has no audio tracks & ebook'
}
var txt = ''
let txt = ''
if (this.numMissingParts) {
txt += `${this.numMissingParts} missing parts.`
}
@@ -377,7 +386,7 @@ export default {
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
const classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
@@ -401,6 +410,8 @@ export default {
return this.store.getters['user/getIsAdminOrUp']
},
moreMenuItems() {
if (this.isMusic) return []
if (this.recentEpisode) {
const items = [
{
@@ -438,7 +449,7 @@ export default {
return items
}
var items = []
let items = []
if (!this.isPodcast) {
items = [
{
@@ -534,11 +545,11 @@ export default {
return this.author
},
isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.DETAIL
},
isAuthorBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.AUTHOR
},
titleDisplayBottomOffset() {
@@ -548,7 +559,7 @@ export default {
},
rssFeed() {
if (this.booksInSeries) return null
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId)
return this._libraryItem.rssFeed || null
}
},
methods: {

View File

@@ -9,6 +9,9 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
@@ -72,6 +75,9 @@ export default {
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
},
rssFeed() {
return this.collection ? this.collection.rssFeed : null
}
},
methods: {

View File

@@ -13,6 +13,8 @@
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div>
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
@@ -125,6 +127,9 @@ export default {
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
},
rssFeed() {
return this.series ? this.series.rssFeed : null
}
},
methods: {

View File

@@ -87,8 +87,14 @@ export default {
this.$emit('input', val)
}
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
seriesItems() {
return [
@@ -214,9 +220,33 @@ export default {
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
}
]
},
selectItems() {
if (this.isSeries) return this.seriesItems
if (this.isPodcast) return this.podcastItems
if (this.isMusic) return this.musicItems
return this.bookItems
},
selectedItemSublist() {

View File

@@ -50,8 +50,14 @@ export default {
this.$emit('update:descending', val)
}
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
podcastItems() {
return [
@@ -134,10 +140,40 @@ export default {
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelSize,
value: 'size'
},
{
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
},
selectItems() {
let items = null
if (this.isPodcast) {
items = this.podcastItems
} else if (this.isMusic) {
items = this.musicItems
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
items = this.seriesItems
} else {

View File

@@ -40,7 +40,7 @@ export default {
showMenu: false,
currentPlaybackRate: 0,
MIN_SPEED: 0.5,
MAX_SPEED: 3,
MAX_SPEED: 10,
menuLeft: -92,
arrowLeft: 0
}

View File

@@ -65,7 +65,8 @@ export default {
return `${this.naturalWidth}x${this.naturalHeight}px`
},
placeholderUrl() {
return `${this.$config.routerBasePath}/book_placeholder.jpg`
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
}
},
methods: {

View File

@@ -22,8 +22,8 @@
</div>
<div class="flex-grow" />
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
</div>
</div>
@@ -31,55 +31,55 @@
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsDownload }}</p>
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.download" />
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newUser.permissions.download" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsUpdate }}</p>
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.update" />
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newUser.permissions.update" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsDelete }}</p>
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.delete" />
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newUser.permissions.delete" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsUpload }}</p>
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.upload" />
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newUser.permissions.upload" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" />
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newUser.permissions.accessExplicitContent" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-2xl md:text-4xl">close</span>
</div>

View File

@@ -109,7 +109,8 @@ export default {
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error(this.$strings.ToastAuthorUpdateFailed)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || this.$strings.ToastAuthorUpdateFailed)
return null
})
if (result) {
@@ -125,8 +126,7 @@ export default {
},
async removeCover() {
var updatePayload = {
imagePath: null,
relImagePath: null
imagePath: null
}
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
@@ -161,8 +161,7 @@ export default {
if (response.author.imagePath) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.$store.commit('globals/showEditAuthorModal', response.author)
}
else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
} else {
this.$toast.info('No updates were made for Author')
}

View File

@@ -396,6 +396,12 @@ export default {
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
// Prefer using ASIN if set and using audible provider
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
this.searchTitle = this.libraryItem.media.metadata.asin
this.searchAuthor = ''
}
if (this.searchTitle) {
this.submitSearch()
}

View File

@@ -67,6 +67,10 @@ export default {
value: 'podcast',
text: this.$strings.LabelPodcasts
}
// {
// value: 'music',
// text: 'Music'
// }
]
},
folderPaths() {

View File

@@ -6,13 +6,13 @@
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="currentFeedUrl" class="w-full">
<div v-if="currentFeed" class="w-full">
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<div class="w-full relative">
<ui-text-input v-model="currentFeedUrl" readonly />
<ui-text-input v-model="currentFeed.feedUrl" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeedUrl)">content_copy</span>
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
</div>
</div>
<div v-else class="w-full">
@@ -28,7 +28,7 @@
</div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-if="currentFeed" color="error" small @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
</div>
</div>
@@ -37,19 +37,11 @@
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => null
},
feedUrl: String
},
data() {
return {
processing: false,
newFeedSlug: null,
currentFeedUrl: null
currentFeed: null
}
},
watch: {
@@ -65,23 +57,29 @@ export default {
computed: {
show: {
get() {
return this.value
return this.$store.state.globals.showRSSFeedOpenCloseModal
},
set(val) {
this.$emit('input', val)
this.$store.commit('globals/setShowRSSFeedOpenCloseModal', val)
}
},
libraryItemId() {
return this.libraryItem.id
rssFeedEntity() {
return this.$store.state.globals.rssFeedEntity || {}
},
media() {
return this.libraryItem.media || {}
entityId() {
return this.rssFeedEntity.id
},
mediaMetadata() {
return this.media.metadata || {}
entityType() {
return this.rssFeedEntity.type
},
entityFeed() {
return this.rssFeedEntity.feed
},
hasEpisodesWithoutPubDate() {
return !!this.rssFeedEntity.hasEpisodesWithoutPubDate
},
title() {
return this.mediaMetadata.title
return this.rssFeedEntity.name
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
@@ -91,12 +89,6 @@ export default {
},
isHttp() {
return window.origin.startsWith('http://')
},
episodes() {
return this.media.episodes || []
},
hasEpisodesWithoutPubDate() {
return this.episodes.some((ep) => !ep.pubDate)
}
},
methods: {
@@ -106,7 +98,7 @@ export default {
return
}
var sanitized = this.$sanitizeSlug(this.newFeedSlug)
const sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again')
@@ -121,19 +113,15 @@ export default {
console.log('Payload', payload)
this.$axios
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
.$post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
.then((data) => {
if (data.success) {
console.log('Opened RSS Feed', data)
this.currentFeedUrl = data.feedUrl
} else {
const errorMsg = data.error || 'Unknown error'
this.$toast.error(errorMsg)
}
console.log('Opened RSS Feed', data)
this.currentFeed = data.feed
})
.catch((error) => {
console.error('Failed to open RSS Feed', error)
this.$toast.error()
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
})
},
copyToClipboard(str) {
@@ -142,22 +130,23 @@ export default {
closeFeed() {
this.processing = true
this.$axios
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
.$post(`/api/feeds/${this.currentFeed.id}/close`)
.then(() => {
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
this.show = false
this.processing = false
})
.catch((error) => {
console.error('Failed to close RSS feed', error)
this.processing = false
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
})
.finally(() => {
this.processing = false
})
},
init() {
if (!this.libraryItem) return
this.newFeedSlug = this.libraryItem.id
this.currentFeedUrl = this.feedUrl
if (!this.entityId) return
this.newFeedSlug = this.entityId
this.currentFeed = this.entityFeed
}
},
mounted() {}

View File

@@ -0,0 +1,88 @@
<template>
<div class="h-full w-full">
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
</div>
</template>
<script>
export default {
props: {
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
bookInfo: {},
page: 0,
numPages: 0,
pageHtml: '',
progress: 0
}
},
computed: {
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
hasPrev() {
return this.page > 0
},
hasNext() {
return this.page < this.numPages - 1
}
},
methods: {
prev() {
if (!this.hasPrev) return
this.page--
this.loadPage()
},
next() {
if (!this.hasNext) return
this.page++
this.loadPage()
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
loadPage() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
.then((html) => {
this.pageHtml = html
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load page')
})
},
loadInfo() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
.then((bookInfo) => {
this.bookInfo = bookInfo
this.numPages = bookInfo.pages
this.page = 0
this.loadPage()
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load info')
})
},
initEpub() {
if (!this.libraryItemId) return
this.loadInfo()
}
},
mounted() {
this.initEpub()
}
}
</script>

View File

@@ -9,7 +9,7 @@
<p v-if="abAuthor">by {{ abAuthor }}</p>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" />
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
</div>
@@ -37,7 +37,8 @@ export default {
}
},
componentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
else if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'

View File

@@ -35,13 +35,13 @@
</ui-tooltip>
</td>
<td class="py-0">
<div class="w-full flex justify-center">
<div class="w-full flex justify-left">
<!-- Dont show edit for non-root users -->
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<span class="material-icons text-base">edit</span>
<button :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-icons text-base">edit</button>
</div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<span class="material-icons text-base">delete</span>
<button :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-icons text-base">delete</button>
</div>
</div>
</td>

View File

@@ -1,32 +1,29 @@
<template>
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
<div class="w-full pl-2 pr-4 md:px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
<ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
<ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<p class="text-base md:text-xl font-book pl-2 md:pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan" class="hidden md:block" small color="success" @click.stop="scan">{{ $strings.ButtonScan }}</ui-btn>
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2 hidden md:block" @click.stop="forceScan">{{ $strings.ButtonForceReScan }}</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2 hidden md:block" @click.stop="matchAll">{{ $strings.ButtonMatchBooks }}</ui-btn>
<!-- Desktop context menu icon -->
<ui-context-menu-dropdown v-if="!libraryScan && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" />
<span v-if="isHovering && !libraryScan" class="!hidden md:!block material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-if="!libraryScan && isHovering && !isDeleting" class="!hidden md:!block material-icons text-xl text-gray-300 ml-3 hover:text-gray-50 cursor-pointer" @click.stop="deleteClick">delete</span>
<!-- Mobile context menu icon -->
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
<!-- For mobile -->
<span v-if="!libraryScan" class="!block md:!hidden material-icons text-xl text-gray-300 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-2xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
<svg viewBox="0 0 24 24" class="w-6 h-6">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-2 md:ml-4">reorder</span>
<!-- For mobile -->
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="contextMenuItems" @action="contextMenuAction" />
</div>
</template>
@@ -63,34 +60,45 @@ export default {
menuTitle() {
return this.library.name
},
mobileMenuItems() {
contextMenuItems() {
const items = [
{
text: this.$strings.ButtonEdit,
action: 'edit',
value: 'edit'
},
{
text: this.$strings.ButtonScan,
action: 'scan',
value: 'scan'
},
{
text: this.$strings.ButtonForceReScan,
action: 'force-scan',
value: 'force-scan'
}
]
if (this.isBookLibrary) {
items.push({
text: this.$strings.ButtonMatchBooks,
action: 'match-books',
value: 'match-books'
})
}
items.push({
text: this.$strings.ButtonDelete,
action: 'delete',
value: 'delete'
})
return items
}
},
methods: {
mobileMenuAction(action) {
contextMenuAction(action) {
this.showMobileMenu = false
if (action === 'scan') {
if (action === 'edit') {
this.editClick()
} else if (action === 'scan') {
this.scan()
} else if (action === 'force-scan') {
this.forceScan()
@@ -130,37 +138,52 @@ export default {
})
},
forceScan() {
if (confirm(this.$strings.MessageConfirmForceReScan)) {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.then(() => {
this.$toast.success(this.$strings.ToastLibraryScanStarted)
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
})
const payload = {
message: this.$strings.MessageConfirmForceReScan,
callback: (confirmed) => {
if (confirmed) {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.then(() => {
this.$toast.success(this.$strings.ToastLibraryScanStarted)
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteClick() {
if (confirm(this.$getString('MessageConfirmDeleteLibrary', [this.library.name]))) {
this.isDeleting = true
this.$axios
.$delete(`/api/libraries/${this.library.id}`)
.then((data) => {
this.isDeleting = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success(this.$strings.ToastLibraryDeleteSuccess)
}
})
.catch((error) => {
console.error('Failed to delete library', error)
this.$toast.error(this.$strings.ToastLibraryDeleteFailed)
this.isDeleting = false
})
const payload = {
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
callback: (confirmed) => {
if (confirmed) {
this.isDeleting = true
this.$axios
.$delete(`/api/libraries/${this.library.id}`)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success(this.$strings.ToastLibraryDeleteSuccess)
}
})
.catch((error) => {
console.error('Failed to delete library', error)
this.$toast.error(this.$strings.ToastLibraryDeleteFailed)
})
.finally(() => {
this.isDeleting = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
},
mounted() {}

View File

@@ -1,8 +1,9 @@
<template>
<div class="w-full py-6">
<p class="text-lg mb-2 font-semibold md:hidden">{{ $strings.HeaderEpisodes }}</p>
<div class="flex items-center mb-4">
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
<div class="flex-grow" />
<p class="text-lg mb-0 font-semibold hidden md:block">{{ $strings.HeaderEpisodes }}</p>
<div class="flex-grow hidden md:block" />
<template v-if="isSelectionMode">
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
@@ -11,8 +12,10 @@
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
</template>
<template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-32 md:w-36 h-9 ml-1 sm:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-32 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 sm:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template>
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
@@ -42,15 +45,27 @@ export default {
showPodcastRemoveModal: false,
selectedEpisodes: [],
episodesToRemove: [],
processing: false
processing: false,
quickMatchingEpisodes: false
}
},
watch: {
libraryItem() {
this.init()
libraryItem: {
handler() {
this.init()
}
}
},
computed: {
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
return [
{
text: 'Quick match all episodes',
action: 'quick-match-episodes'
}
]
},
sortItems() {
return [
{
@@ -94,8 +109,8 @@ export default {
isSelectionMode() {
return this.selectedEpisodes.length > 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
media() {
return this.libraryItem.media || {}
@@ -131,6 +146,44 @@ export default {
}
},
methods: {
contextMenuAction(action) {
if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return
this.quickMatchAllEpisodes()
}
},
quickMatchAllEpisodes() {
if (!this.mediaMetadata.feedUrl) {
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
return
}
this.quickMatchingEpisodes = true
const payload = {
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/match-episodes?override=1`)
.then((data) => {
if (data.numEpisodesUpdated) {
this.$toast.success(`${data.numEpisodesUpdated} episodes updated`)
} else {
this.$toast.info('No changes were made')
}
})
.catch((error) => {
console.error('Failed to request match episodes', error)
this.$toast.error('Failed to match episodes')
})
}
this.quickMatchingEpisodes = false
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
addToPlaylist(episode) {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }])
this.$store.commit('globals/setShowPlaylistsModal', true)

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="material-icons">more_vert</span>
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
<transition name="menu">
@@ -23,6 +23,10 @@ export default {
items: {
type: Array,
default: () => []
},
iconClass: {
type: String,
default: ''
}
},
data() {

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span>
@@ -13,9 +13,9 @@
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox">
<template v-for="item in itemsToShow">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
<span v-if="item.subtext">:&nbsp;</span>
@@ -91,6 +91,13 @@ export default {
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
return classes.join(' ')
},
longLabel() {
let result = ''
if (this.label) result += this.label + ': '
if (this.selectedText) result += this.selectedText
if (this.selectedSubtext) result += ' ' + this.selectedSubtext
return result
}
},
methods: {

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" @click.stop.prevent="clickShowMenu">
<div class="flex items-center justify-center sm:justify-start">
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
@@ -10,7 +10,7 @@
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="selectLibrary(library)">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2">
<ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>

View File

@@ -15,7 +15,7 @@
</div>
</form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
@@ -117,7 +117,7 @@ export default {
}, 50)
},
recalcMenuPos() {
if (!this.menu) return
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page
@@ -135,7 +135,7 @@ export default {
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()

View File

@@ -11,7 +11,7 @@
</div>
</div>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
@@ -68,14 +68,14 @@ export default {
},
methods: {
recalcMenuPos() {
if (!this.menu) return
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()

View File

@@ -18,7 +18,7 @@
</div>
</form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
@@ -120,6 +120,7 @@ export default {
console.error('Failed to get search results', error)
return []
})
this.items = results || []
this.searching = false
},
@@ -139,7 +140,7 @@ export default {
}, 50)
},
recalcMenuPos() {
if (!this.menu) return
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page
@@ -157,7 +158,7 @@ export default {
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@@ -203,15 +204,21 @@ export default {
}
if (this.$refs.input) this.$refs.input.focus()
var newSelected = null
let newSelected = null
if (this.getIsSelected(item.id)) {
newSelected = this.selected.filter((s) => s.id !== item.id)
this.$emit('removedItem', item.id)
} else {
newSelected = this.selected.concat([item])
newSelected = this.selected.concat([
{
id: item.id,
name: item.name
}
])
}
this.textInput = null
this.currentSearch = null
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
@@ -245,10 +252,11 @@ export default {
submitForm() {
if (!this.textInput) return
var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => {
return i === cleaned
const cleaned = this.textInput.trim()
const matchesItem = this.items.find((i) => {
return i.name === cleaned
})
if (matchesItem) {
this.clickedOption(null, matchesItem)
} else {

View File

@@ -8,7 +8,7 @@
</div>
</form>
<ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-60 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative">
<input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div>
@@ -31,7 +31,8 @@ export default {
},
noSpinner: Boolean,
textCenter: Boolean,
clearable: Boolean
clearable: Boolean,
inputId: String
},
data() {
return {

View File

@@ -1,11 +1,11 @@
<template>
<div class="w-full">
<slot>
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
>
</slot>
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
<ui-text-input :placeholder="label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
</div>
</template>
@@ -34,6 +34,9 @@ export default {
set(val) {
this.$emit('input', val)
}
},
identifier() {
return Math.random().toString(36).substring(2)
}
},
methods: {

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :class="className" @click="clickToggle">
<button :aria-labelledby="labeledBy" role="checkbox" class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :aria-checked="toggleValue" :class="className" @click="clickToggle">
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div>
</button>
</div>
</template>
@@ -18,7 +18,8 @@ export default {
type: String,
default: 'primary'
},
disabled: Boolean
disabled: Boolean,
labeledBy: String
},
computed: {
toggleValue: {

View File

@@ -210,11 +210,13 @@ export default {
// array of objects with id key
if (array1.length !== array2.length) return false
for (var item of array1) {
var matchingItem = array2.find((a) => a.id === item.id)
if (!matchingItem) return false
for (var key in item) {
if (item[key] !== matchingItem[key]) {
for (let i = 0; i < array1.length; i++) {
const item1 = array1[i]
const item2 = array2[i]
if (!item1 || !item2) return false
for (const key in item1) {
if (item1[key] !== item2[key]) {
// console.log('Object array item keys changed', key, item[key], matchingItem[key])
return false
}

View File

@@ -59,7 +59,6 @@ export default {
..._series
}
console.log('Selected series', this.selectedSeries)
this.showSeriesForm = true
},
addNewSeries() {

View File

@@ -18,6 +18,7 @@
<modals-podcast-view-episode />
<modals-authors-edit-modal />
<modals-batch-quick-match-model />
<modals-rssfeed-open-close-modal />
<prompt-confirm />
<readers-reader />
</div>
@@ -329,12 +330,6 @@ export default {
}
this.$store.commit('libraries/removeUserPlaylist', playlist)
},
rssFeedOpen(data) {
this.$store.commit('feeds/addFeed', data)
},
rssFeedClosed(data) {
this.$store.commit('feeds/removeFeed', data)
},
backupApplied() {
// Force refresh
location.reload()
@@ -424,10 +419,6 @@ export default {
this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished)
// Feed Listeners
this.socket.on('rss_feed_open', this.rssFeedOpen)
this.socket.on('rss_feed_closed', this.rssFeedClosed)
this.socket.on('backup_applied', this.backupApplied)
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)

View File

@@ -3,6 +3,7 @@ import LazyBookCard from '@/components/cards/LazyBookCard'
import LazySeriesCard from '@/components/cards/LazySeriesCard'
import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
import LazyAlbumCard from '@/components/cards/LazyAlbumCard'
export default {
data() {
@@ -17,6 +18,7 @@ export default {
if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
if (this.entityName === 'albums') return Vue.extend(LazyAlbumCard)
return Vue.extend(LazyBookCard)
},
async mountEntityCard(index) {
@@ -28,7 +30,7 @@ export default {
}
this.entityIndexesMounted.push(index)
if (this.entityComponentRefs[index]) {
var bookComponent = this.entityComponentRefs[index]
const bookComponent = this.entityComponentRefs[index]
shelfEl.appendChild(bookComponent.$el)
if (this.isSelectionMode) {
bookComponent.setSelectionMode(true)
@@ -43,13 +45,13 @@ export default {
bookComponent.isHovering = false
return
}
var shelfOffsetY = 16
var row = index % this.entitiesPerShelf
var shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft
const shelfOffsetY = 16
const row = index % this.entitiesPerShelf
const shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft
var ComponentClass = this.getComponentClass()
const ComponentClass = this.getComponentClass()
var props = {
const props = {
index,
width: this.entityWidth,
height: this.entityHeight,
@@ -58,15 +60,15 @@ export default {
sortingIgnorePrefix: !!this.sortingIgnorePrefix
}
if (this.entityName === 'books') {
if (this.entityName === 'items') {
props.filterBy = this.filterBy
props.orderBy = this.orderBy
} else if (this.entityName === 'series') {
props.orderBy = this.seriesSortBy
}
var _this = this
var instance = new ComponentClass({
const _this = this
const instance = new ComponentClass({
propsData: props,
created() {
this.$on('edit', (entity) => {

View File

@@ -114,6 +114,11 @@ module.exports = {
{
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
sizes: "any"
},
{
src: (process.env.ROUTER_BASE_PATH || '') + '/icon64.png',
type: "image/png",
sizes: "64x64"
}
]
},

View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.2.9",
"version": "2.2.12",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.2.9",
"version": "2.2.12",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.2.9",
"version": "2.2.12",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {

View File

@@ -62,19 +62,42 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<div v-if="selectedTool === 'embed'" class="w-full flex justify-end items-center mb-4">
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div>
<div v-else class="w-full flex justify-end items-center mb-4">
<div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
</button>
<div class="flex-grow" />
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn>
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageM4BFailed }} {{ taskError }}</p>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div>
<div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 64k)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
</div>
<p class="text-sm text-warning">Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.</p>
</div>
</transition>
</div>
<div class="mb-4">
<div v-if="selectedTool === 'embed'" class="flex items-start mb-2">
<div v-if="isEmbedTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p>
</div>
@@ -85,21 +108,21 @@
</p>
</div>
<div class="flex items-start mb-2">
<div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
</p>
</div>
<div v-if="selectedTool === 'embed' && audioFiles.length > 1" class="flex items-start mb-2">
<div v-if="isEmbedTool && audioFiles.length > 1" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p>
</div>
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2">
<div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p>
</div>
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2">
<div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p>
</div>
@@ -152,7 +175,7 @@ export default {
if (!store.getters['user/getIsAdminOrUp']) {
return redirect('/?error=unauthorized')
}
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
const libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
@@ -180,7 +203,14 @@ export default {
isFinished: false,
toneObject: null,
selectedTool: 'embed',
isCancelingEncode: false
isCancelingEncode: false,
showEncodeOptions: false,
shouldBackupAudioFiles: true,
encodingOptions: {
bitrate: '64k',
channels: '2',
codec: 'aac'
}
}
},
watch: {
@@ -193,6 +223,12 @@ export default {
}
},
computed: {
isEmbedTool() {
return this.selectedTool === 'embed'
},
isM4BTool() {
return this.selectedTool === 'm4b'
},
libraryItemId() {
return this.libraryItem.id
},
@@ -244,6 +280,9 @@ export default {
}
},
methods: {
toggleBackupAudioFiles(val) {
localStorage.setItem('embedMetadataShouldBackup', val ? 1 : 0)
},
cancelEncodeClick() {
this.isCancelingEncode = true
this.$axios
@@ -260,9 +299,23 @@ export default {
})
},
encodeM4bClick() {
if (this.$refs.bitrateInput) this.$refs.bitrateInput.blur()
if (this.$refs.channelsInput) this.$refs.channelsInput.blur()
if (this.$refs.codecInput) this.$refs.codecInput.blur()
let queryStr = ''
if (this.showEncodeOptions) {
const options = []
if (this.encodingOptions.bitrate) options.push(`bitrate=${this.encodingOptions.bitrate}`)
if (this.encodingOptions.channels) options.push(`channels=${this.encodingOptions.channels}`)
if (this.encodingOptions.codec) options.push(`codec=${this.encodingOptions.codec}`)
if (options.length) {
queryStr = `?${options.join('&')}`
}
}
this.processing = true
this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b`)
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b${queryStr}`)
.then(() => {
console.log('Ab m4b merge started')
})
@@ -287,7 +340,7 @@ export default {
updateAudioFileMetadata() {
this.processing = true
this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?tone=1`)
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?backup=${this.shouldBackupAudioFiles ? 1 : 0}`)
.then(() => {
console.log('Audio metadata encode started')
})
@@ -305,9 +358,14 @@ export default {
console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return
this.processing = false
this.isFinished = true
this.audiofilesEncoding = {}
this.$toast.success('Audio file metadata updated')
if (data.failed) {
this.$toast.error(data.error)
} else {
this.isFinished = true
this.$toast.success('Audio file metadata updated')
}
},
audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return
@@ -333,6 +391,9 @@ export default {
}
if (this.task) this.taskUpdated(this.task)
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
},
fetchToneObject() {
this.$axios

View File

@@ -19,6 +19,11 @@
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<!-- RSS feed -->
<ui-tooltip v-if="rssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="showRSSFeedModal" />
</ui-tooltip>
<button type="button" class="h-9 w-9 flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5 mx-px" @click.stop.prevent="editClick">
<span class="material-icons text-xl">edit</span>
</button>
@@ -46,7 +51,7 @@ export default {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
var collection = await app.$axios.$get(`/api/collections/${params.id}`).catch((error) => {
const collection = await app.$axios.$get(`/api/collections/${params.id}?include=rssfeed`).catch((error) => {
console.error('Failed', error)
return false
})
@@ -61,7 +66,8 @@ export default {
store.commit('libraries/addUpdateCollection', collection)
return {
collectionId: collection.id
collectionId: collection.id,
rssFeed: collection.rssFeed || null
}
},
data() {
@@ -99,6 +105,9 @@ export default {
showPlayButton() {
return this.playableBooks.length
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@@ -112,6 +121,12 @@ export default {
action: 'create-playlist'
}
]
if (this.userIsAdminOrUp || this.rssFeed) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'open-rss-feed'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
@@ -122,11 +137,21 @@ export default {
}
},
methods: {
showRSSFeedModal() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.collectionId,
name: this.collectionName,
type: 'collection',
feed: this.rssFeed
})
},
contextMenuAction(action) {
if (action === 'delete') {
this.removeClick()
} else if (action === 'create-playlist') {
this.createPlaylistFromCollection()
} else if (action === 'open-rss-feed') {
this.showRSSFeedModal()
}
},
createPlaylistFromCollection() {
@@ -206,9 +231,27 @@ export default {
queueItems
})
}
},
rssFeedOpen(data) {
if (data.entityId === this.collectionId) {
console.log('RSS Feed Opened', data)
this.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.collectionId) {
console.log('RSS Feed Closed', data)
this.rssFeed = null
}
}
},
mounted() {},
beforeDestroy() {}
mounted() {
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
},
beforeDestroy() {
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
}
}
</script>

View File

@@ -2,9 +2,9 @@
<div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
<div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
<span class="material-icons text-2xl cursor-pointer" @click.stop.prevent="showMore">more_vert</span>
<p class="pl-3 capitalize">{{ currentPage }}</p>
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
<span class="material-icons text-2xl cursor-pointer">arrow_forward</span>
<p class="pl-3 capitalize">{{ $strings.HeaderSettings }}</p>
</div>
<nuxt-child />
</div>
@@ -35,8 +35,8 @@ export default {
}
},
computed: {
isMobile() {
return this.$store.state.globals.isMobile
isMobilePortrait() {
return this.$store.state.globals.isMobilePortrait
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
@@ -60,8 +60,8 @@ export default {
}
},
methods: {
showMore() {
this.sideDrawerOpen = true
toggleShowMore() {
this.sideDrawerOpen = !this.sideDrawerOpen
},
setDeveloperMode() {
var value = !this.$store.state.developerMode

View File

@@ -33,6 +33,8 @@
</div>
<tables-backups-table />
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
</app-settings-content>
</div>
</template>

View File

@@ -7,30 +7,30 @@
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
</div>
<div class="flex items-end py-2">
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-toggle-switch labeledBy="settings-store-cover-with-items" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4">
{{ $strings.LabelSettingsStoreCoversWithItem }}
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-toggle-switch labeledBy="settings-store-metadata-with-items" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<p class="pl-4">
{{ $strings.LabelSettingsStoreMetadataWithItem }}
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-toggle-switch labeledBy="settings-sorting-ignore-prefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<p class="pl-4">
{{ $strings.LabelSettingsSortingIgnorePrefixes }}
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
@@ -40,8 +40,8 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4">{{ $strings.LabelSettingsChromecastSupport }}</p>
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="pt-4">
@@ -49,33 +49,31 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="homepageUseBookshelfView" :disabled="updatingServerSettings" @input="updateHomeUseBookshelfView" />
<ui-toggle-switch labeledBy="settings-home-page-uses-bookshelf" v-model="homepageUseBookshelfView" :disabled="updatingServerSettings" @input="updateHomeUseBookshelfView" />
<ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp">
<p class="pl-4">
{{ $strings.LabelSettingsHomePageBookshelfView }}
<span id="settings-home-page-uses-bookshelf">{{ $strings.LabelSettingsHomePageBookshelfView }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useBookshelfView" :disabled="updatingServerSettings" @input="updateUseBookshelfView" />
<ui-toggle-switch labeledBy="settings-library-uses-bookshelf" v-model="useBookshelfView" :disabled="updatingServerSettings" @input="updateUseBookshelfView" />
<ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp">
<p class="pl-4">
{{ $strings.LabelSettingsLibraryBookshelfView }}
<span id="settings-library-uses-bookshelf">{{ $strings.LabelSettingsLibraryBookshelfView }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="py-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelSettingsDateFormat }}</p>
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
</div>
<div class="py-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelLanguageDefaultServer }}</p>
<ui-dropdown ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
<ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
</div>
</div>
@@ -85,20 +83,20 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
<p class="pl-4">
{{ $strings.LabelSettingsParseSubtitles }}
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
<p class="pl-4">
{{ $strings.LabelSettingsFindCovers }}
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
@@ -109,50 +107,50 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
<ui-toggle-switch labeledBy="settings-overdrive-media-markers" v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
<ui-tooltip :text="$strings.LabelSettingsOverdriveMediaMarkersHelp">
<p class="pl-4">
{{ $strings.LabelSettingsOverdriveMediaMarkers }}
<span id="settings-overdrive-media-markers">{{ $strings.LabelSettingsOverdriveMediaMarkers }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-toggle-switch labeledBy="settings-prefer-audio-metadata" v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferAudioMetadataHelp">
<p class="pl-4">
{{ $strings.LabelSettingsPreferAudioMetadata }}
<span id="settings-prefer-audio-metadata">{{ $strings.LabelSettingsPreferAudioMetadata }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
<ui-toggle-switch labeledBy="settings-prefer-opf-metadata" v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferOPFMetadataHelp">
<p class="pl-4">
{{ $strings.LabelSettingsPreferOPFMetadata }}
<span id="settings-prefer-opf-metadata">{{ $strings.LabelSettingsPreferOPFMetadata }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<p class="pl-4">
{{ $strings.LabelSettingsPreferMatchedMetadata }}
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
<p class="pl-4">
{{ $strings.LabelSettingsDisableWatcher }}
<span id="settings-disable-watcher">{{ $strings.LabelSettingsDisableWatcher }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
@@ -163,11 +161,11 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-toggle-switch labeledBy="settings-experimental-features" v-model="showExperimentalFeatures" />
<ui-tooltip :text="$strings.LabelSettingsExperimentalFeaturesHelp">
<p class="pl-4">
{{ $strings.LabelSettingsExperimentalFeatures }}
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span id="settings-experimental-features">{{ $strings.LabelSettingsExperimentalFeatures }}</span>
<a :aria-label="$strings.LabelSettingsExperimentalFeaturesHelp" href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-icons icon-text">info_outlined</span>
</a>
</p>
@@ -175,10 +173,10 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-toggle-switch labeledBy="settings-enable-e-reader" v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp">
<p class="pl-4">
{{ $strings.LabelSettingsEnableEReader }}
<span id="settings-enable-e-reader">{{ $strings.LabelSettingsEnableEReader }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>

View File

@@ -28,8 +28,10 @@
</template>
</div>
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center">
<ui-loading-indicator />
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 rounded-md">
<div class="sticky top-0 left-0 w-full h-full flex items-center justify-center" style="max-height: 80vh">
<ui-loading-indicator />
</div>
</div>
</div>
</template>

View File

@@ -28,8 +28,10 @@
</template>
</div>
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center">
<ui-loading-indicator />
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 rounded-md">
<div class="sticky top-0 left-0 w-full h-full flex items-center justify-center" style="max-height: 80vh">
<ui-loading-indicator />
</div>
</div>
</div>
</template>

View File

@@ -34,6 +34,9 @@
<template v-if="!isVideo">
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
<p v-else-if="musicArtists.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
@@ -59,6 +62,38 @@
{{ publishedYear }}
</div>
</div>
<div v-if="musicAlbum" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div>
<div>
{{ musicAlbum }}
</div>
</div>
<div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div>
<div>
{{ musicAlbumArtist }}
</div>
</div>
<div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div>
<div>
{{ musicTrackPretty }}
</div>
</div>
<div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div>
<div>
{{ musicDiscPretty }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
@@ -70,7 +105,7 @@
</template>
</div>
</div>
<div v-if="tracks.length" class="flex py-0.5">
<div v-if="tracks.length || audioFile" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
@@ -132,6 +167,7 @@
<span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
@@ -150,11 +186,11 @@
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-tooltip v-if="!isPodcast && !isMusic" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast && userCanUpdate" :text="$strings.LabelCollections" direction="top">
<ui-tooltip v-if="showCollectionsButton" :text="$strings.LabelCollections" direction="top">
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip>
@@ -173,7 +209,7 @@
<!-- RSS feed -->
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip>
</div>
@@ -199,7 +235,6 @@
</div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div>
</template>
@@ -222,7 +257,7 @@ export default {
}
return {
libraryItem: item,
rssFeedUrl: item.rssFeedUrl || null
rssFeed: item.rssFeed || null
}
},
data() {
@@ -234,7 +269,6 @@ export default {
podcastFeedEpisodes: [],
episodesDownloading: [],
episodeDownloadsQueued: [],
showRssFeedModal: false,
showBookmarksModal: false
}
},
@@ -263,12 +297,18 @@ export default {
isDeveloperMode() {
return this.$store.state.developerMode
},
isBook() {
return this.libraryItem.mediaType === 'book'
},
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
isVideo() {
return this.libraryItem.mediaType === 'video'
},
isMusic() {
return this.libraryItem.mediaType === 'music'
},
isMissing() {
return this.libraryItem.isMissing
},
@@ -276,11 +316,12 @@ export default {
return this.libraryItem.isInvalid
},
invalidAudioFiles() {
if (this.isPodcast || this.isVideo) return []
if (!this.isBook) return []
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
},
showPlayButton() {
if (this.isMissing || this.isInvalid) return false
if (this.isMusic) return !!this.audioFile
if (this.isVideo) return !!this.videoFile
if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length
@@ -338,6 +379,25 @@ export default {
authors() {
return this.mediaMetadata.authors || []
},
musicArtists() {
return this.mediaMetadata.artists || []
},
musicAlbum() {
return this.mediaMetadata.album || ''
},
musicAlbumArtist() {
return this.mediaMetadata.albumArtist || ''
},
musicTrackPretty() {
if (!this.mediaMetadata.trackNumber) return null
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
},
musicDiscPretty() {
if (!this.mediaMetadata.discNumber) return null
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
},
narrators() {
return this.mediaMetadata.narrators || []
},
@@ -346,7 +406,7 @@ export default {
},
seriesList() {
return this.series.map((se) => {
var text = se.name
let text = se.name
if (se.sequence) text += ` #${se.sequence}`
return {
...se,
@@ -355,11 +415,12 @@ export default {
})
},
durationPretty() {
if (!this.tracks.length) return 'N/A'
return this.$elapsedPretty(this.media.duration)
if (!this.tracks.length && !this.audioFile) return 'N/A'
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
return this.$elapsedPretty(this.duration)
},
duration() {
if (!this.tracks.length) return 0
if (!this.tracks.length && !this.audioFile) return 0
return this.media.duration
},
sizePretty() {
@@ -374,6 +435,10 @@ export default {
videoFile() {
return this.media.videoFile
},
audioFile() {
// Music track
return this.media.audioFile
},
showExperimentalReadAlert() {
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
},
@@ -381,6 +446,7 @@ export default {
return this.mediaMetadata.description || ''
},
userMediaProgress() {
if (this.isMusic) return null
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userIsFinished() {
@@ -388,7 +454,7 @@ export default {
},
userTimeRemaining() {
if (!this.userMediaProgress) return 0
var duration = this.userMediaProgress.duration || this.duration
const duration = this.userMediaProgress.duration || this.duration
return duration - this.userMediaProgress.currentTime
},
progressPercent() {
@@ -419,14 +485,17 @@ export default {
return this.$store.getters['user/getUserCanDownload']
},
showRssFeedBtn() {
if (!this.rssFeedUrl && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
// If rss feed is open then show feed url to users otherwise just show to admins
return this.userIsAdminOrUp || this.rssFeedUrl
return this.userIsAdminOrUp || this.rssFeed
},
showQueueBtn() {
if (this.isPodcast || this.isVideo) return false
if (!this.isBook) return false
return !this.$store.getters['getIsStreamingFromDifferentLibrary'] && this.streamLibraryItem
},
showCollectionsButton() {
return this.isBook && this.userCanUpdate
}
},
methods: {
@@ -531,14 +600,14 @@ export default {
})
},
playItem(startTime = null) {
var episodeId = null
let episodeId = null
const queueItems = []
if (this.isPodcast) {
const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
// Find most recent episode unplayed
var episodeIndex = episodesInListeningOrder.findLastIndex((ep) => {
var podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
let episodeIndex = episodesInListeningOrder.findLastIndex((ep) => {
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
return !podcastProgress || !podcastProgress.isFinished
})
if (episodeIndex < 0) episodeIndex = 0
@@ -617,7 +686,13 @@ export default {
this.$store.commit('globals/setShowPlaylistsModal', true)
},
clickRSSFeed() {
this.showRssFeedModal = true
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.libraryItemId,
name: this.title,
type: 'item',
feed: this.rssFeed,
hasEpisodesWithoutPubDate: this.podcastEpisodes.some((ep) => !ep.pubDate)
})
},
episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
@@ -639,13 +714,13 @@ export default {
rssFeedOpen(data) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
this.rssFeedUrl = data.feedUrl
this.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Closed', data)
this.rssFeedUrl = null
this.rssFeed = null
}
},
queueBtnClick() {

View File

@@ -8,8 +8,8 @@
<script>
export default {
async asyncData({ store, params, redirect }) {
var libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId)
const libraryId = params.library
const library = await store.dispatch('libraries/fetch', libraryId)
if (!library) {
return redirect(`/oops?message=Library "${libraryId}" not found`)
}

View File

@@ -8,8 +8,8 @@
<script>
export default {
async asyncData({ store, params, redirect, query, app }) {
var libraryId = params.library
var libraryData = await store.dispatch('libraries/fetch', libraryId)
const libraryId = params.library
const libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) {
return redirect('/oops?message=Library not found')
}
@@ -19,7 +19,7 @@ export default {
return redirect(`/library/${libraryId}`)
}
var series = await app.$axios.$get(`/api/series/${params.id}?include=progress`).catch((error) => {
const series = await app.$axios.$get(`/api/series/${params.id}?include=progress,rssfeed`).catch((error) => {
console.error('Failed', error)
return false
})

View File

@@ -127,7 +127,6 @@ export default {
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
this.$store.commit('feeds/setFeeds', feeds)
this.$setServerLanguageCode(serverSettings.language)
if (serverSettings.chromecastEnabled) {

View File

@@ -17,6 +17,7 @@ export default class PlayerHandler {
this.playerState = 'IDLE'
this.isHlsTranscode = false
this.isVideo = false
this.isMusic = false
this.currentSessionId = null
this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page)
this.startTime = 0
@@ -54,10 +55,13 @@ export default class PlayerHandler {
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
this.libraryItem = libraryItem
this.isVideo = libraryItem.mediaType === 'video'
this.isMusic = libraryItem.mediaType === 'music'
this.episodeId = episodeId
this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate
this.isVideo = libraryItem.mediaType === 'video'
this.initialPlaybackRate = this.isMusic ? 1 : playbackRate
this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
if (!this.player) this.switchPlayer(playWhenReady)
@@ -140,12 +144,14 @@ export default class PlayerHandler {
playerStateChange(state) {
console.log('[PlayerHandler] Player state change', state)
this.playerState = state
if (this.playerState === 'PLAYING') {
this.setPlaybackRate(this.initialPlaybackRate)
this.startPlayInterval()
} else {
this.stopPlayInterval()
}
if (this.player) {
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
this.ctx.setDuration(this.getDuration())
@@ -252,14 +258,14 @@ export default class PlayerHandler {
startPlayInterval() {
clearInterval(this.playInterval)
var lastTick = Date.now()
let lastTick = Date.now()
this.playInterval = setInterval(() => {
// Update UI
if (!this.player) return
var currentTime = this.player.getCurrentTime()
const currentTime = this.player.getCurrentTime()
this.ctx.setCurrentTime(currentTime)
var exactTimeElapsed = ((Date.now() - lastTick) / 1000)
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
lastTick = Date.now()
this.listeningTimeSinceSync += exactTimeElapsed
if (this.listeningTimeSinceSync >= 5) {
@@ -269,9 +275,9 @@ export default class PlayerHandler {
}
sendCloseSession() {
var syncData = null
let syncData = null
if (this.player) {
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
syncData = {
timeListened: listeningTimeToAdd,
duration: this.getDuration(),
@@ -285,12 +291,14 @@ export default class PlayerHandler {
}
sendProgressSync(currentTime) {
var diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)
if (this.isMusic) return
const diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)
if (diffSinceLastSync < 1) return
this.lastSyncTime = currentTime
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
var syncData = {
const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
const syncData = {
timeListened: listeningTimeToAdd,
duration: this.getDuration(),
currentTime

View File

@@ -47,7 +47,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
const windowsTrailingRe = /[\. ]+$/
const lineBreaks = /[\n\r]/g
sanitized = filename
let sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement)
.replace(controlRe, replacement)

View File

@@ -54,18 +54,18 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
if (isNaN(seconds) || seconds === null) return ''
seconds = Math.round(seconds)
var minutes = Math.floor(seconds / 60)
let minutes = Math.floor(seconds / 60)
seconds -= minutes * 60
var hours = Math.floor(minutes / 60)
let hours = Math.floor(minutes / 60)
minutes -= hours * 60
var days = 0
let days = 0
if (useDays || Math.floor(hours / 24) >= 100) {
days = Math.floor(hours / 24)
hours -= days * 24
}
var strs = []
const strs = []
if (days) strs.push(`${days}d`)
if (hours) strs.push(`${hours}h`)
if (minutes) strs.push(`${minutes}m`)

View File

Binary file not shown.

View File

@@ -1,28 +0,0 @@
export const state = () => ({
feeds: []
})
export const getters = {
getFeedForItem: state => id => {
return state.feeds.find(feed => feed.id === id)
}
}
export const actions = {
}
export const mutations = {
addFeed(state, feed) {
var index = state.feeds.findIndex(f => f.id === feed.id)
if (index >= 0) state.feeds.splice(index, 1, feed)
else state.feeds.push(feed)
},
removeFeed(state, feed) {
state.feeds = state.feeds.filter(f => f.id !== feed.id)
},
setFeeds(state, feeds) {
state.feeds = feeds || []
}
}

View File

@@ -1,6 +1,7 @@
export const state = () => ({
isMobile: false,
isMobileLandscape: false,
isMobilePortrait: false,
showBatchCollectionModal: false,
showCollectionsModal: false,
showEditCollectionModal: false,
@@ -8,9 +9,11 @@ export const state = () => ({
showEditPlaylistModal: false,
showEditPodcastEpisode: false,
showViewPodcastEpisodeModal: false,
showRSSFeedOpenCloseModal: false,
showConfirmPrompt: false,
confirmPromptOptions: null,
showEditAuthorModal: false,
rssFeedEntity: null,
selectedEpisode: null,
selectedPlaylistItems: null,
selectedPlaylist: null,
@@ -74,7 +77,8 @@ export const getters = {
export const mutations = {
updateWindowSize(state, { width, height }) {
state.isMobile = width < 640 || height < 640
state.isMobileLandscape = state.isMobile && height > width
state.isMobileLandscape = state.isMobile && height < width
state.isMobilePortrait = state.isMobile && height >= width
},
setShowCollectionsModal(state, val) {
state.showBatchCollectionModal = false
@@ -99,6 +103,13 @@ export const mutations = {
setShowViewPodcastEpisodeModal(state, val) {
state.showViewPodcastEpisodeModal = val
},
setShowRSSFeedOpenCloseModal(state, val) {
state.showRSSFeedOpenCloseModal = val
},
setRSSFeedOpenCloseModal(state, entity) {
state.rssFeedEntity = entity
state.showRSSFeedOpenCloseModal = true
},
setShowConfirmPrompt(state, val) {
state.showConfirmPrompt = val
},

View File

@@ -70,8 +70,8 @@ export const actions = {
},
loadFolders({ state, commit }) {
if (state.folders.length) {
var lastCheck = Date.now() - state.folderLastUpdate
if (lastCheck < 1000 * 60 * 10) { // 10 minutes
const lastCheck = Date.now() - state.folderLastUpdate
if (lastCheck < 1000 * 5) { // 5 seconds
// Folders up to date
return state.folders
}

View File

@@ -20,6 +20,7 @@
"ButtonCreate": "Ertsellen",
"ButtonCreateBackup": "Sicherung erstellen",
"ButtonDelete": "Löschen",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten",
"ButtonForceReScan": "Erzwinge kompletten Neu-Scan",
@@ -41,10 +42,10 @@
"ButtonOpenManager": "Manager öffnen",
"ButtonPlay": "Abspielen",
"ButtonPlaying": "Spielt",
"ButtonPlaylists": "Playlists",
"ButtonPurgeAllCache": "Bereinige alle Zwischenspeicher",
"ButtonPurgeItemsCache": "Bereinige den Hörbuch/Podcast-Zwischenspeicher",
"ButtonPurgeMediaProgress": "Bereinige die Hörfortschritte",
"ButtonPlaylists": "Wiedergabelisten",
"ButtonPurgeAllCache": "Lösche alle Zwischenspeicher",
"ButtonPurgeItemsCache": "Lösche Medien-Zwischenspeicher",
"ButtonPurgeMediaProgress": "Lösche Hörfortschritte",
"ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
"ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
"ButtonQuickMatch": "Schnellabgleich",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Sicherung hochladen",
"ButtonUploadCover": "Titelbild hochladen",
"ButtonUploadOPMLFile": "OPML-Datei hochladen",
"ButtonUserDelete": "Benutzer {0} löschen",
"ButtonUserEdit": "Benutzer {0} editieren",
"ButtonViewAll": "Alles anzeigen",
"ButtonYes": "Ja",
"HeaderAccount": "Konto",
@@ -94,8 +97,8 @@
"HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien",
"HeaderItemFiles": "Objekt-Dateien",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderItemFiles": "Medien-Dateien",
"HeaderItemMetadataUtils": "Metadaten",
"HeaderLastListeningSession": "Letzte Hörsitzung",
"HeaderLatestEpisodes": "Letzte Episoden",
"HeaderLibraries": "Bibliotheken",
@@ -105,8 +108,8 @@
"HeaderListeningStats": "Hörstatistiken",
"HeaderLogin": "Anmeldung",
"HeaderLogs": "Protokolle",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderManageGenres": "Kategorien verwalten",
"HeaderManageTags": "Tags verwalten",
"HeaderMapDetails": "Stapelverarbeitung",
"HeaderMatch": "Online-Suche",
"HeaderMetadataToEmbed": "Einzubettende Metadaten",
@@ -117,8 +120,8 @@
"HeaderOtherFiles": "Sonstige Dateien",
"HeaderPermissions": "Berechtigungen",
"HeaderPlayerQueue": "Spieler Warteschlange",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPlaylist": "Wiedergabeliste",
"HeaderPlaylistItems": "Einträge in der Wiedergabeliste",
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
"HeaderPreviewCover": "Vorschau Titelbild",
"HeaderRemoveEpisode": "Episode löschen",
@@ -146,7 +149,7 @@
"HeaderUpdateDetails": "Details aktualisieren",
"HeaderUpdateLibrary": "Bibliothek aktualisieren",
"HeaderUsers": "Benutzer",
"HeaderYourStats": "Eigene Statistik",
"HeaderYourStats": "Eigene Statistiken",
"LabelAccountType": "Kontoart",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast",
@@ -154,9 +157,9 @@
"LabelActivity": "Aktivitäten",
"LabelAddedAt": "Hinzugefügt am",
"LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Bücher der Sammlung hinzu",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer",
"LabelAppend": "Anhängen",
@@ -235,7 +238,7 @@
"LabelIntervalEveryDay": "Jeden Tag",
"LabelIntervalEveryHour": "Jede Stunde",
"LabelInvalidParts": "Ungültige Teile",
"LabelItem": "Hörbuch/Podcast",
"LabelItem": "Medium",
"LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLastSeen": "Zuletzt angesehen",
@@ -252,7 +255,6 @@
"LabelLogLevelInfo": "Informationen",
"LabelLogLevelWarn": "Warnungen",
"LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum",
"LabelMarkSeries": "Serien markieren als",
"LabelMediaPlayer": "Mediaplayer",
"LabelMediaType": "Medientyp",
"LabelMetadataProvider": "Metadatenanbieter",
@@ -294,7 +296,7 @@
"LabelPermissionsUpdate": "Aktualisieren",
"LabelPermissionsUpload": "Hochladen",
"LabelPhotoPathURL": "Foto Pfad/URL",
"LabelPlaylists": "Playlists",
"LabelPlaylists": "Wiedergabelisten",
"LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
@@ -350,10 +352,10 @@
"LabelSettingsSortingIgnorePrefixesHelp": "Beispiel: für den Artikel \"der\" würde der Hörbuchtitel \"Der Buchtitel\" als \"Buchtitel, Der\" sortiert werden.",
"LabelSettingsSquareBookCovers": "Benutze quadratische Titelbilder",
"LabelSettingsSquareBookCoversHelp": "Bevorzugen quadratische Titelbilder gegenüber den Standardtielbildern im Verhältnis 1,6:1",
"LabelSettingsStoreCoversWithItem": "Titelbilder im Hörbuchordner speichern",
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Hörbuch befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Hörbuchordner speichern",
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Hörbuch befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
"LabelSettingsStoreCoversWithItem": "Titelbilder im Medienordner speichern",
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
"LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer",
@@ -369,7 +371,7 @@
"LabelStatsDaysListened": "Gehörte Tage",
"LabelStatsHours": "Stunden",
"LabelStatsInARow": "nacheinander",
"LabelStatsItemsFinished": "Gehörte Hörbücher/Podcasts",
"LabelStatsItemsFinished": "Gehörte Medien",
"LabelStatsItemsInLibrary": "Bibliothekseinträge",
"LabelStatsMinutes": "Minuten",
"LabelStatsMinutesListening": "Gehörte Minuten",
@@ -421,7 +423,7 @@
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs",
"LabelYourBookmarks": "Lesezeichen",
"LabelYourPlaylists": "Eigene Playlists",
"LabelYourPlaylists": "Eigene Wiedergabelisten",
"LabelYourProgress": "Fortschritt",
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, müssen Sie eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würden Sie <code>http://192.168.1.1:8337/notify</code> eingeben.",
@@ -441,16 +443,18 @@
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmRemovePlaylist": "Sind Sie sicher, dass Sie die Wiedergabeliste \"{0}\" entfernen möchten?",
"MessageConfirmRenameGenre": "Sind Sie sicher, dass Sie die Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
"MessageConfirmRenameGenreWarning": "Warnung! Ein ähnliche Kategorie mit einem anderen Wortlaut existiert bereits: \"{0}\".",
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
"MessageDownloadingEpisode": "Episode herunterladen",
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
"MessageEmbedFinished": "Einbettung abgeschlossen!",
@@ -460,8 +464,8 @@
"MessageForceReScanDescription": "durchsucht alle Dateien neu, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.",
"MessageImportantNotice": "Wichtiger Hinweis!",
"MessageInsertChapterBelow": "Kapitel unten einfügen",
"MessageItemsSelected": "{0} ausgewählte Elemente",
"MessageItemsUpdated": "{0} Items Updated",
"MessageItemsSelected": "{0} ausgewählte Medien",
"MessageItemsUpdated": "{0} Medien aktualisiert",
"MessageJoinUsOn": "Besuchen Sie uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Laden...",
@@ -485,8 +489,8 @@
"MessageNoFoldersAvailable": "Keine Ordner verfügbar",
"MessageNoGenres": "Keine Kategorien",
"MessageNoIssues": "Keine Probleme",
"MessageNoItems": "Keine Elemente/Einträge",
"MessageNoItemsFound": "Keine Elemente/Einträge gefunden",
"MessageNoItems": "Keine Medien",
"MessageNoItemsFound": "Keine Medien gefunden",
"MessageNoListeningSessions": "Keine Hörsitzungen",
"MessageNoLogs": "Keine Protokolle",
"MessageNoMediaProgress": "Kein Medienfortschritt",
@@ -495,7 +499,7 @@
"MessageNoResults": "Keine Ergebnisse",
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "Keine Serien",
"MessageNoTags": "No Tags",
"MessageNoTags": "Keine Tags",
"MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
@@ -503,7 +507,7 @@
"MessageOr": "oder",
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
@@ -539,7 +543,7 @@
"NoteUploaderUnsupportedFiles": "Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.",
"PlaceholderNewCollection": "Neuer Sammlungsname",
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
"PlaceholderSearch": "Suche...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert",
@@ -566,21 +570,21 @@
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
"ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
"ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen",
"ToastCollectionItemsRemoveFailed": "Element(e) konnte(n) nicht aus der Sammlung entfernt werden",
"ToastCollectionItemsRemoveSuccess": "Element(e) wurde(n) aus der Sammlung entfernt",
"ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung",
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
"ToastCollectionRemoveSuccess": "Sammlung gelöscht",
"ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastItemCoverUpdateFailed": "Aktualisierung des Titelbildes fehlgeschlagen",
"ToastItemCoverUpdateFailed": "Fehler bei der Aktualisierung des Titelbildes",
"ToastItemCoverUpdateSuccess": "Titelbild aktualisiert",
"ToastItemDetailsUpdateFailed": "Fehler bei der Aktualisierung der Artikeldetails",
"ToastItemDetailsUpdateSuccess": "Artikeldetails aktualisiert",
"ToastItemDetailsUpdateUnneeded": "Keine Aktualisierung für Artikeldetails erforderlichs",
"ToastItemMarkedAsFinishedFailed": "Als \"abgeschlossen zu markieren\" ist fehlgeschlagen",
"ToastItemMarkedAsFinishedSuccess": "Artikel/Eintrag als fertig markiert",
"ToastItemMarkedAsNotFinishedFailed": "Fehler bei der Markierung als \"Nicht Fertig\"",
"ToastItemMarkedAsNotFinishedSuccess": "Artikel/Eintrag als \"Nicht Fertig\" markiert",
"ToastItemDetailsUpdateUnneeded": "Keine Aktualisierung für die Artikeldetails erforderlich",
"ToastItemMarkedAsFinishedFailed": "Fehler bei der Markierung des Mediums als \"Beendet\"",
"ToastItemMarkedAsFinishedSuccess": "Medium als \"Beendet\" markiert",
"ToastItemMarkedAsNotFinishedFailed": "Fehler bei der Markierung des Mediums als \"Nicht Beendet\"",
"ToastItemMarkedAsNotFinishedSuccess": "Medium als \"Nicht Beendet\" markiert",
"ToastLibraryCreateFailed": "Bibliothek konnte nicht erstellt werden",
"ToastLibraryCreateSuccess": "Bibliothek \"{0}\" erstellt",
"ToastLibraryDeleteFailed": "Bibliothek konnte nicht gelöscht werden",
@@ -589,18 +593,20 @@
"ToastLibraryScanStarted": "Bibliotheksscan gestartet",
"ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen",
"ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist aktualisieren",
"ToastPlaylistCreateFailed": "Erstellen der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistCreateSuccess": "Wiedergabeliste erstellt",
"ToastPlaylistRemoveFailed": "Löschen der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistRemoveSuccess": "Wiedergabeliste gelöscht",
"ToastPlaylistUpdateFailed": "Aktualisieren der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistUpdateSuccess": "Wiedergabeliste aktualisiert",
"ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden",
"ToastPodcastCreateSuccess": "Podcast erfolgreich erstellt",
"ToastRemoveItemFromCollectionFailed": "Element/Eintrag konnte nicht aus der Sammlung entfernt werden",
"ToastRemoveItemFromCollectionSuccess": "Element/Eintrag aus der Sammlung entfernt",
"ToastPodcastCreateSuccess": "Podcast erstellt",
"ToastRemoveItemFromCollectionFailed": "Löschen des Mediums aus der Sammlung fehlgeschlagen",
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
"ToastSessionDeleteSuccess": "Sitzung gelöscht",
"ToastSocketConnected": "Verbindung zum WebSocket hergestellt",

View File

@@ -20,6 +20,7 @@
"ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Edit Chapters",
"ButtonEditPodcast": "Edit Podcast",
"ButtonForceReScan": "Force Re-Scan",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Cover",
"ButtonUploadOPMLFile": "Upload OPML File",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "View All",
"ButtonYes": "Yes",
"HeaderAccount": "Account",
@@ -252,7 +255,6 @@
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMarkSeries": "Mark Series",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider",
@@ -441,6 +443,8 @@
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
@@ -601,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted",
"ToastSocketConnected": "Socket connected",

View File

@@ -20,6 +20,7 @@
"ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Edit Chapters",
"ButtonEditPodcast": "Edit Podcast",
"ButtonForceReScan": "Force Re-Scan",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Cover",
"ButtonUploadOPMLFile": "Upload OPML File",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "View All",
"ButtonYes": "Yes",
"HeaderAccount": "Account",
@@ -252,7 +255,6 @@
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMarkSeries": "Mark Series",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider",
@@ -441,6 +443,8 @@
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
@@ -601,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted",
"ToastSocketConnected": "Socket connected",

View File

@@ -20,6 +20,7 @@
"ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une Sauvegarde",
"ButtonDelete": "Effacer",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Editer Chapitre",
"ButtonEditPodcast": "Editer Podcast",
"ButtonForceReScan": "Forcer un Re-Scan",
@@ -50,16 +51,16 @@
"ButtonQuickMatch": "Recherche Rapide",
"ButtonRead": "Lire",
"ButtonRemove": "Supprimer",
"ButtonRemoveAll": "Supprimer Tout",
"ButtonRemoveAllLibraryItems": "Supprimer Tout les Articles de la Bibliothèque",
"ButtonRemoveFromContinueListening": "Supprimer de Continuer à Ecouter",
"ButtonRemoveSeriesFromContinueSeries": "Supprimer la Série de Continuer la Série",
"ButtonRemoveAll": "Supprimer tout",
"ButtonRemoveAllLibraryItems": "Supprimer tous les Articles de la Bibliothèque",
"ButtonRemoveFromContinueListening": "Ne plus continuer à écouter",
"ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la Série",
"ButtonReScan": "Re-Scan",
"ButtonReset": "Réinitialiser",
"ButtonRestore": "Rétablir",
"ButtonSave": "Sauvegarder",
"ButtonSaveAndClose": "Sauvegarder & Fermer",
"ButtonSaveTracklist": "Sauvegarder la Tracklist",
"ButtonSaveTracklist": "Sauvegarder la liste de lecture",
"ButtonScan": "Scanner",
"ButtonScanLibrary": "Scanner la Bibliothèque",
"ButtonSearch": "Rechercher",
@@ -67,15 +68,17 @@
"ButtonSeries": "Séries",
"ButtonSetChaptersFromTracks": "Positionner les Chapitre par rapports aux Pistes",
"ButtonShiftTimes": "Décaler le Temps",
"ButtonShow": "Montrer",
"ButtonStartM4BEncode": "Démarrer l'Encodage M4B",
"ButtonShow": "Afficher",
"ButtonStartM4BEncode": "Démarrer l'encodage M4B",
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées Intégrées",
"ButtonSubmit": "Soumettre",
"ButtonUpload": "Téléverser",
"ButtonUploadBackup": "Téléverser une Sauvegarde",
"ButtonUploadCover": "Téléverser une Couverture",
"ButtonUploadOPMLFile": "Téléverser un Fichier OPML",
"ButtonViewAll": "Voir Tout",
"ButtonUserDelete": "Effacer l'utilisateur {0}",
"ButtonUserEdit": "Modifier l'utilisateur {0}",
"ButtonViewAll": "Afficher Tout",
"ButtonYes": "Oui",
"HeaderAccount": "Compte",
"HeaderAdvanced": "Avancé",
@@ -83,7 +86,7 @@
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
"HeaderAudioTracks": "Pistes Audio",
"HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Chager le Mot de Passe",
"HeaderChangePassword": "Chager le mot de passe",
"HeaderChapters": "Chapitres",
"HeaderChooseAFolder": "Choisir un Dossier",
"HeaderCollection": "Collection",
@@ -95,7 +98,7 @@
"HeaderFindChapters": "Trouver les Chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés",
"HeaderItemFiles": "Fichiers des Articles",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderItemMetadataUtils": "Outils de Gestion des Métadonnées",
"HeaderLastListeningSession": "Dernière Session d'Ecoute",
"HeaderLatestEpisodes": "Dernier Episodes",
"HeaderLibraries": "Bibliothèque",
@@ -105,8 +108,8 @@
"HeaderListeningStats": "Statistiques d'Ecoute",
"HeaderLogin": "Connexion",
"HeaderLogs": "Fichiers Journaux",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderManageGenres": "Gérer les Genres",
"HeaderManageTags": "Gérer les Etiquettes",
"HeaderMapDetails": "Edition en Masse",
"HeaderMatch": "Rechercher",
"HeaderMetadataToEmbed": "Métadonnée à Intégrer",
@@ -252,7 +255,6 @@
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Rechercher de Nouveaux Episode après cette Date",
"LabelMarkSeries": "Marquer la Série",
"LabelMediaPlayer": "Lecteur Multimédia",
"LabelMediaType": "Type de Média",
"LabelMetadataProvider": "Fournisseur de Métadonnées",
@@ -414,9 +416,9 @@
"LabelUsername": "Nom d'Utilisateur",
"LabelValue": "Valeur",
"LabelVersion": "Version",
"LabelViewBookmarks": "Voir les Signets",
"LabelViewChapters": "Voir les Chapitres",
"LabelViewQueue": "Voir la Liste de Lecture",
"LabelViewBookmarks": "Afficher les Signets",
"LabelViewChapters": "Afficher les Chapitres",
"LabelViewQueue": "Afficher la Liste de Lecture",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Jours de la semaine à exécuter",
"LabelYourAudiobookDuration": "Durée de vos Livres Audios",
@@ -441,16 +443,18 @@
"MessageConfirmDeleteLibrary": "Etes vous certain de vouloir supprimer définitivement la bibliothèque \"{0}\"?",
"MessageConfirmDeleteSession": "Etes vous certain de vouloir supprimer cette session?",
"MessageConfirmForceReScan": "Etes vous certain de vouloir lancer une Analyse Forcée?",
"MessageConfirmMarkSeriesFinished": "Etes vous certain de vouloir marquer comme terminé tous les livres de cette série?",
"MessageConfirmMarkSeriesNotFinished": "Etes vous certain de vouloir marquer comme non terminé tous les livres de cette série?",
"MessageConfirmRemoveCollection": "Etes vous certain de vouloir supprimer la collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?",
"MessageConfirmRemovePlaylist": "Etes vous certain de vouloir supprimer la liste de lecture \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmRenameGenre": "Etes vous certain de vouloir renommer le genre \"{0}\" vers \"{1}\" pour tous les articles?",
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
"MessageConfirmRenameGenreWarning": "Attention! Un genre similaire avec une casse différente existe déjà \"{0}\".",
"MessageConfirmRenameTag": "Etes vous certain de vouloir renommer l'étiquette \"{0}\" vers \"{1}\" pour tous les articles?",
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Attention! Une étiquette similaire avec une casse différente existe déjà \"{0}\".",
"MessageDownloadingEpisode": "Téléchargement de l'épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct",
"MessageEmbedFinished": "Intégration Terminée!",
@@ -461,7 +465,7 @@
"MessageImportantNotice": "Information Importante!",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"MessageItemsSelected": "{0} Articles Sélectionnés",
"MessageItemsUpdated": "{0} Items Updated",
"MessageItemsUpdated": "{0} Articles Mis à Jour",
"MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier",
"MessageLoading": "Chargement...",
@@ -495,7 +499,7 @@
"MessageNoResults": "Pas de Résultats",
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"",
"MessageNoSeries": "Pas de Séries",
"MessageNoTags": "No Tags",
"MessageNoTags": "Pas d'Etiquettes",
"MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Pas de mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire",
@@ -519,8 +523,8 @@
"MessageServerCouldNotBeReached": "Serveur inaccessible",
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour \"{0}\" à {1}?",
"MessageThinking": "On Réfléchit...",
"MessageUploaderItemFailed": "Echec du téléversement",
"MessageThinking": "On réfléchit...",
"MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué!",
"MessageUploading": "Téléversement...",
"MessageValidCronExpression": "Expression cron valide",
@@ -541,72 +545,74 @@
"PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
"PlaceholderSearch": "Recherche...",
"ToastAccountUpdateFailed": "Echec de la mise à jour du compte",
"ToastAccountUpdateFailed": "Échec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAuthorImageRemoveFailed": "Echec de la suppression de l'image",
"ToastAuthorImageRemoveFailed": "Échec de la suppression de l'image",
"ToastAuthorImageRemoveSuccess": "Image de l'auteur supprimée",
"ToastAuthorUpdateFailed": "Echec de la mise à jour de l'auteur",
"ToastAuthorUpdateFailed": "Échec de la mise à jour de l'auteur",
"ToastAuthorUpdateMerged": "Auteur fusionné",
"ToastAuthorUpdateSuccess": "Auteur mis à jour",
"ToastAuthorUpdateSuccessNoImageFound": "Auteur mis à jour (pas d'image trouvée)",
"ToastBackupCreateFailed": "Echec de la création de sauvegarde",
"ToastBackupCreateFailed": "Échec de la création de sauvegarde",
"ToastBackupCreateSuccess": "Sauvegarde créée",
"ToastBackupDeleteFailed": "Echec de la suppression de sauvegarde",
"ToastBackupDeleteFailed": "Échec de la suppression de sauvegarde",
"ToastBackupDeleteSuccess": "Sauvegarde supprimée",
"ToastBackupRestoreFailed": "Echec de la restauration de sauvegarde",
"ToastBackupUploadFailed": "Echec du téléversement de sauvegarde",
"ToastBackupRestoreFailed": "Échec de la restauration de sauvegarde",
"ToastBackupUploadFailed": "Échec du téléversement de sauvegarde",
"ToastBackupUploadSuccess": "Sauvegarde téléversée",
"ToastBatchUpdateFailed": "Echec de la mise à jour par lot",
"ToastBatchUpdateFailed": "Échec de la mise à jour par lot",
"ToastBatchUpdateSuccess": "Mise à jour par lot terminée",
"ToastBookmarkCreateFailed": "Echec de la création de signet",
"ToastBookmarkCreateFailed": "Échec de la création de signet",
"ToastBookmarkCreateSuccess": "Signet ajouté",
"ToastBookmarkRemoveFailed": "Echec de la suppression de signet",
"ToastBookmarkRemoveFailed": "Échec de la suppression de signet",
"ToastBookmarkRemoveSuccess": "Signet supprimé",
"ToastBookmarkUpdateFailed": "Echec de la mise à jour de signet",
"ToastBookmarkUpdateFailed": "Échec de la mise à jour de signet",
"ToastBookmarkUpdateSuccess": "Signet mis à jour",
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
"ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre",
"ToastCollectionItemsRemoveFailed": "Echec de la suppression de(s) article(s) de la collection",
"ToastCollectionItemsRemoveFailed": "Échec de la suppression de(s) article(s) de la collection",
"ToastCollectionItemsRemoveSuccess": "Article(s) supprimé(s) de la collection",
"ToastCollectionRemoveFailed": "Echec de la suppression de la collection",
"ToastCollectionRemoveFailed": "Échec de la suppression de la collection",
"ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateFailed": "Echec de la mise à jour de la collection",
"ToastCollectionUpdateFailed": "Échec de la mise à jour de la collection",
"ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastItemCoverUpdateFailed": "Echec de la mise à jour de la couverture de l'article",
"ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de l'article",
"ToastItemCoverUpdateSuccess": "Couverture de l'article mise à jour",
"ToastItemDetailsUpdateFailed": "Echec de la mise à jour des détails de l'article",
"ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de l'article",
"ToastItemDetailsUpdateSuccess": "Détails de l'article mis à jour",
"ToastItemDetailsUpdateUnneeded": "Pas de mise à jour nécessaire pour les détails de l'article",
"ToastItemMarkedAsFinishedFailed": "Echec de l'annotation terminée",
"ToastItemMarkedAsFinishedFailed": "Échec de l'annotation terminée",
"ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé",
"ToastItemMarkedAsNotFinishedFailed": "Echec de l'annotation non-terminée",
"ToastItemMarkedAsNotFinishedFailed": "Échec de l'annotation non-terminée",
"ToastItemMarkedAsNotFinishedSuccess": "Article marqué comme non-terminé",
"ToastLibraryCreateFailed": "Echec de la création de bibliothèque",
"ToastLibraryCreateFailed": "Échec de la création de bibliothèque",
"ToastLibraryCreateSuccess": "Bibliothèque \"{0}\" créée",
"ToastLibraryDeleteFailed": "Echec de la suppression de la bibliothèque",
"ToastLibraryDeleteFailed": "Échec de la suppression de la bibliothèque",
"ToastLibraryDeleteSuccess": "Bibliothèque supprimée",
"ToastLibraryScanFailedToStart": "Echec du démarrage de l'analyse",
"ToastLibraryScanFailedToStart": "Échec du démarrage de l'analyse",
"ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée",
"ToastLibraryUpdateFailed": "Echec de la mise à jour de la bibliothèque",
"ToastLibraryUpdateFailed": "Échec de la mise à jour de la bibliothèque",
"ToastLibraryUpdateSuccess": "Bibliothèque \"{0}\" mise à jour",
"ToastPlaylistCreateFailed": "Echec de la création de la liste de lecture",
"ToastPlaylistCreateFailed": "Échec de la création de la liste de lecture",
"ToastPlaylistCreateSuccess": "Liste de lecture créée",
"ToastPlaylistRemoveFailed": "Echec de la suppression de la liste de lecture",
"ToastPlaylistRemoveFailed": "Échec de la suppression de la liste de lecture",
"ToastPlaylistRemoveSuccess": "Liste de lecture supprimée",
"ToastPlaylistUpdateFailed": "Echec de la mise à jour de la liste de lecture",
"ToastPlaylistUpdateFailed": "Échec de la mise à jour de la liste de lecture",
"ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour",
"ToastPodcastCreateFailed": "Echec de la création du Podcast",
"ToastPodcastCreateFailed": "Échec de la création du Podcast",
"ToastPodcastCreateSuccess": "Podcast créé",
"ToastRemoveItemFromCollectionFailed": "Echec de la suppression de l'article de la collection",
"ToastRemoveItemFromCollectionFailed": "Échec de la suppression de l'article de la collection",
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
"ToastRSSFeedCloseFailed": "Echec de la fermeture du flux RSS",
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS fermé",
"ToastSessionDeleteFailed": "Echec de la suppression de session",
"ToastSeriesUpdateFailed": "Echec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastSessionDeleteFailed": "Échec de la suppression de session",
"ToastSessionDeleteSuccess": "Session supprimée",
"ToastSocketConnected": "WebSocket connectée",
"ToastSocketDisconnected": "WebSocket déconnectée",
"ToastSocketFailedToConnect": "Echec de la connexion WebSocket",
"ToastUserDeleteFailed": "Echec de la suppression de l'utilisateur",
"ToastSocketConnected": "WebSocket connecté",
"ToastSocketDisconnected": "WebSocket déconnecté",
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de l'utilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé",
"WeekdayFriday": "Vendredi",
"WeekdayMonday": "Lundi",

View File

@@ -20,6 +20,7 @@
"ButtonCreate": "Napravi",
"ButtonCreateBackup": "Napravi backup",
"ButtonDelete": "Obriši",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Uredi poglavlja",
"ButtonEditPodcast": "Uredi podcast",
"ButtonForceReScan": "Prisilno ponovno skeniranje",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Upload backup",
"ButtonUploadCover": "Upload Cover",
"ButtonUploadOPMLFile": "Upload OPML Datoteku",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Prikaži sve",
"ButtonYes": "Da",
"HeaderAccount": "Korisnički račun",
@@ -252,7 +255,6 @@
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma",
"LabelMarkSeries": "Označi seriju",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Poslužitelj metapodataka ",
@@ -441,6 +443,8 @@
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
"MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?",
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
@@ -601,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz kolekcije",
"ToastRSSFeedCloseFailed": "Neuspješno zatvaranje RSS Feeda",
"ToastRSSFeedCloseSuccess": "RSS Feed zatvoren",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Neuspješno brisanje serije",
"ToastSessionDeleteSuccess": "Sesija obrisana",
"ToastSocketConnected": "Socket connected",

View File

@@ -20,6 +20,7 @@
"ButtonCreate": "Crea",
"ButtonCreateBackup": "Crea un Backup",
"ButtonDelete": "Elimina",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Modifica Capitoli",
"ButtonEditPodcast": "Modifica Podcast",
"ButtonForceReScan": "Forza Re-Scan",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Carica Backup",
"ButtonUploadCover": "Carica Cover",
"ButtonUploadOPMLFile": "Carica File OPML",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Mostra Tutto",
"ButtonYes": "Si",
"HeaderAccount": "Account",
@@ -95,7 +98,7 @@
"HeaderFindChapters": "Trova Capitoli",
"HeaderIgnoredFiles": "File Ignorati",
"HeaderItemFiles": "Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderItemMetadataUtils": "Utilità Metadata oggetti",
"HeaderLastListeningSession": "Ultima sessione di Ascolto",
"HeaderLatestEpisodes": "Ultimi Episodi",
"HeaderLibraries": "Librerie",
@@ -105,9 +108,9 @@
"HeaderListeningStats": "Statistiche di Ascolto",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderManageGenres": "Gestisci Generi",
"HeaderManageTags": "Gestisci Tags",
"HeaderMapDetails": "Mappa Dettagli",
"HeaderMatch": "Trova Corrispondenza",
"HeaderMetadataToEmbed": "Metadata da incorporare",
"HeaderNewAccount": "Nuovo Account",
@@ -157,9 +160,9 @@
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
"LabelAddToPlaylist": "aggiungi alla Playlist",
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
"LabelAll": "All",
"LabelAll": "Tutti",
"LabelAllUsers": "Tutti gli Utenti",
"LabelAppend": "Append",
"LabelAppend": "Appese",
"LabelAuthor": "Autore",
"LabelAuthorFirstLast": "Autore (Per Nome)",
"LabelAuthorLastFirst": "Autori (Per Cognome)",
@@ -252,7 +255,6 @@
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Allarme",
"LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data",
"LabelMarkSeries": "Segna Serie",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Tipo Media",
"LabelMetadataProvider": "Metadata Provider",
@@ -347,7 +349,7 @@
"LabelSettingsSkipMatchingBooksWithASIN": "Salta la ricerca dati in internet se è già presente un codice ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Salta la ricerca dati in internet se è già presente un codice ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignora i prefissi nei titoli durante l'aggiunta",
"LabelSettingsSortingIgnorePrefixesHelp": "Per prefisso si intende ad esempio \"il\" cone nel libro \"Il signore degli anelli\" che verrebbe ordinato come \"signore degli anelli, il\"",
"LabelSettingsSortingIgnorePrefixesHelp": "Per prefisso si intende ad esempio \"il\" come nel libro \"Il signore degli anelli\" che verrebbe ordinato come \"signore degli anelli, il\"",
"LabelSettingsSquareBookCovers": "Utilizza le copertine quadrate",
"LabelSettingsSquareBookCoversHelp": "Preferisci usare copertine quadrate rispetto a copertine di libri standard 1,6:1",
"LabelSettingsStoreCoversWithItem": "Archivia le copertine con il file",
@@ -441,16 +443,18 @@
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameGenreMergeNote": "Note: Questo genere esiste già quindi verra unito.",
"MessageConfirmRenameGenreWarning": "Avvertimento! Esiste già un genere simile con un nome simile \"{0}\".",
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameTagMergeNote": "Note: Questo tag esiste già e verrà unito nel vecchio.",
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".",
"MessageDownloadingEpisode": "Download episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFinished": "Incorporamento finito!",
@@ -461,7 +465,7 @@
"MessageImportantNotice": "Avviso Importante!",
"MessageInsertChapterBelow": "Inserisci capitolo sotto",
"MessageItemsSelected": "{0} oggetti Selezionati",
"MessageItemsUpdated": "{0} Items Updated",
"MessageItemsUpdated": "{0} Oggetti aggiornati",
"MessageJoinUsOn": "Unisciti a noi su",
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
"MessageLoading": "Caricamento...",
@@ -503,7 +507,7 @@
"MessageOr": "o",
"MessagePauseChapter": "Metti in Pausa Capitolo",
"MessagePlayChapter": "Ascolta dall'inizio del capitolo",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePlaylistCreateFromCollection": "Crea playlist da una Raccolta",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast non ha l'URL del feed RSS da utilizzare per il match",
"MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".",
"MessageRemoveAllItemsWarning": "AVVERTIMENTO! Questa azione rimuoverà tutti gli elementi della libreria dal database, inclusi eventuali aggiornamenti o corrispondenze apportate. Questo non fa nulla ai tuoi file effettivi. Sei sicuro?",
@@ -589,8 +593,8 @@
"ToastLibraryScanStarted": "Scansione Libreria iniziata",
"ToastLibraryUpdateFailed": "Errore Aggiornamento libreria",
"ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistCreateFailed": "Errore Creazione playlist",
"ToastPlaylistCreateSuccess": "Playlist creata",
"ToastPlaylistRemoveFailed": "Rimozione Playlist Fallita",
"ToastPlaylistRemoveSuccess": "Playlist rimossa",
"ToastPlaylistUpdateFailed": "Aggiornamento Playlist Fallita",
@@ -601,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
"ToastSessionDeleteSuccess": "Sessione cancellata",
"ToastSocketConnected": "Socket connesso",

View File

@@ -20,6 +20,7 @@
"ButtonCreate": "Utwórz",
"ButtonCreateBackup": "Utwórz kopię zapasową",
"ButtonDelete": "Usuń",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Edytuj rozdziały",
"ButtonEditPodcast": "Edytuj podcast",
"ButtonForceReScan": "Wymuś ponowne skanowanie",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Wgraj kopię zapasową",
"ButtonUploadCover": "Wgraj okładkę",
"ButtonUploadOPMLFile": "Wgraj plik OPML",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Zobacz wszystko",
"ButtonYes": "Tak",
"HeaderAccount": "Konto",
@@ -252,7 +255,6 @@
"LabelLogLevelInfo": "Informacja",
"LabelLogLevelWarn": "Ostrzeżenie",
"LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie",
"LabelMarkSeries": "Oznacz serię",
"LabelMediaPlayer": "Odtwarzacz",
"LabelMediaType": "Typ mediów",
"LabelMetadataProvider": "Dostawca metadanych",
@@ -441,6 +443,8 @@
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
@@ -601,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
"ToastSessionDeleteSuccess": "Sesja usunięta",
"ToastSocketConnected": "Nawiązano połączenie z serwerem",

View File

@@ -1,5 +1,5 @@
{
"ButtonAdd": "加",
"ButtonAdd": "加",
"ButtonAddChapters": "添加章节",
"ButtonAddPodcasts": "添加播客",
"ButtonAddYourFirstLibrary": "添加第一个媒体库",
@@ -20,6 +20,7 @@
"ButtonCreate": "创建",
"ButtonCreateBackup": "创建备份",
"ButtonDelete": "删除",
"ButtonEdit": "Edit",
"ButtonEditChapters": "编辑章节",
"ButtonEditPodcast": "编辑播客",
"ButtonForceReScan": "强制重新扫描",
@@ -66,7 +67,7 @@
"ButtonSelectFolderPath": "选择文件夹路径",
"ButtonSeries": "系列",
"ButtonSetChaptersFromTracks": "将音轨设置为章节",
"ButtonShiftTimes": "快速移动时间",
"ButtonShiftTimes": "快速调整时间",
"ButtonShow": "显示",
"ButtonStartM4BEncode": "开始 M4B 编码",
"ButtonStartMetadataEmbed": "开始嵌入元数据",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "上传备份",
"ButtonUploadCover": "上传封面",
"ButtonUploadOPMLFile": "上传 OPML 文件",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "查看全部",
"ButtonYes": "确定",
"HeaderAccount": "帐户",
@@ -95,7 +98,7 @@
"HeaderFindChapters": "查找章节",
"HeaderIgnoredFiles": "忽略的文件",
"HeaderItemFiles": "项目文件",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderItemMetadataUtils": "项目元数据管理程序",
"HeaderLastListeningSession": "最后一次收听会话",
"HeaderLatestEpisodes": "最新剧集",
"HeaderLibraries": "媒体库",
@@ -105,9 +108,9 @@
"HeaderListeningStats": "收听统计数据",
"HeaderLogin": "登录",
"HeaderLogs": "日志",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderManageGenres": "管理流派",
"HeaderManageTags": "管理标签",
"HeaderMapDetails": "编辑详情",
"HeaderMatch": "匹配",
"HeaderMetadataToEmbed": "嵌入元数据",
"HeaderNewAccount": "新建帐户",
@@ -159,7 +162,7 @@
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAll": "全部",
"LabelAllUsers": "所有用户",
"LabelAppend": "Append",
"LabelAppend": "附加",
"LabelAuthor": "作者",
"LabelAuthorFirstLast": "作者 (姓 名)",
"LabelAuthorLastFirst": "作者 (名, 姓)",
@@ -206,7 +209,7 @@
"LabelEpisode": "剧集",
"LabelEpisodeTitle": "剧集标题",
"LabelEpisodeType": "剧集类型",
"LabelExplicit": "信息确",
"LabelExplicit": "信息确",
"LabelFeedURL": "源 URL",
"LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间",
@@ -252,7 +255,6 @@
"LabelLogLevelInfo": "信息",
"LabelLogLevelWarn": "警告",
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
"LabelMarkSeries": "标记系列",
"LabelMediaPlayer": "媒体播放器",
"LabelMediaType": "媒体类型",
"LabelMetadataProvider": "元数据提供者",
@@ -283,7 +285,7 @@
"LabelNumberOfBooks": "图书数量",
"LabelNumberOfEpisodes": "# 集",
"LabelOpenRSSFeed": "打开 RSS 源",
"LabelOverwrite": "Overwrite",
"LabelOverwrite": "覆盖",
"LabelPassword": "密码",
"LabelPath": "路径",
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
@@ -384,7 +386,7 @@
"LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}",
"LabelTimeToShift": "快速移动时间以秒为单位",
"LabelTimeToShift": "快速调整时间以秒为单位",
"LabelTitle": "标题",
"LabelToolsEmbedMetadata": "嵌入元数据",
"LabelToolsEmbedMetadataDescription": "将元数据嵌入音频文件, 包括封面图像和章节.",
@@ -441,16 +443,18 @@
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
"MessageConfirmDeleteSession": "你确定要删除此会话吗?",
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameGenreMergeNote": "注意: 该流派已经存在, 因此它们将被合并.",
"MessageConfirmRenameGenreWarning": "警告! 已经存在有大小写不同的类似流派 \"{0}\".",
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
"MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!",
@@ -461,7 +465,7 @@
"MessageImportantNotice": "重要通知!",
"MessageInsertChapterBelow": "在下面插入章节",
"MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "{0} Items Updated",
"MessageItemsUpdated": "已更新 {0} 个项目",
"MessageJoinUsOn": "加入我们",
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
"MessageLoading": "加载...",
@@ -495,7 +499,7 @@
"MessageNoResults": "无结果",
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
"MessageNoSeries": "无系列",
"MessageNoTags": "No Tags",
"MessageNoTags": "无标签",
"MessageNotYetImplemented": "尚未实施",
"MessageNoUpdateNecessary": "无需更新",
"MessageNoUpdatesWereNecessary": "无需更新",
@@ -503,7 +507,7 @@
"MessageOr": "或",
"MessagePauseChapter": "暂停章节播放",
"MessagePlayChapter": "开始章节播放",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePlaylistCreateFromCollection": "从收藏中创建播放列表",
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
"MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?",
@@ -589,8 +593,8 @@
"ToastLibraryScanStarted": "媒体库扫描已启动",
"ToastLibraryUpdateFailed": "更新图书库失败",
"ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistCreateFailed": "创建播放列表失败",
"ToastPlaylistCreateSuccess": "已成功创建播放列表",
"ToastPlaylistRemoveFailed": "删除播放列表失败",
"ToastPlaylistRemoveSuccess": "播放列表已删除",
"ToastPlaylistUpdateFailed": "更新播放列表失败",
@@ -601,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除",
"ToastRSSFeedCloseFailed": "关闭 RSS 源失败",
"ToastRSSFeedCloseSuccess": "RSS 源已关闭",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "删除会话失败",
"ToastSessionDeleteSuccess": "会话已删除",
"ToastSocketConnected": "网络已连接",

View File

@@ -20,7 +20,8 @@ module.exports = {
'w-3.5',
'h-3.5',
'border-warning',
'mb-px'
'mb-px',
'text-1.5xl'
],
},
theme: {
@@ -61,6 +62,7 @@ module.exports = {
'80': '20rem'
},
spacing: {
'18': '4.5rem',
'-54': '-13.5rem'
},
rotate: {
@@ -94,6 +96,7 @@ module.exports = {
},
fontSize: {
xxs: '0.625rem',
'1.5xl': '1.375rem',
'2.5xl': '1.6875rem'
},
zIndex: {

View File

@@ -5,11 +5,11 @@ const isDev = process.env.NODE_ENV !== 'production'
if (isDev) {
const devEnv = require('./dev').config
process.env.NODE_ENV = 'development'
process.env.PORT = devEnv.Port
process.env.CONFIG_PATH = devEnv.ConfigPath
process.env.METADATA_PATH = devEnv.MetadataPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath
process.env.FFPROBE_PATH = devEnv.FFProbePath
if (devEnv.Port) process.env.PORT = devEnv.Port
if (devEnv.ConfigPath) process.env.CONFIG_PATH = devEnv.ConfigPath
if (devEnv.MetadataPath) process.env.METADATA_PATH = devEnv.MetadataPath
if (devEnv.FFmpegPath) process.env.FFMPEG_PATH = devEnv.FFmpegPath
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.2.9",
"version": "2.2.12",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.2.9",
"version": "2.2.12",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.26.1",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.2.9",
"version": "2.2.12",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {

118
readme.md
View File

@@ -6,7 +6,7 @@
<br />
<a href="https://audiobookshelf.org/docs">Documentation</a>
·
<a href="https://audiobookshelf.org/install">Install Guides</a>
<a href="https://audiobookshelf.org/guides">User Guides</a>
·
<a href="https://audiobookshelf.org/support">Support</a>
</p>
@@ -36,14 +36,17 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
Join us on [discord](https://discord.gg/pJsjuNCKRq) or [matrix](https://matrix.to/#/#audiobookshelf:matrix.org)
Join us on [Discord](https://discord.gg/pJsjuNCKRq) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org)
### Android App (beta)
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
### iOS App (early beta)
### iOS App (beta)
Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join the discussion](https://github.com/advplyr/audiobookshelf-app/discussions/60)
### Build your own tools & clients
Check out the [API documentation](https://api.audiobookshelf.org/)
<br />
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/LibraryStreamSquare.png" />
@@ -54,106 +57,13 @@ Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join
#### Directory structure and folder names are important to Audiobookshelf!
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage.
See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
<br />
# Installation
### Docker Install
Available in Unraid Community Apps
```bash
docker pull ghcr.io/advplyr/audiobookshelf:latest
docker run -d \
-e AUDIOBOOKSHELF_UID=99 \
-e AUDIOBOOKSHELF_GID=100 \
-p 13378:80 \
-v </path/to/audiobooks>:/audiobooks \
-v </path/to/podcasts>:/podcasts \
-v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \
--name audiobookshelf \
ghcr.io/advplyr/audiobookshelf:latest
```
### Docker Update
```bash
docker stop audiobookshelf
docker rm audiobookshelf
docker pull ghcr.io/advplyr/audiobookshelf:latest
docker start audiobookshelf
```
### Running with Docker Compose
```yaml
### docker-compose.yml ###
services:
audiobookshelf:
container_name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:latest
environment:
- AUDIOBOOKSHELF_UID=99
- AUDIOBOOKSHELF_GID=100
ports:
- 13378:80
volumes:
- </path/to/audiobooks>:/audiobooks
- </path/to/podcasts>:/podcasts
- </path/to/config>:/config
- </path/to/metadata>:/metadata
```
### Docker Compose Update
Depending on the version of Docker Compose please run one of the two commands. If not sure on which version you are running you can run the following command and check.
#### Version Check
docker-compose --version or docker compose version
#### v2 Update
```bash
docker compose --file <path/to/config>/docker-compose.yml pull
docker compose --file <path/to/config>/docker-compose.yml up -d
```
#### V1 Update
```bash
docker-compose --file <path/to/config>/docker-compose.yml pull
docker-compose --file <path/to/config>/docker-compose.yml up -d
```
### Linux (amd64) Install
Debian package will use this config file `/etc/default/audiobookshelf` if exists. The install will create a user and group named `audiobookshelf`.
### Ubuntu Install via PPA
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa)
See [install docs](https://www.audiobookshelf.org/install/#ubuntu)
### Install via debian package
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
See [install docs](https://www.audiobookshelf.org/install#debian)
#### Linux file locations
Project directory: `/usr/share/audiobookshelf/`
Config file: `/etc/default/audiobookshelf`
System Service: `/lib/systemd/system/audiobookshelf.service`
Ffmpeg static build: `/usr/lib/audiobookshelf-ffmpeg/`
See [install docs](https://www.audiobookshelf.org/docs)
<br />
@@ -161,6 +71,8 @@ Ffmpeg static build: `/usr/lib/audiobookshelf-ffmpeg/`
#### Important! Audiobookshelf requires a websocket connection.
#### Note: Subfolder paths (e.g. /audiobooks) are not supported yet. See [issue](https://github.com/advplyr/audiobookshelf/issues/385)
### NGINX Proxy Manager
Toggle websockets support.
@@ -261,6 +173,16 @@ Middleware relating to CORS will cause the app to report Unknown Error when logg
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506)
<br />
### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
```
subdomain.domain.com {
encode gzip zstd
reverse_proxy <LOCAL_IP>:<PORT>
}
```
# Run from source
[See discussion](https://github.com/advplyr/audiobookshelf/discussions/259#discussioncomment-1869729)

View File

@@ -115,17 +115,16 @@ class Auth {
})
}
getUserLoginResponsePayload(user, feeds) {
getUserLoginResponsePayload(user) {
return {
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSONForBrowser(),
feeds,
Source: global.Source
}
}
async login(req, res, feeds) {
async login(req, res) {
const ipAddress = requestIp.getClientIp(req)
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
@@ -146,14 +145,14 @@ class Auth {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
return res.json(this.getUserLoginResponsePayload(user, feeds))
return res.json(this.getUserLoginResponsePayload(user))
}
}
// Check password match
var compare = await bcrypt.compare(password, user.pash)
if (compare) {
res.json(this.getUserLoginResponsePayload(user, feeds))
res.json(this.getUserLoginResponsePayload(user))
} else {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {

View File

@@ -107,7 +107,7 @@ class Db {
checkPreviousVersion() {
return this.settingsDb.select(() => true).then((results) => {
if (results.data && results.data.length) {
var serverSettings = results.data.find(s => s.id === 'server-settings')
const serverSettings = results.data.find(s => s.id === 'server-settings')
if (serverSettings && serverSettings.version && serverSettings.version !== version) {
return serverSettings.version
}
@@ -163,7 +163,7 @@ class Db {
const p4 = this.settingsDb.select(() => true).then(async (results) => {
if (results.data && results.data.length) {
this.settings = results.data
var serverSettings = this.settings.find(s => s.id === 'server-settings')
const serverSettings = this.settings.find(s => s.id === 'server-settings')
if (serverSettings) {
this.serverSettings = new ServerSettings(serverSettings)
@@ -185,7 +185,7 @@ class Db {
}
}
var notificationSettings = this.settings.find(s => s.id === 'notification-settings')
const notificationSettings = this.settings.find(s => s.id === 'notification-settings')
if (notificationSettings) {
this.notificationSettings = new NotificationSettings(notificationSettings)
}
@@ -280,7 +280,7 @@ class Db {
}
getAllEntities(entityName) {
var entityDb = this.getEntityDb(entityName)
const entityDb = this.getEntityDb(entityName)
return entityDb.select(() => true).then((results) => results.data).catch((error) => {
Logger.error(`[DB] Failed to get all ${entityName}`, error)
return null
@@ -371,16 +371,16 @@ class Db {
}
updateEntity(entityName, entity) {
var entityDb = this.getEntityDb(entityName)
const entityDb = this.getEntityDb(entityName)
var jsonEntity = entity
let jsonEntity = entity
if (entity && entity.toJSON) {
jsonEntity = entity.toJSON()
}
return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => {
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
var arrayKey = this.getEntityArrayKey(entityName)
const arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].map(e => {
return e.id === entity.id ? entity : e
@@ -410,10 +410,10 @@ class Db {
})
}
removeEntities(entityName, selectFunc) {
removeEntities(entityName, selectFunc, silent = false) {
var entityDb = this.getEntityDb(entityName)
return entityDb.delete(selectFunc).then((results) => {
Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`)
if (!silent) Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].filter(e => {

View File

@@ -22,6 +22,15 @@ class Logger {
return 'UNKNOWN'
}
get source() {
try {
throw new Error()
} catch (error) {
const regex = global.isWin ? /^.*\\([^\\:]*:[0-9]*):[0-9]*\)*/ : /^.*\/([^/:]*:[0-9]*):[0-9]*\)*/
return error.stack.split('\n')[3].replace(regex, '$1')
}
}
getLogLevelString(level) {
for (const key in LogLevel) {
if (LogLevel[key] === level) {
@@ -55,6 +64,7 @@ class Logger {
handleLog(level, args) {
const logObj = {
timestamp: this.timestamp,
source: this.source,
message: args.join(' '),
levelName: this.getLogLevelString(level),
level
@@ -84,30 +94,30 @@ class Logger {
debug(...args) {
if (this.logLevel > LogLevel.DEBUG) return
console.debug(`[${this.timestamp}] DEBUG:`, ...args)
console.debug(`[${this.timestamp}] DEBUG:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.DEBUG, args)
}
info(...args) {
if (this.logLevel > LogLevel.INFO) return
console.info(`[${this.timestamp}] INFO:`, ...args)
console.info(`[${this.timestamp}] INFO:`, ...args)
this.handleLog(LogLevel.INFO, args)
}
warn(...args) {
if (this.logLevel > LogLevel.WARN) return
console.warn(`[${this.timestamp}] WARN:`, ...args)
console.warn(`[${this.timestamp}] WARN:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.WARN, args)
}
error(...args) {
if (this.logLevel > LogLevel.ERROR) return
console.error(`[${this.timestamp}] ERROR:`, ...args)
console.error(`[${this.timestamp}] ERROR:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.ERROR, args)
}
fatal(...args) {
console.error(`[${this.timestamp}] FATAL:`, ...args)
console.error(`[${this.timestamp}] FATAL:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.FATAL, args)
}

View File

@@ -10,6 +10,7 @@ const { version } = require('../package.json')
// Utils
const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms')
const fileUtils = require('./utils/fileUtils')
const Logger = require('./Logger')
const Auth = require('./Auth')
@@ -34,24 +35,20 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
const TaskManager = require('./managers/TaskManager')
const EBookManager = require('./managers/EBookManager')
class Server {
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
this.Port = PORT
this.Host = HOST
global.Source = SOURCE
global.isWin = process.platform === 'win32'
global.Uid = isNaN(UID) ? 0 : Number(UID)
global.Gid = isNaN(GID) ? 0 : Number(GID)
global.ConfigPath = Path.normalize(CONFIG_PATH)
global.MetadataPath = Path.normalize(METADATA_PATH)
global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
global.RouterBasePath = ROUTER_BASE_PATH
// Fix backslash if not on Windows
if (process.platform !== 'win32') {
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
}
if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath)
filePerms.setDefaultDirSync(global.ConfigPath, false)
@@ -77,6 +74,7 @@ class Server {
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db)
this.eBookManager = new EBookManager(this.db)
this.scanner = new Scanner(this.db, this.coverManager)
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
@@ -124,6 +122,7 @@ class Server {
await this.backupManager.init()
await this.logManager.init()
await this.apiRouter.checkRemoveEmptySeries(this.db.series) // Remove empty series
await this.rssFeedManager.init()
this.cronManager.init()
@@ -212,7 +211,7 @@ class Server {
]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res, this.rssFeedManager.feedsArray))
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
router.post('/init', (req, res) => {
if (this.db.hasRootUser) {

View File

@@ -2,6 +2,8 @@ const EventEmitter = require('events')
const Watcher = require('./libs/watcher/watcher')
const Logger = require('./Logger')
const { filePathToPOSIX } = require('./utils/fileUtils')
class FolderWatcher extends EventEmitter {
constructor() {
super()
@@ -143,23 +145,23 @@ class FolderWatcher extends EventEmitter {
}
addFileUpdate(libraryId, path, type) {
path = path.replace(/\\/g, '/')
path = filePathToPOSIX(path)
if (this.pendingFilePaths.includes(path)) return
// Get file library
var libwatcher = this.libraryWatchers.find(lw => lw.id === libraryId)
const libwatcher = this.libraryWatchers.find(lw => lw.id === libraryId)
if (!libwatcher) {
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
return
}
// Get file folder
var folder = libwatcher.folders.find(fold => path.startsWith(fold.fullPath.replace(/\\/g, '/')))
const folder = libwatcher.folders.find(fold => path.startsWith(filePathToPOSIX(fold.fullPath)))
if (!folder) {
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
return
}
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
const folderFullPath = filePathToPOSIX(folder.fullPath)
var relPath = path.replace(folderFullPath, '')
@@ -189,12 +191,12 @@ class FolderWatcher extends EventEmitter {
checkShouldIgnorePath(path) {
return !!this.ignoreDirs.find(dirpath => {
return path.replace(/\\/g, '/').startsWith(dirpath)
return filePathToPOSIX(path).startsWith(dirpath)
})
}
cleanDirPath(path) {
var path = path.replace(/\\/g, '/')
path = filePathToPOSIX(path)
if (path.endsWith('/')) path = path.slice(0, -1)
return path
}

View File

@@ -1,8 +1,11 @@
const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { reqSupportsWebp } = require('../utils/index')
const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@@ -77,9 +80,17 @@ class AuthorController {
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
}
payload.imagePath = imageData.path
payload.relImagePath = imageData.relPath
hasUpdated = true
}
} else if (payload.imagePath && payload.imagePath !== req.author.imagePath) { // Changing image path locally
if (!await fs.pathExists(payload.imagePath)) { // Make sure image path exists
Logger.error(`[AuthorController] Image path does not exist: "${payload.imagePath}"`)
return res.status(400).send('Author image path does not exist')
}
if (req.author.imagePath) {
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
}
}
}
@@ -117,6 +128,8 @@ class AuthorController {
}
if (hasUpdated) {
req.author.updatedAt = Date.now()
if (authorNameUpdate) { // Update author name on all books
const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
itemsWithAuthor.forEach(libraryItem => {
@@ -178,7 +191,6 @@ class AuthorController {
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) {
req.author.imagePath = imageData.path
req.author.relImagePath = imageData.relPath
hasUpdates = true
}
}
@@ -206,7 +218,15 @@ class AuthorController {
// GET api/authors/:id/image
async getImage(req, res) {
let { query: { width, height, format }, author } = req
const { query: { width, height, format, raw }, author } = req
if (raw) { // any value
if (!author.imagePath || !await fs.pathExists(author.imagePath)) {
return res.sendStatus(404)
}
return res.sendFile(author.imagePath)
}
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),

View File

@@ -26,13 +26,22 @@ class CollectionController {
}
findOne(req, res) {
res.json(req.collection.toJSONExpanded(this.db.libraryItems))
const includeEntities = (req.query.include || '').split(',')
const collectionExpanded = req.collection.toJSONExpanded(this.db.libraryItems)
if (includeEntities.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
collectionExpanded.rssFeed = feedData ? feedData.toJSONMinified() : null
}
res.json(collectionExpanded)
}
async update(req, res) {
const collection = req.collection
var wasUpdated = collection.update(req.body)
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
const wasUpdated = collection.update(req.body)
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
if (wasUpdated) {
await this.db.updateEntity('collection', collection)
SocketAuthority.emitter('collection_updated', jsonExpanded)
@@ -42,7 +51,11 @@ class CollectionController {
async delete(req, res) {
const collection = req.collection
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
// Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(collection.id)
await this.db.removeEntity('collection', collection.id)
SocketAuthority.emitter('collection_removed', jsonExpanded)
res.sendStatus(200)
@@ -50,7 +63,7 @@ class CollectionController {
async addBook(req, res) {
const collection = req.collection
var libraryItem = this.db.libraryItems.find(li => li.id === req.body.id)
const libraryItem = this.db.libraryItems.find(li => li.id === req.body.id)
if (!libraryItem) {
return res.status(500).send('Book not found')
}
@@ -61,7 +74,7 @@ class CollectionController {
return res.status(500).send('Book already in collection')
}
collection.addBook(req.body.id)
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('collection', collection)
SocketAuthority.emitter('collection_updated', jsonExpanded)
res.json(jsonExpanded)

View File

@@ -0,0 +1,52 @@
const Logger = require('../Logger')
const { isNullOrNaN } = require('../utils/index')
class EBookController {
constructor() { }
async getEbookInfo(req, res) {
const isDev = req.query.dev == 1
const json = await this.eBookManager.getBookInfo(req.libraryItem, req.user, isDev)
res.json(json)
}
async getEbookPage(req, res) {
if (isNullOrNaN(req.params.page)) {
return res.status(400).send('Invalid page params')
}
const isDev = req.query.dev == 1
const pageIndex = Number(req.params.page)
const page = await this.eBookManager.getBookPage(req.libraryItem, req.user, pageIndex, isDev)
if (!page) {
return res.status(500).send('Failed to get page')
}
res.send(page)
}
async getEbookResource(req, res) {
if (!req.query.path) {
return res.status(400).send('Invalid query path')
}
const isDev = req.query.dev == 1
this.eBookManager.getBookResource(req.libraryItem, req.user, req.query.path, isDev, res)
}
middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
return res.sendStatus(403)
}
if (!item.isBook || !item.media.ebookFile) {
return res.status(400).send('Invalid ebook library item')
}
req.libraryItem = item
next()
}
}
module.exports = new EBookController()

View File

@@ -59,7 +59,9 @@ class LibraryController {
findAll(req, res) {
const librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible && librariesAccessible.length) {
return res.json(this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON()))
return res.json({
libraries: this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON())
})
}
res.json({
@@ -168,8 +170,11 @@ class LibraryController {
// api/libraries/:id/items
// TODO: Optimize this method, items are iterated through several times but can be combined
getLibraryItems(req, res) {
var libraryItems = req.libraryItems
var payload = {
let libraryItems = req.libraryItems
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
results: [],
total: libraryItems.length,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
@@ -179,7 +184,8 @@ class LibraryController {
filterBy: req.query.filter,
mediaType: req.library.mediaType,
minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1'
collapseseries: req.query.collapseseries === '1',
include: include.join(',')
}
const mediaIsBook = payload.mediaType === 'book'
@@ -217,7 +223,7 @@ class LibraryController {
}
// Step 3 - Sort the retrieved library items.
var sortArray = []
const sortArray = []
// When on the series page, sort by sequence only
if (payload.sortBy === 'book.volumeNumber') payload.sortBy = null // TODO: Remove temp fix after mobile release 0.9.60
@@ -292,13 +298,13 @@ class LibraryController {
// Step 3.5: Limit items
if (payload.limit) {
var startIndex = payload.page * payload.limit
const startIndex = payload.page * payload.limit
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
}
// Step 4 - Transform the items to pass to the client side
payload.results = libraryItems.map(li => {
let json = payload.minified ? li.toJSONMinified() : li.toJSON()
const json = payload.minified ? li.toJSONMinified() : li.toJSON()
if (li.collapsedSeries) {
json.collapsedSeries = {
@@ -331,9 +337,17 @@ class LibraryController {
.map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
.join(', ')
}
} else if (filterSeries) {
// If filtering by series, make sure to include the series metadata
json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
} else {
// add rssFeed object if "include=rssfeed" was put in query string (only for non-collapsed series)
if (include.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(json.id)
json.rssFeed = feedData ? feedData.toJSONMinified() : null
}
if (filterSeries) {
// If filtering by series, make sure to include the series metadata
json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
}
}
return json
@@ -361,6 +375,9 @@ class LibraryController {
// api/libraries/:id/series
async getAllSeriesForLibrary(req, res) {
const libraryItems = req.libraryItems
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
results: [],
total: 0,
@@ -369,7 +386,8 @@ class LibraryController {
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
minified: req.query.minified === '1'
minified: req.query.minified === '1',
include: include.join(',')
}
let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
@@ -394,19 +412,30 @@ class LibraryController {
payload.total = series.length
if (payload.limit) {
var startIndex = payload.page * payload.limit
const startIndex = payload.page * payload.limit
series = series.slice(startIndex, startIndex + payload.limit)
}
// add rssFeed when "include=rssfeed" is in query string
if (include.includes('rssfeed')) {
series = series.map((se) => {
const feedData = this.rssFeedManager.findFeedForEntityId(se.id)
se.rssFeed = feedData?.toJSONMinified() || null
return se
})
}
payload.results = series
res.json(payload)
}
// api/libraries/:id/collections
async getCollectionsForLibrary(req, res) {
var libraryItems = req.libraryItems
const libraryItems = req.libraryItems
var payload = {
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
results: [],
total: 0,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
@@ -414,20 +443,28 @@ class LibraryController {
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
minified: req.query.minified === '1'
minified: req.query.minified === '1',
include: include.join(',')
}
var collections = this.db.collections.filter(c => c.libraryId === req.library.id).map(c => {
var expanded = c.toJSONExpanded(libraryItems, payload.minified)
let collections = this.db.collections.filter(c => c.libraryId === req.library.id).map(c => {
const expanded = c.toJSONExpanded(libraryItems, payload.minified)
// If all books restricted to user in this collection then hide this collection
if (!expanded.books.length && c.books.length) return null
if (include.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(c.id)
expanded.rssFeed = feedData?.toJSONMinified() || null
}
return expanded
}).filter(c => !!c)
payload.total = collections.length
if (payload.limit) {
var startIndex = payload.page * payload.limit
const startIndex = payload.page * payload.limit
collections = collections.slice(startIndex, startIndex + payload.limit)
}
@@ -455,6 +492,32 @@ class LibraryController {
res.json(payload)
}
// api/libraries/:id/albums
async getAlbumsForLibrary(req, res) {
if (!req.library.isMusic) {
return res.status(400).send('Invalid library media type')
}
let libraryItems = this.db.libraryItems.filter(li => li.libraryId === req.library.id)
let albums = libraryHelpers.groupMusicLibraryItemsIntoAlbums(libraryItems)
albums = naturalSort(albums).asc(a => a.title) // Alphabetical by album title
const payload = {
results: [],
total: albums.length,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
}
if (payload.limit) {
const startIndex = payload.page * payload.limit
albums = albums.slice(startIndex, startIndex + payload.limit)
}
payload.results = albums
res.json(payload)
}
async getLibraryFilterData(req, res) {
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
}
@@ -464,9 +527,10 @@ class LibraryController {
async getLibraryUserPersonalizedOptimal(req, res) {
const mediaType = req.library.mediaType
const libraryItems = req.libraryItems
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 10
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const categories = libraryHelpers.buildPersonalizedShelves(req.user, libraryItems, mediaType, this.db.series, this.db.authors, limitPerShelf)
const categories = libraryHelpers.buildPersonalizedShelves(this, req.user, libraryItems, mediaType, limitPerShelf, include)
res.json(categories)
}
@@ -508,16 +572,16 @@ class LibraryController {
if (!req.query.q) {
return res.status(400).send('No query string')
}
var libraryItems = req.libraryItems
var maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
const libraryItems = req.libraryItems
const maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
var itemMatches = []
var authorMatches = {}
var seriesMatches = {}
var tagMatches = {}
const itemMatches = []
const authorMatches = {}
const seriesMatches = {}
const tagMatches = {}
libraryItems.forEach((li) => {
var queryResult = li.searchQuery(req.query.q)
const queryResult = li.searchQuery(req.query.q)
if (queryResult.matchKey) {
itemMatches.push({
libraryItem: li.toJSONExpanded(),
@@ -528,7 +592,7 @@ class LibraryController {
if (queryResult.series && queryResult.series.length) {
queryResult.series.forEach((se) => {
if (!seriesMatches[se.id]) {
var _series = this.db.series.find(_se => _se.id === se.id)
const _series = this.db.series.find(_se => _se.id === se.id)
if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] }
} else {
seriesMatches[se.id].books.push(li.toJSON())
@@ -538,7 +602,7 @@ class LibraryController {
if (queryResult.authors && queryResult.authors.length) {
queryResult.authors.forEach((au) => {
if (!authorMatches[au.id]) {
var _author = this.db.authors.find(_au => _au.id === au.id)
const _author = this.db.authors.find(_au => _au.id === au.id)
if (_author) {
authorMatches[au.id] = _author.toJSON()
authorMatches[au.id].numBooks = 1
@@ -558,8 +622,8 @@ class LibraryController {
})
}
})
var itemKey = req.library.mediaType
var results = {
const itemKey = req.library.mediaType
const results = {
[itemKey]: itemMatches.slice(0, maxResults),
tags: Object.values(tagMatches).slice(0, maxResults),
authors: Object.values(authorMatches).slice(0, maxResults),

View File

@@ -21,8 +21,8 @@ class LibraryItemController {
}
if (includeEntities.includes('rssfeed')) {
var feedData = this.rssFeedManager.findFeedForItem(item.id)
item.rssFeedUrl = feedData ? feedData.feedUrl : null
const feedData = this.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData ? feedData.toJSONMinified() : null
}
if (item.mediaType == 'book') {
@@ -52,7 +52,7 @@ class LibraryItemController {
await this.cacheManager.purgeCoverCache(libraryItem.id)
}
var hasUpdates = libraryItem.update(req.body)
const hasUpdates = libraryItem.update(req.body)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`)
await this.db.updateLibraryItem(libraryItem)
@@ -70,8 +70,8 @@ class LibraryItemController {
// PATCH: will create new authors & series if in payload
//
async updateMedia(req, res) {
var libraryItem = req.libraryItem
var mediaPayload = req.body
const libraryItem = req.libraryItem
const mediaPayload = req.body
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
await this.cacheManager.purgeCoverCache(libraryItem.id)
@@ -83,7 +83,7 @@ class LibraryItemController {
}
// Podcast specific
var isPodcastAutoDownloadUpdated = false
let isPodcastAutoDownloadUpdated = false
if (libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true
@@ -92,8 +92,23 @@ class LibraryItemController {
}
}
var hasUpdates = libraryItem.media.update(mediaPayload)
// Book specific - Get all series being removed from this item
let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
}
const hasUpdates = libraryItem.media.update(mediaPayload)
if (hasUpdates) {
libraryItem.updatedAt = Date.now()
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(seriesRemoved)
}
if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem)
}
@@ -432,38 +447,6 @@ class LibraryItemController {
})
}
// POST: api/items/:id/open-feed
async openRSSFeed(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to open RSS feed`, req.user.username)
return res.sendStatus(403)
}
const feedData = await this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
if (feedData.error) {
return res.json({
success: false,
error: feedData.error
})
}
res.json({
success: true,
feedUrl: feedData.feedUrl
})
}
async closeRSSFeed(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to close RSS feed`, req.user.username)
return res.sendStatus(403)
}
await this.rssFeedManager.closeFeedForItem(req.params.id)
res.sendStatus(200)
}
async toneScan(req, res) {
if (!req.libraryItem.media.audioFiles.length) {
return res.sendStatus(404)
@@ -481,7 +464,7 @@ class LibraryItemController {
}
middleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
// Check user can access this library item

View File

@@ -122,7 +122,7 @@ class MiscController {
Logger.error('Invalid user in authorize')
return res.sendStatus(401)
}
const userResponse = this.auth.getUserLoginResponsePayload(req.user, this.rssFeedManager.feedsArray)
const userResponse = this.auth.getUserLoginResponsePayload(req.user)
res.json(userResponse)
}

View File

@@ -4,7 +4,7 @@ const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
const { getFileTimestampsWithIno } = require('../utils/fileUtils')
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const LibraryItem = require('../objects/LibraryItem')
@@ -30,7 +30,7 @@ class PodcastController {
return res.status(404).send('Folder not found')
}
var podcastPath = payload.path.replace(/\\/g, '/')
const podcastPath = filePathToPOSIX(payload.path)
if (await fs.pathExists(podcastPath)) {
Logger.error(`[PodcastController] Podcast folder already exists "${podcastPath}"`)
return res.status(400).send('Podcast already exists')
@@ -173,7 +173,7 @@ class PodcastController {
async downloadEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
return res.sendStatus(500)
return res.sendStatus(403)
}
var libraryItem = req.libraryItem
@@ -186,8 +186,27 @@ class PodcastController {
res.sendStatus(200)
}
// POST: api/podcasts/:id/match-episodes
async quickMatchEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
return res.sendStatus(403)
}
const overrideDetails = req.query.override === '1'
const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
if (episodesUpdated) {
await this.db.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
}
res.json({
numEpisodesUpdated: episodesUpdated
})
}
async updateEpisode(req, res) {
var libraryItem = req.libraryItem
const libraryItem = req.libraryItem
var episodeId = req.params.episodeId
if (!libraryItem.media.checkHasEpisode(episodeId)) {
@@ -237,7 +256,7 @@ class PodcastController {
}
middleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
if (!item.isPodcast) {

View File

@@ -0,0 +1,137 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
class RSSFeedController {
constructor() { }
// POST: api/feeds/item/:itemId/open
async openRSSFeedForItem(req, res) {
const options = req.body || {}
const item = this.db.libraryItems.find(li => li.id === req.params.itemId)
if (!item) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`)
return res.sendStatus(403)
}
// Check request body options exist
if (!options.serverAddress || !options.slug) {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check item has audio tracks
if (!item.media.numTracks) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`)
return res.status(400).send('Item has no audio tracks')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.feeds[options.slug]) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const feed = await this.rssFeedManager.openFeedForItem(req.user, item, req.body)
res.json({
feed: feed.toJSONMinified()
})
}
// POST: api/feeds/collection/:collectionId/open
async openRSSFeedForCollection(req, res) {
const options = req.body || {}
const collection = this.db.collections.find(li => li.id === req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check request body options exist
if (!options.serverAddress || !options.slug) {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.feeds[options.slug]) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const collectionExpanded = collection.toJSONExpanded(this.db.libraryItems)
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
// Check collection has audio tracks
if (!collectionItemsWithTracks.length) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
return res.status(400).send('Collection has no audio tracks')
}
const feed = await this.rssFeedManager.openFeedForCollection(req.user, collectionExpanded, req.body)
res.json({
feed: feed.toJSONMinified()
})
}
// POST: api/feeds/series/:seriesId/open
async openRSSFeedForSeries(req, res) {
const options = req.body || {}
const series = this.db.series.find(se => se.id === req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check request body options exist
if (!options.serverAddress || !options.slug) {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.feeds[options.slug]) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const seriesJson = series.toJSON()
// Get books in series that have audio tracks
seriesJson.books = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
// Check series has audio tracks
if (!seriesJson.books.length) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`)
return res.status(400).send('Series has no audio tracks')
}
const feed = await this.rssFeedManager.openFeedForSeries(req.user, seriesJson, req.body)
res.json({
feed: feed.toJSONMinified()
})
}
// POST: api/feeds/:id/close
async closeRSSFeed(req, res) {
await this.rssFeedManager.closeRssFeed(req.params.id)
res.sendStatus(200)
}
middleware(req, res, next) {
if (!req.user.isAdminOrUp) { // Only admins can manage rss feeds
Logger.error(`[RSSFeedController] Non-admin user attempted to make a request to an RSS feed route`, req.user.username)
return res.sendStatus(403)
}
if (req.params.id) {
const feed = this.rssFeedManager.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RSSFeedController] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
}
}
next()
}
}
module.exports = new RSSFeedController()

View File

@@ -49,5 +49,12 @@ class SearchController {
}
res.json(chapterData)
}
async findMusicTrack(req, res) {
const tracks = await this.musicFinder.searchTrack(req.query || {})
res.json({
tracks
})
}
}
module.exports = new SearchController()

View File

@@ -5,15 +5,15 @@ class SeriesController {
constructor() { }
async findOne(req, res) {
var include = (req.query.include || '').split(',')
const include = (req.query.include || '').split(',').map(v => v.trim()).filter(v => !!v)
var seriesJson = req.series.toJSON()
const seriesJson = req.series.toJSON()
// Add progress map with isFinished flag
if (include.includes('progress')) {
var libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id))
var libraryItemsFinished = libraryItemsInSeries.filter(li => {
var mediaProgress = req.user.getMediaProgress(li.id)
const libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id))
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
const mediaProgress = req.user.getMediaProgress(li.id)
return mediaProgress && mediaProgress.isFinished
})
seriesJson.progress = {
@@ -23,6 +23,11 @@ class SeriesController {
}
}
if (include.includes('rssfeed')) {
const feedObj = this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
}
return res.json(seriesJson)
}
@@ -41,13 +46,13 @@ class SeriesController {
const hasUpdated = req.series.update(req.body)
if (hasUpdated) {
await this.db.updateEntity('series', req.series)
SocketAuthority.emitter('series_updated', req.series)
SocketAuthority.emitter('series_updated', req.series.toJSON())
}
res.json(req.series)
res.json(req.series.toJSON())
}
middleware(req, res, next) {
var series = this.db.series.find(se => se.id === req.params.id)
const series = this.db.series.find(se => se.id === req.params.id)
if (!series) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {

View File

@@ -26,7 +26,8 @@ class ToolsController {
return res.status(500).send('Invalid audiobook: no audio tracks')
}
this.abMergeManager.startAudiobookMerge(req.user, req.libraryItem)
const options = req.query || {}
this.abMergeManager.startAudiobookMerge(req.user, req.libraryItem, options)
res.sendStatus(200)
}
@@ -46,7 +47,6 @@ class ToolsController {
res.sendStatus(200)
}
// POST: api/tools/item/:id/embed-metadata
async embedAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) {
@@ -59,9 +59,11 @@ class ToolsController {
return res.sendStatus(500)
}
const useTone = req.query.tone === '1'
const forceEmbedChapters = req.query.forceEmbedChapters === '1'
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone, forceEmbedChapters)
const options = {
forceEmbedChapters: req.query.forceEmbedChapters === '1',
backup: req.query.backup === '1'
}
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, options)
res.sendStatus(200)
}

View File

@@ -48,7 +48,7 @@ class UserController {
var newUser = new User(account)
var success = await this.db.insertEntity('user', newUser)
if (success) {
SocketAuthority.adminEmitter('user_added', newUser)
SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
res.json({
user: newUser.toJSONForBrowser()
})

View File

@@ -0,0 +1,12 @@
const MusicBrainz = require('../providers/MusicBrainz')
class MusicFinder {
constructor() {
this.musicBrainz = new MusicBrainz()
}
searchTrack(options) {
return this.musicBrainz.searchTrack(options)
}
}
module.exports = MusicFinder

9
server/libs/css/LICENSE Normal file
View File

@@ -0,0 +1,9 @@
(The MIT License)
Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

2
server/libs/css/index.js Normal file
View File

@@ -0,0 +1,2 @@
exports.parse = require('./parse');
exports.stringify = require('./stringify');

View File

@@ -0,0 +1,603 @@
// http://www.w3.org/TR/CSS21/grammar.html
// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027
var commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g
module.exports = function(css, options){
options = options || {};
/**
* Positional.
*/
var lineno = 1;
var column = 1;
/**
* Update lineno and column based on `str`.
*/
function updatePosition(str) {
var lines = str.match(/\n/g);
if (lines) lineno += lines.length;
var i = str.lastIndexOf('\n');
column = ~i ? str.length - i : column + str.length;
}
/**
* Mark position and patch `node.position`.
*/
function position() {
var start = { line: lineno, column: column };
return function(node){
node.position = new Position(start);
whitespace();
return node;
};
}
/**
* Store position information for a node
*/
function Position(start) {
this.start = start;
this.end = { line: lineno, column: column };
this.source = options.source;
}
/**
* Non-enumerable source string
*/
Position.prototype.content = css;
/**
* Error `msg`.
*/
var errorsList = [];
function error(msg) {
var err = new Error(options.source + ':' + lineno + ':' + column + ': ' + msg);
err.reason = msg;
err.filename = options.source;
err.line = lineno;
err.column = column;
err.source = css;
if (options.silent) {
errorsList.push(err);
} else {
throw err;
}
}
/**
* Parse stylesheet.
*/
function stylesheet() {
var rulesList = rules();
return {
type: 'stylesheet',
stylesheet: {
source: options.source,
rules: rulesList,
parsingErrors: errorsList
}
};
}
/**
* Opening brace.
*/
function open() {
return match(/^{\s*/);
}
/**
* Closing brace.
*/
function close() {
return match(/^}/);
}
/**
* Parse ruleset.
*/
function rules() {
var node;
var rules = [];
whitespace();
comments(rules);
while (css.length && css.charAt(0) != '}' && (node = atrule() || rule())) {
if (node !== false) {
rules.push(node);
comments(rules);
}
}
return rules;
}
/**
* Match `re` and return captures.
*/
function match(re) {
var m = re.exec(css);
if (!m) return;
var str = m[0];
updatePosition(str);
css = css.slice(str.length);
return m;
}
/**
* Parse whitespace.
*/
function whitespace() {
match(/^\s*/);
}
/**
* Parse comments;
*/
function comments(rules) {
var c;
rules = rules || [];
while (c = comment()) {
if (c !== false) {
rules.push(c);
}
}
return rules;
}
/**
* Parse comment.
*/
function comment() {
var pos = position();
if ('/' != css.charAt(0) || '*' != css.charAt(1)) return;
var i = 2;
while ("" != css.charAt(i) && ('*' != css.charAt(i) || '/' != css.charAt(i + 1))) ++i;
i += 2;
if ("" === css.charAt(i-1)) {
return error('End of comment missing');
}
var str = css.slice(2, i - 2);
column += 2;
updatePosition(str);
css = css.slice(i);
column += 2;
return pos({
type: 'comment',
comment: str
});
}
/**
* Parse selector.
*/
function selector() {
var m = match(/^([^{]+)/);
if (!m) return;
/* @fix Remove all comments from selectors
* http://ostermiller.org/findcomment.html */
return trim(m[0])
.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')
.replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, function(m) {
return m.replace(/,/g, '\u200C');
})
.split(/\s*(?![^(]*\)),\s*/)
.map(function(s) {
return s.replace(/\u200C/g, ',');
});
}
/**
* Parse declaration.
*/
function declaration() {
var pos = position();
// prop
var prop = match(/^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/);
if (!prop) return;
prop = trim(prop[0]);
// :
if (!match(/^:\s*/)) return error("property missing ':'");
// val
var val = match(/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/);
var ret = pos({
type: 'declaration',
property: prop.replace(commentre, ''),
value: val ? trim(val[0]).replace(commentre, '') : ''
});
// ;
match(/^[;\s]*/);
return ret;
}
/**
* Parse declarations.
*/
function declarations() {
var decls = [];
if (!open()) return error("missing '{'");
comments(decls);
// declarations
var decl;
while (decl = declaration()) {
if (decl !== false) {
decls.push(decl);
comments(decls);
}
}
if (!close()) return error("missing '}'");
return decls;
}
/**
* Parse keyframe.
*/
function keyframe() {
var m;
var vals = [];
var pos = position();
while (m = match(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/)) {
vals.push(m[1]);
match(/^,\s*/);
}
if (!vals.length) return;
return pos({
type: 'keyframe',
values: vals,
declarations: declarations()
});
}
/**
* Parse keyframes.
*/
function atkeyframes() {
var pos = position();
var m = match(/^@([-\w]+)?keyframes\s*/);
if (!m) return;
var vendor = m[1];
// identifier
var m = match(/^([-\w]+)\s*/);
if (!m) return error("@keyframes missing name");
var name = m[1];
if (!open()) return error("@keyframes missing '{'");
var frame;
var frames = comments();
while (frame = keyframe()) {
frames.push(frame);
frames = frames.concat(comments());
}
if (!close()) return error("@keyframes missing '}'");
return pos({
type: 'keyframes',
name: name,
vendor: vendor,
keyframes: frames
});
}
/**
* Parse supports.
*/
function atsupports() {
var pos = position();
var m = match(/^@supports *([^{]+)/);
if (!m) return;
var supports = trim(m[1]);
if (!open()) return error("@supports missing '{'");
var style = comments().concat(rules());
if (!close()) return error("@supports missing '}'");
return pos({
type: 'supports',
supports: supports,
rules: style
});
}
/**
* Parse host.
*/
function athost() {
var pos = position();
var m = match(/^@host\s*/);
if (!m) return;
if (!open()) return error("@host missing '{'");
var style = comments().concat(rules());
if (!close()) return error("@host missing '}'");
return pos({
type: 'host',
rules: style
});
}
/**
* Parse media.
*/
function atmedia() {
var pos = position();
var m = match(/^@media *([^{]+)/);
if (!m) return;
var media = trim(m[1]);
if (!open()) return error("@media missing '{'");
var style = comments().concat(rules());
if (!close()) return error("@media missing '}'");
return pos({
type: 'media',
media: media,
rules: style
});
}
/**
* Parse custom-media.
*/
function atcustommedia() {
var pos = position();
var m = match(/^@custom-media\s+(--[^\s]+)\s*([^{;]+);/);
if (!m) return;
return pos({
type: 'custom-media',
name: trim(m[1]),
media: trim(m[2])
});
}
/**
* Parse paged media.
*/
function atpage() {
var pos = position();
var m = match(/^@page */);
if (!m) return;
var sel = selector() || [];
if (!open()) return error("@page missing '{'");
var decls = comments();
// declarations
var decl;
while (decl = declaration()) {
decls.push(decl);
decls = decls.concat(comments());
}
if (!close()) return error("@page missing '}'");
return pos({
type: 'page',
selectors: sel,
declarations: decls
});
}
/**
* Parse document.
*/
function atdocument() {
var pos = position();
var m = match(/^@([-\w]+)?document *([^{]+)/);
if (!m) return;
var vendor = trim(m[1]);
var doc = trim(m[2]);
if (!open()) return error("@document missing '{'");
var style = comments().concat(rules());
if (!close()) return error("@document missing '}'");
return pos({
type: 'document',
document: doc,
vendor: vendor,
rules: style
});
}
/**
* Parse font-face.
*/
function atfontface() {
var pos = position();
var m = match(/^@font-face\s*/);
if (!m) return;
if (!open()) return error("@font-face missing '{'");
var decls = comments();
// declarations
var decl;
while (decl = declaration()) {
decls.push(decl);
decls = decls.concat(comments());
}
if (!close()) return error("@font-face missing '}'");
return pos({
type: 'font-face',
declarations: decls
});
}
/**
* Parse import
*/
var atimport = _compileAtrule('import');
/**
* Parse charset
*/
var atcharset = _compileAtrule('charset');
/**
* Parse namespace
*/
var atnamespace = _compileAtrule('namespace');
/**
* Parse non-block at-rules
*/
function _compileAtrule(name) {
var re = new RegExp('^@' + name + '\\s*([^;]+);');
return function() {
var pos = position();
var m = match(re);
if (!m) return;
var ret = { type: name };
ret[name] = m[1].trim();
return pos(ret);
}
}
/**
* Parse at rule.
*/
function atrule() {
if (css[0] != '@') return;
return atkeyframes()
|| atmedia()
|| atcustommedia()
|| atsupports()
|| atimport()
|| atcharset()
|| atnamespace()
|| atdocument()
|| atpage()
|| athost()
|| atfontface();
}
/**
* Parse rule.
*/
function rule() {
var pos = position();
var sel = selector();
if (!sel) return error('selector missing');
comments();
return pos({
type: 'rule',
selectors: sel,
declarations: declarations()
});
}
return addParent(stylesheet());
};
/**
* Trim `str`.
*/
function trim(str) {
return str ? str.replace(/^\s+|\s+$/g, '') : '';
}
/**
* Adds non-enumerable parent node reference to each node.
*/
function addParent(obj, parent) {
var isNode = obj && typeof obj.type === 'string';
var childParent = isNode ? obj : parent;
for (var k in obj) {
var value = obj[k];
if (Array.isArray(value)) {
value.forEach(function(v) { addParent(v, childParent); });
} else if (value && typeof value === 'object') {
addParent(value, childParent);
}
}
if (isNode) {
Object.defineProperty(obj, 'parent', {
configurable: true,
writable: true,
enumerable: false,
value: parent || null
});
}
return obj;
}

View File

@@ -0,0 +1,50 @@
/**
* Expose `Compiler`.
*/
module.exports = Compiler;
/**
* Initialize a compiler.
*
* @param {Type} name
* @return {Type}
* @api public
*/
function Compiler(opts) {
this.options = opts || {};
}
/**
* Emit `str`
*/
Compiler.prototype.emit = function(str) {
return str;
};
/**
* Visit `node`.
*/
Compiler.prototype.visit = function(node){
return this[node.type](node);
};
/**
* Map visit over array of `nodes`, optionally using a `delim`
*/
Compiler.prototype.mapVisit = function(nodes, delim){
var buf = '';
delim = delim || '';
for (var i = 0, length = nodes.length; i < length; i++) {
buf += this.visit(nodes[i]);
if (delim && i < length - 1) buf += this.emit(delim);
}
return buf;
};

Some files were not shown because too many files have changed in this diff Show More