├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── FUNDING.yml
├── .gitignore
├── .nvmrc
├── .postcssrc.js
├── .prettierignore
├── .prettierrc
├── README.md
├── babel.config.js
├── backend
├── .gitignore
├── calendar.js
├── docker-compose.yml
├── error-handler.js
├── http-utils.js
├── package-lock.json
├── package.json
├── providers
│ ├── Aniwatch.js
│ └── providers.js
├── response.html
├── server.js
└── utils
│ └── strings.js
├── dist
└── spa
│ ├── chibi.png
│ ├── css
│ ├── 2.bc3cfb91.css
│ ├── 3.cbb1f13c.css
│ ├── 4.f4c9749e.css
│ ├── app.aa8f7688.css
│ └── vendor.2dcdf556.css
│ ├── favicon.ico
│ ├── fonts
│ ├── KFOkCnqEu92Fr1MmgVxIIzQ.68bb21d0.woff
│ ├── KFOlCnqEu92Fr1MmEU9fBBc-.48af7707.woff
│ ├── KFOlCnqEu92Fr1MmSU5fBBc-.c2f7ab22.woff
│ ├── KFOlCnqEu92Fr1MmWUlfBBc-.77ecb942.woff
│ ├── KFOlCnqEu92Fr1MmYUtfBBc-.f5677eb2.woff
│ ├── KFOmCnqEu92Fr1Mu4mxM.f1e2a767.woff
│ ├── fa-brands-400.2285773e.woff
│ ├── fa-brands-400.d878b0a6.woff2
│ ├── fa-regular-400.7a333762.woff2
│ ├── fa-regular-400.bb58e57c.woff
│ ├── fa-solid-900.1551f4f6.woff2
│ ├── fa-solid-900.eeccf4f6.woff
│ ├── flUhRq6tzZclQEJ-Vdg-IuiaDsNa.4d73cb90.woff
│ └── flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.0383092b.woff2
│ ├── icons
│ ├── animeflv.ico
│ ├── animepahe.png
│ ├── crunchyroll.png
│ ├── favicon-128x128.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon-96x96.png
│ ├── monoschinos.ico
│ └── youtube.png
│ ├── index.html
│ ├── js
│ ├── 10.6910009d.js
│ ├── 11.d74a3687.js
│ ├── 12.92284c22.js
│ ├── 13.4d6a33f0.js
│ ├── 14.a0442d14.js
│ ├── 15.601bcce8.js
│ ├── 16.ee88b894.js
│ ├── 17.0d3a8f1c.js
│ ├── 18.f8da8327.js
│ ├── 19.1e9e23d1.js
│ ├── 2.ec70d99d.js
│ ├── 20.0aeafe02.js
│ ├── 3.f88a54a3.js
│ ├── 4.e5f9f4f4.js
│ ├── 5.f58dc392.js
│ ├── 6.31eb4eef.js
│ ├── 7.c01ad7d9.js
│ ├── 8.49fba52f.js
│ ├── 9.e43c0c97.js
│ ├── app.fb2425d9.js
│ └── vendor.3787859d.js
│ ├── kofi.png
│ ├── mal.png
│ └── old
│ ├── FontSiteSans-Cond-webfont.woff
│ ├── background.jpg
│ ├── http.js
│ ├── index.css
│ ├── index.html
│ ├── index.js
│ ├── load-fig.gif
│ ├── mal.jpg
│ ├── storage.js
│ └── utils.js
├── js
└── app.0ec75656.js
├── jsconfig.json
├── old
├── FontSiteSans-Cond-webfont.woff
├── background.jpg
├── http.js
├── index.css
├── index.html
├── index.js
├── load-fig.gif
├── mal.jpg
├── storage.js
└── utils.js
├── package.json
├── public
├── chibi.png
├── favicon.ico
├── icons
│ ├── animeflv.ico
│ ├── animepahe.png
│ ├── crunchyroll.png
│ ├── favicon-128x128.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon-96x96.png
│ ├── monoschinos.ico
│ └── youtube.png
├── kofi.png
└── mal.png
├── quasar.conf.js
├── redirect
├── README.md
├── favicon.ico
├── index.html
└── style.css
├── src
├── App.vue
├── README.md
├── api
│ ├── API.js
│ ├── Backend.js
│ ├── MALSync.js
│ └── MyAnimeList.js
├── assets
│ └── sad.svg
├── boot
│ ├── components.js
│ └── i18n.js
├── components
│ ├── About.vue
│ ├── AnimeEpisode.vue
│ ├── AnimeSettings.vue
│ ├── Avatar.vue
│ ├── Back.vue
│ ├── CalendarButton.vue
│ ├── DynamicButton.vue
│ ├── ItemButton.vue
│ ├── LanguageSelect.vue
│ ├── PasswordDialog.vue
│ ├── ProviderSelect.vue
│ ├── ResetButton.vue
│ ├── StatusSelect.vue
│ ├── SupportMe.vue
│ ├── TitleSelect.vue
│ └── UserSearch.vue
├── css
│ ├── app.scss
│ └── quasar.variables.scss
├── i18n
│ ├── en
│ │ └── index.js
│ ├── es
│ │ └── index.js
│ └── index.js
├── index.template.html
├── layouts
│ └── MainLayout.vue
├── mixins
│ ├── bind.js
│ ├── configuration.js
│ └── keyboard.js
├── model
│ ├── Anime.js
│ └── providers
│ │ ├── AnimeFLV.js
│ │ ├── AnimeFenix.js
│ │ ├── AnimeHeaven.js
│ │ ├── AnimeID.js
│ │ ├── AnimeMovil2.js
│ │ ├── AnimeSuge.js
│ │ ├── Animeflix.js
│ │ ├── Animepahe.js
│ │ ├── Crunchyroll.js
│ │ ├── FeelingLucky.js
│ │ ├── Gogoanime.js
│ │ ├── MonosChinos.js
│ │ ├── MyAnimeList.js
│ │ ├── Netflix.js
│ │ ├── NineAnime.js
│ │ ├── Provider.js
│ │ ├── YouTube.js
│ │ ├── YugenAnime.js
│ │ ├── Zoro.js
│ │ └── jkAnime.js
├── pages
│ ├── Error404.vue
│ └── Index.vue
├── router
│ ├── index.js
│ └── routes.js
├── store
│ ├── index.js
│ ├── store-flag.d.ts
│ └── store
│ │ ├── actions.js
│ │ ├── getters.js
│ │ ├── index.js
│ │ ├── mutations.js
│ │ └── state.js
└── utils
│ ├── errors.js
│ ├── strings.js
│ └── subscription.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /dist
2 | /src-bex/www
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 |
4 | parserOptions: {
5 | parser: 'babel-eslint',
6 | sourceType: 'module',
7 | },
8 |
9 | env: {
10 | browser: true,
11 | es6: true,
12 | node: true,
13 | },
14 |
15 | extends: [
16 | // https://eslint.vuejs.org/rules/#priority-a-essential-error-prevention
17 | 'plugin:vue/recommended',
18 | 'airbnb-base',
19 | 'prettier',
20 | 'prettier/vue',
21 | ],
22 |
23 | // required to lint *.vue files
24 | plugins: ['prettier', 'vue'],
25 |
26 | globals: {
27 | ga: true, // Google Analytics
28 | cordova: true,
29 | __statics: true,
30 | process: true,
31 | Capacitor: true,
32 | chrome: true,
33 | },
34 |
35 | // add your custom rules here
36 | rules: {
37 | 'max-len': [
38 | 'warn',
39 | {
40 | code: 120,
41 | ignoreUrls: true,
42 | ignoreComments: true,
43 | ignoreStrings: true,
44 | ignoreTemplateLiterals: true,
45 | },
46 | ],
47 |
48 | 'prefer-destructuring': 'off', // 'error', { object: true, array: false }
49 |
50 | 'vue/component-name-in-template-casing': ['error', 'PascalCase'],
51 | 'vue/no-v-html': 'off', // Beware of XSS!
52 | 'no-param-reassign': 'off',
53 | 'global-require': 'off',
54 | 'no-unused-vars': 'warn',
55 |
56 | 'import/first': 'off',
57 | 'import/named': 'error',
58 | 'import/namespace': 'error',
59 | 'import/default': 'error',
60 | 'import/export': 'error',
61 | 'import/extensions': 'off',
62 | 'import/no-unresolved': 'off',
63 | 'import/no-extraneous-dependencies': 'off',
64 | 'import/prefer-default-export': 'off',
65 |
66 | // allow console during development only
67 | 'no-console': process.env.NODE_ENV === 'production' ? ['error', { allow: ['warn', 'error'] }] : 'off',
68 | // allow debugger during development only
69 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
70 | },
71 | };
72 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ko_fi: carleslc
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .thumbs.db
3 | node_modules
4 |
5 | # Quasar core related directories
6 | .quasar
7 | # /dist
8 |
9 | # Cordova related directories and files
10 | /src-cordova/node_modules
11 | /src-cordova/platforms
12 | /src-cordova/plugins
13 | /src-cordova/www
14 |
15 | # Capacitor related directories and files
16 | /src-capacitor/www
17 | /src-capacitor/node_modules
18 |
19 | # BEX related directories and files
20 | /src-bex/www
21 | /src-bex/js/core
22 |
23 | # Log files
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # Editor directories and files
29 | .idea
30 | *.suo
31 | *.ntvs*
32 | *.njsproj
33 | *.sln
34 | .vscode
35 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/gallium
2 |
--------------------------------------------------------------------------------
/.postcssrc.js:
--------------------------------------------------------------------------------
1 | // https://github.com/michael-ciniawsky/postcss-load-config
2 |
3 | module.exports = {
4 | plugins: [
5 | // to edit target browsers: use "browserslist" field in package.json
6 | require('autoprefixer')
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | index.js
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MyAnime
2 |
3 | Watch your favourite animes with your usual provider, synchronized with [MyAnimeList](https://myanimelist.net/).
4 |
5 | [](https://ko-fi.com/carleslc)
6 |
7 | 
8 |
9 | [](https://app.netlify.com/sites/my-anime/deploys)
10 |
11 | ## Why to use?
12 |
13 | This is a shortcut to access your favourite anime series with your preferred providers, all in sync with the selected animes in your MyAnimeList profile.
14 |
15 | Avoid surfing the internet or the providers looking for your episodes, just seat in and enjoy.
16 |
17 | With this single page you can test several providers and update your MyAnimeList episodes easily.
18 |
19 | It also skips some advertising from providers home sites, because you'll access to the episode page directly.
20 |
21 | ## How to use
22 |
23 | Enter your MyAnimeList username _(https://myanimelist.net/profile/USERNAME)_, select your preferred provider and choose one of your animes to watch, then just enjoy your episode.
24 |
25 | Click on "Next episode" at the top right corner of an episode to mark it as watched in your MyAnimeList profile.
26 |
27 | When updating your watched episodes in MyAnimeList then the next episode will be shown, waiting for you to be watched.
28 |
29 | ## Features
30 |
31 | - Cover images and links for the series in which you are interested.
32 | - Easy access to your next episodes of MyAnimeList lists Watching, On Hold and Plan to Watch, with several providers for streaming to choose.
33 | - Filter episodes by status (Already aired, Not yet aired), type (TV, Movie, OVA, Special, ONA, Music) and genre.
34 | - Some series have title synonyms, choose between original or alternative title in the settings of the anime.
35 | - Update your watched episodes in your MyAnimeList profile directly within this page.
36 |
37 | Your preferences are saved in the browser, so you only need to change them when you need it. User is automatically retrieved based on the last user used.
38 |
39 | ## Supported providers
40 |
41 | - [MyAnimeList](https://myanimelist.net/)
42 | - [Crunchyroll](https://www.crunchyroll.com/)
43 | - [Netflix](https://www.netflix.com/)
44 | - [Google](https://www.google.com/)
45 | - 🇬🇧 [9Anime](https://9anime.to/)
46 | - 🇬🇧 [Zoro](https://zoro.to/)
47 | - 🇬🇧 [YugenAnime](https://yugen.to/)
48 | - 🇬🇧 [GogoAnime](https://gogoanime.bid/)
49 | - 🇬🇧 [GogoAnime.page](https://gogoanime.page/)
50 | - 🇬🇧 [AnimeSuge](https://animesuge.to/)
51 | - 🇬🇧 [animepahe](https://animepahe.ru/)
52 | - 🇬🇧 [AnimeHeaven](https://animeheaven.ru/)
53 | - 🇬🇧 [Animeflix](https://animeflix.live/)
54 | - 🇬🇧 I'm Feeling Lucky
55 | - 🇪🇸 Voy a tener suerte
56 | - 🇪🇸 [AnimeFlv](https://www.animeflv.net)
57 | - 🇪🇸 [AnimeID](https://www.animeid.tv/)
58 | - 🇪🇸 [AnimeFenix](https://animefenix.tv/)
59 | - 🇪🇸 [jkanime](http://jkanime.net/)
60 | - 🇪🇸 [MonosChinos](https://monoschinos2.com/)
61 | - 🇪🇸 [AnimeMovil2](https://animemovil2.com/)
62 |
63 | Some options are based on search engine (_[DuckDuckGo](https://duckduckgo.com/) I'm Feeling Ducky_), trying to get a proper streamer, but it doesn't mean it always work. Sometimes it redirects to another anime, a non-related page, or it is in another language.
64 |
65 | If selected provider cannot find an episode try to change the provider or choose an alternative title.
66 |
67 | ## Contact
68 |
69 | _Your favourite provider is not listed here? Please, open an issue [here](http://github.com/Carleslc/MyAnime/issues) and we will add it!_
70 |
71 | Open an issue too if you have any doubt, advice or you want to report about something broken.
72 |
73 | ### Disclaimer
74 |
75 | This website does not host any video, it is a client-side website, just linking and sharing content from non-affiliated external providers.
76 |
77 | Official providers like Crunchyroll or Netflix are recommended. Use other providers at your own risk.
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | Enjoy!
86 |
87 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@quasar/babel-preset-app'],
3 | };
4 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/backend/calendar.js:
--------------------------------------------------------------------------------
1 | const { DateTime } = require('luxon');
2 | const cheerio = require('cheerio');
3 | const jsonframe = require('jsonframe-cheerio');
4 | const isEqual = require('lodash.isequal');
5 | const get = require('./http-utils');
6 | const handler = require('./error-handler');
7 |
8 | const tag = 'calendar';
9 | const refreshSeconds = 60 * 60 * 24;
10 | const backupTTL = refreshSeconds;
11 |
12 | // Alternative: https://www.livechart.me/timetable
13 | const CALENDAR_URL = 'https://notify.moe/calendar';
14 |
15 | function formatDateTime(dateTime) {
16 | // https://moment.github.io/luxon/docs/manual/formatting.html#table-of-tokens
17 | return dateTime.toFormat('EEE dd/MM/yyyy, HH:mm ZZZZ');
18 | }
19 |
20 | function parse(html) {
21 | const _ = cheerio.load(html);
22 | jsonframe(_);
23 |
24 | const frame = {
25 | airingAnimes: {
26 | _s: '.calendar-entry',
27 | _d: [
28 | {
29 | title: '.calendar-entry-title',
30 | episode: '.calendar-entry-episode | number',
31 | date: '.calendar-entry-time @ datetime',
32 | },
33 | ],
34 | },
35 | };
36 |
37 | /*
38 | airingAnimes: [
39 | {
40 | title: "One Piece",
41 | episode: "835",
42 | date: "2018-05-06T00:30:00Z"
43 | },
44 | ...
45 | ]
46 | */
47 |
48 | return _('.week').scrape(frame);
49 | }
50 |
51 | function set(cache, calendar) {
52 | // eslint-disable-next-line no-unused-vars
53 | cache.add(tag, calendar, { expire: backupTTL, type: 'json' }, (error, _added) => {
54 | if (error) {
55 | console.error(`Calendar set cache error: ${error.message}`);
56 | } else {
57 | console.log('Calendar refreshed successfully');
58 | }
59 | });
60 | }
61 |
62 | function removeCalendarDuplicates(calendar) {
63 | const titleEpisodes = {}; // title: [int(episode)]
64 | const filteredEntries = [];
65 |
66 | calendar.airingAnimes.forEach((entry) => {
67 | const sameTitleEpisodes = titleEpisodes[entry.title];
68 | const entryEpisode = parseInt(entry.episode, 10);
69 |
70 | if (sameTitleEpisodes) {
71 | // Discard duplicated episodes (keep older date, i.e. first episode added) [previousEpisode === entryEpisode]
72 | // Discard older episodes published later (e.g. dubbed versions) [previousEpisode > entryEpisode]
73 | if (!sameTitleEpisodes.some((previousEpisode) => previousEpisode >= entryEpisode)) {
74 | filteredEntries.push(entry);
75 | sameTitleEpisodes.push(entryEpisode);
76 | }
77 | } else {
78 | filteredEntries.push(entry);
79 | titleEpisodes[entry.title] = [entryEpisode];
80 | }
81 | });
82 |
83 | return {
84 | airingAnimes: filteredEntries
85 | };
86 | }
87 |
88 | function clean(calendar) {
89 | try {
90 | return removeCalendarDuplicates(calendar);
91 | } catch (e) {
92 | console.error('Cannot clean calendar', e);
93 | return calendar;
94 | }
95 | }
96 |
97 | function getCurrentCalendar(cache) {
98 | return new Promise((resolve, reject) => {
99 | cache.get(tag, (error, entries) => {
100 | if (error) {
101 | console.error(`Calendar get cache error: ${error.message}`);
102 | reject(error);
103 | } else if (entries.length > 0) {
104 | resolve(entries[0].body);
105 | } else {
106 | console.warn('Calendar not cached!');
107 | resolve(null);
108 | }
109 | });
110 | });
111 | }
112 |
113 | function fetch(cache) {
114 | return new Promise((resolve, reject) => {
115 | function retrieve() {
116 | get(CALENDAR_URL)
117 | .then((html) => {
118 | const calendar = clean(parse(html));
119 | console.log('Calendar retrieved');
120 | const calendarData = JSON.stringify(calendar);
121 | if (cache) {
122 | set(cache, calendarData);
123 | }
124 | resolve(calendarData);
125 | })
126 | .catch(reject);
127 | }
128 | if (cache) {
129 | getCurrentCalendar(cache)
130 | .then((calendarData) => {
131 | if (calendarData !== null) {
132 | resolve(calendarData);
133 | } else {
134 | retrieve();
135 | }
136 | })
137 | .catch(reject);
138 | } else retrieve();
139 | });
140 | }
141 |
142 | function expire(cache) {
143 | return new Promise((resolve, reject) => {
144 | cache.del(tag, (error, n) => {
145 | if (error) {
146 | console.error(`Calendar del cache error: ${error.message}`);
147 | reject(error);
148 | } else if (n > 0) {
149 | console.log('Expired calendar entries');
150 | }
151 | resolve();
152 | });
153 | });
154 | }
155 |
156 | function refreshTask(cache) {
157 | function refresh() {
158 | console.log(`[${formatDateTime(DateTime.utc())}] Refreshing calendar...`);
159 | fetch()
160 | .then((newCalendar) => {
161 | getCurrentCalendar(cache).then((oldCalendar) => {
162 | if (oldCalendar === null || !isEqual(oldCalendar, newCalendar)) {
163 | expire(cache).then(() => {
164 | set(cache, newCalendar);
165 | });
166 | } else {
167 | console.log('Calendar is already up to date');
168 | }
169 | });
170 | })
171 | .catch(handler.httpConsole());
172 | }
173 | setImmediate(refresh);
174 | const now = DateTime.utc();
175 | const scheduleLoop = now.startOf('day').plus({ days: 1, hours: 1, minutes: 1 }); // some delay to wait for an update at notify.moe/calendar
176 | const scheduleLoopMillis = scheduleLoop.diff(now).toObject().milliseconds;
177 | console.log(
178 | `Refresh loop will start in ${(scheduleLoopMillis / 1000 / 3600).toFixed(2)} hours at ${formatDateTime(
179 | scheduleLoop
180 | )}`
181 | );
182 | setTimeout(function scheduleRefresh() {
183 | refresh();
184 | setInterval(refresh, refreshSeconds * 1000);
185 | }, scheduleLoopMillis);
186 | }
187 |
188 | module.exports = {
189 | fetch,
190 | expire,
191 | refreshTask,
192 | };
193 |
--------------------------------------------------------------------------------
/backend/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | my-anime:
5 | image: node:alpine
6 | container_name: my-anime
7 | restart: unless-stopped
8 | volumes:
9 | - ./:/var/www/app
10 | ports:
11 | - 3000:3000
12 | environment:
13 | - WHITELIST=my-anime.netlify.app,anime.carleslc.me,carleslc.me
14 | - REDIS_HOST=my-anime-redis-cache
15 | - NODE_ENV=production
16 | - PORT=3000
17 | links:
18 | - redis
19 | working_dir: /var/www/app
20 | command: sh -c 'npm i && node server.js'
21 | redis:
22 | image: redis
23 | container_name: my-anime-redis-cache
24 | expose:
25 | - 6379
26 |
--------------------------------------------------------------------------------
/backend/error-handler.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | http(res) {
3 | return (err, status) => {
4 | console.error((err.response && err.response.data) || err);
5 | res.status(status || 500).send(err.message || '');
6 | };
7 | },
8 | httpConsole() {
9 | return (err, status) => console.error(`Error ${err}, status: ${status}`);
10 | },
11 | console() {
12 | return (err) => console.error(err.message);
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/backend/http-utils.js:
--------------------------------------------------------------------------------
1 | const fetch = require('request');
2 |
3 | module.exports = function get(url, username, password) {
4 | return new Promise((resolve, reject) => {
5 | const options = { url };
6 | if (username && password) {
7 | options.auth = {
8 | user: username,
9 | password,
10 | };
11 | }
12 | fetch(options, (error, res, body) => {
13 | if (!error && res.statusCode === 200) {
14 | resolve(body);
15 | } else {
16 | reject(body || error, (res && res.statusCode) || 500);
17 | }
18 | });
19 | });
20 | };
21 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-anime",
3 | "version": "1.0.0",
4 | "description": "MyAnime API",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node index.js"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/Carleslc/MyAnime.git"
12 | },
13 | "author": "Carlos Lázaro Costa",
14 | "license": "ISC",
15 | "bugs": {
16 | "url": "https://github.com/Carleslc/MyAnime/issues"
17 | },
18 | "homepage": "https://github.com/Carleslc/MyAnime#readme",
19 | "dependencies": {
20 | "axios": "^0.21.2",
21 | "basic-auth": "^2.0.0",
22 | "body-parser": "^1.20.1",
23 | "cheerio": "^1.0.0-rc.2",
24 | "cors": "^2.8.4",
25 | "express": "^4.18.2",
26 | "express-redis-cache": "^1.1.1",
27 | "full-icu": "^1.2.1",
28 | "jsonframe-cheerio": "^3.0.1",
29 | "lodash.isequal": "^4.5.0",
30 | "luxon": "^1.28.1",
31 | "nconf": "^0.11.4",
32 | "popura": "^1.2.5",
33 | "redis": "^3.1.1",
34 | "request": "^2.86.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/backend/providers/Aniwatch.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios');
2 | const { generateToken } = require('../utils/strings');
3 |
4 | // This provider is no longer active
5 | // We keep this file only as a reference
6 |
7 | class Aniwatch {
8 | constructor() {
9 | this.url = 'https://aniwatch.me/';
10 |
11 | this.axios = axios.create({
12 | baseURL: `${this.url}api/ajax/APIHandle`,
13 | headers: {
14 | common: {
15 | Accept: 'application/json',
16 | 'Content-Type': 'application/json',
17 | 'User-Agent':
18 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36',
19 | },
20 | },
21 | });
22 |
23 | this.api = {
24 | post: (data, opts) => this.axios.post('', JSON.stringify(data), opts),
25 | };
26 | }
27 |
28 | buildUrl(id, episode) {
29 | return id ? `${this.url}anime/${id}/${episode || ''}` : null;
30 | }
31 |
32 | async search(title) {
33 | console.log('Aniwatch search', title);
34 | const xsrf = generateToken();
35 | const response = await this.api.post(
36 | {
37 | controller: 'Search',
38 | action: 'search',
39 | rOrder: false,
40 | order: 'title',
41 | typed: title,
42 | },
43 | {
44 | withCredentials: true,
45 | headers: {
46 | 'x-path': '/search',
47 | 'x-xsrf-token': xsrf,
48 | referer: this.url,
49 | Cookie: `XSRF-TOKEN=${xsrf};`,
50 | },
51 | }
52 | );
53 | let id = null;
54 | if (response.data.length > 0) {
55 | id = response.data[0].detail_id || null;
56 | }
57 | return id;
58 | }
59 |
60 | async getUrl(cache, title, episode) {
61 | let id = await cache.get(title);
62 |
63 | if (id === undefined) {
64 | id = await this.search(title);
65 | cache.save(title, id);
66 | }
67 |
68 | return this.buildUrl(id, episode);
69 | }
70 | }
71 |
72 | module.exports = new Aniwatch();
73 |
--------------------------------------------------------------------------------
/backend/providers/providers.js:
--------------------------------------------------------------------------------
1 | const expiration = 60 * 60 * 24; // 1 day in seconds
2 |
3 | const providers = {
4 | Aniwatch: require('./Aniwatch'),
5 | };
6 |
7 | function expire(cache, provider) {
8 | return new Promise((resolve, reject) => {
9 | cache.del(`${provider}.*`, (error, n) => {
10 | if (error) {
11 | console.error(`${provider} expire cache error: ${error.message}`);
12 | reject(error);
13 | } else if (n > 0) {
14 | console.log(`Expired ${n} ${provider} entries`);
15 | }
16 | resolve();
17 | });
18 | });
19 | }
20 |
21 | function tag(provider, key) {
22 | return `${provider}.${key}`;
23 | }
24 |
25 | function providerCache(cache, provider) {
26 | return {
27 | get: (key) =>
28 | new Promise((resolve) => {
29 | cache.get(tag(provider, key), (error, entries) => {
30 | if (error) {
31 | console.error(`${provider} cache error: ${error.message}`);
32 | } else if (entries.length > 0) {
33 | resolve(JSON.parse(entries[0].body));
34 | } else {
35 | resolve();
36 | }
37 | });
38 | }),
39 | save: (key, value) => {
40 | cache.add(tag(provider, key), JSON.stringify(value), { expire: expiration }, (error) => {
41 | if (error) {
42 | console.error(`Provider set cache error: ${error.message}`);
43 | }
44 | });
45 | },
46 | };
47 | }
48 |
49 | module.exports = {
50 | has(provider) {
51 | return !!provider && !!providers[provider];
52 | },
53 | getUrl(cache, provider, title, episode) {
54 | return providers[provider].getUrl(providerCache(cache, provider), title, episode);
55 | },
56 | expireAll(cache) {
57 | Object.keys(providers).forEach((provider) => expire(cache, provider));
58 | },
59 | };
60 |
--------------------------------------------------------------------------------
/backend/server.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 |
3 | const app = express();
4 |
5 | const cors = require('cors');
6 |
7 | app.use(cors());
8 |
9 | const nconf = require('nconf');
10 |
11 | nconf.argv().env();
12 |
13 | const port = nconf.get('PORT') || 3000;
14 |
15 | // eslint-disable-next-line import/order
16 | const handler = require('./error-handler');
17 |
18 | const redis = require('redis');
19 |
20 | const client = redis
21 | .createClient(nconf.get('REDIS_PORT') || '6379', nconf.get('REDIS_HOST') || '127.0.0.1', {
22 | auth_pass: nconf.get('REDIS_KEY'),
23 | return_buffers: true,
24 | })
25 | .on('error', handler.console());
26 |
27 | const cache = require('express-redis-cache')({ client, prefix: 'anime' });
28 |
29 | /* cache.on('message', function(message) {
30 | console.log(message);
31 | }); */
32 |
33 | app.get('/', (req, res) => {
34 | res.send('MyAnime API
Go to the app');
35 | });
36 |
37 | const calendar = require('./calendar');
38 |
39 | app.get('/calendar', (req, res) => {
40 | res.setHeader('Content-Type', 'application/json');
41 | calendar
42 | .fetch(cache)
43 | .then((cal) => res.send(cal))
44 | .catch(handler.http(res));
45 | });
46 |
47 | const providers = require('./providers/providers');
48 |
49 | let whitelist = [];
50 |
51 | const whitelistEnv = nconf.get('WHITELIST');
52 | if (whitelistEnv) {
53 | whitelist = whitelistEnv.split(',');
54 | }
55 |
56 | app.get('/provider/:name', (req, res) => {
57 | const origin = req.headers.origin || req.headers.host;
58 | if (whitelist.length > 0 && !whitelist.includes(origin)) {
59 | res.status(403).send(`Forbidden (whitelist). Origin ${origin} not allowed.`);
60 | return;
61 | }
62 |
63 | const provider = req.params.name;
64 | const title = req.query.title;
65 | const episode = req.query.episode;
66 |
67 | if (!providers.has(provider)) {
68 | res.status(404).send(`Provider ${provider} not found`);
69 | } else if (!title) {
70 | res.status(400).send('Missing title parameter');
71 | } else {
72 | providers
73 | .getUrl(cache, provider, title, episode)
74 | .then((url) => {
75 | if (url) {
76 | res.send({ url });
77 | } else {
78 | res.status(404).send(`Anime ${title} not found for provider ${provider}`);
79 | }
80 | })
81 | .catch(handler.http(res));
82 | }
83 | });
84 |
85 | providers.expireAll(cache);
86 |
87 | calendar.refreshTask(cache);
88 |
89 | app.listen(port, (err) => {
90 | console.log(err || `Server is listening on ${port}`);
91 | });
92 |
--------------------------------------------------------------------------------
/backend/utils/strings.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | generateToken(n = 32, chars = ['a', 'b', 'c', 'd', 'e', 'f', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) {
3 | let result = '';
4 | // eslint-disable-next-line no-plusplus
5 | for (let i = 0; i < n; i++) {
6 | result += chars[Math.floor(Math.random() * chars.length)];
7 | }
8 | return result;
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/dist/spa/chibi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/chibi.png
--------------------------------------------------------------------------------
/dist/spa/css/2.bc3cfb91.css:
--------------------------------------------------------------------------------
1 | .about a{color:#e242bd;text-decoration:none}.about a:active,.about a:hover{text-decoration:underline}.about ul{margin-top:0}.about ul a{color:#5e3175}.about ul a:active,.about ul a:hover{color:#e242bd;text-decoration:none}.about .section p:last-child{margin-bottom:0}
--------------------------------------------------------------------------------
/dist/spa/css/3.cbb1f13c.css:
--------------------------------------------------------------------------------
1 | .icon-only{border-radius:3px}.icon-only .q-item__section{padding:0;min-width:0}
--------------------------------------------------------------------------------
/dist/spa/css/4.f4c9749e.css:
--------------------------------------------------------------------------------
1 | .q-icon>img{width:2rem;height:2rem}
--------------------------------------------------------------------------------
/dist/spa/css/app.aa8f7688.css:
--------------------------------------------------------------------------------
1 | body{background-color:#2d1e53}body *{font-size:1rem}.q-footer,.q-page{background:transparent}.q-footer{width:-moz-fit-content;width:fit-content;left:auto}.q-header{background-color:#2d1e53;height:136px;z-index:5}.q-header .q-toolbar{height:88px}.gradient{background-image:linear-gradient(#2d1e53,#e242bd);background-color:#2d1e53}.q-page-container{padding-bottom:0!important}.q-btn{color:#fff}@media (max-width:349px){#settings{min-width:30px}.gt-xxs{display:none}.q-drawer__opener{width:4px}}.q-drawer{max-width:100vw}.q-drawer .q-item__section{margin:0}.q-tooltip{max-width:256px;text-align:center;font-size:1em;background-color:#1d1d1d;z-index:4000}.q-tooltip>div,.q-tooltip>p{font-size:1.05em}.prefix{font-size:16px;padding-bottom:2px}.user-search{min-width:192px;max-width:25vw}.user-search .q-field__control{padding-right:0}.user-search.q-field--focused .q-btn{color:#fff;background-color:#068b25}.user-search.q-field--focused .q-btn--flat{color:#1d1d1d;background-color:transparent}@media (max-width:349px){.user-search{min-width:152px}.user-search .q-btn{width:40px}}.q-select .q-field__before,.q-select .q-field__prepend{padding-right:12px}.q-select .q-field__after,.q-select .q-field__append{padding-left:12px}.q-select .q-field__inner{min-width:42px}.q-select.q-field--standout.q-field--focused .filter-options{background:rgba(45,30,83,0.1)}.q-select.q-field--standout.q-field--focused .filter-options .q-field__native{color:#1d1d1d}.q-select.q-field--standout.q-field--focused .filter-options .q-field__marginal{color:rgba(0,0,0,0.54)}.filter-options{color:grey}.filter-options .q-item--active{color:#e242bd;font-weight:700}@media (max-width:750px){.gt-xsm{display:none}}h1{font-size:3em;font-weight:700;margin:0}h2{font-size:1.5em;font-weight:600;margin:0.25em 0}p{text-align:justify}a.link{color:#1d1d1d;text-decoration:none}a.link-underline{color:#2d1e53;text-decoration:underline}a.link:active,a.link:hover{color:#e242bd}.clickable{cursor:pointer}.anime-container{display:grid;grid-gap:10px;grid-template-columns:repeat(auto-fill,calc(12.5% - 8.75px));justify-content:space-between}@media (min-width:2304px){.anime-container{grid-template-columns:repeat(auto-fill,calc(8.33333% - 9.16667px))}}@media (max-width:1439.98px){.anime-container{grid-template-columns:repeat(auto-fill,calc(16.66667% - 8.33333px))}}@media (max-width:1023.98px){.anime-container{grid-template-columns:repeat(auto-fill,calc(25% - 7.5px))}}@media (max-width:599.98px){.anime-container{grid-template-columns:repeat(auto-fill,calc(50% - 5px))}}@media (max-width:288px){.anime-container{grid-template-columns:repeat(auto-fill,100%)}}.anime-container .anime-episode.q-card{min-width:128px;border-radius:12px;overflow:hidden;box-shadow:none;outline:0}.anime-container .anime-episode.q-card h1,.anime-container .anime-episode.q-card h2{vertical-align:middle;text-align:center;color:#fff;text-shadow:0 1px 0 #000;line-height:1.2em;font-family:Roboto,Helvetica,Arial,sans-serif;z-index:1}.anime-container .anime-episode.q-card h1{font-size:1.2em;font-weight:700}.anime-container .anime-episode.q-card.small h1,.anime-container .anime-episode.q-card h2{font-size:1em}.anime-container .anime-episode.q-card.small h2{font-size:0.8em}.anime-container .anime-episode.q-card .q-btn,.anime-container .anime-episode.q-card .q-chip{z-index:2}.anime-container .anime-episode.q-card .q-img{image-rendering:optimizespeed;image-rendering:-moz-crisp-edges;image-rendering:-o-crisp-edges;image-rendering:-webkit-optimize-contrast;-ms-interpolation-mode:nearest-neighbor;min-height:200px}@media (max-width:599.98px){.anime-container .anime-episode.q-card .q-img{min-height:300px}}.overlay:after{content:"";background:rgba(0,0,0,0.6);position:absolute;top:0;right:0;bottom:0;left:0}.on-hover .hoverable{opacity:0;transition:opacity 200ms ease-in-out}.on-hover:focus-within .hoverable,.on-hover:focus .hoverable,.on-hover:hover .hoverable{opacity:1;transition:opacity 200ms ease-in-out}.q-skeleton:before{content:""}.square{border-radius:0}.fit-width{width:100%}
--------------------------------------------------------------------------------
/dist/spa/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/favicon.ico
--------------------------------------------------------------------------------
/dist/spa/fonts/KFOkCnqEu92Fr1MmgVxIIzQ.68bb21d0.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/KFOkCnqEu92Fr1MmgVxIIzQ.68bb21d0.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/KFOlCnqEu92Fr1MmEU9fBBc-.48af7707.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/KFOlCnqEu92Fr1MmEU9fBBc-.48af7707.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/KFOlCnqEu92Fr1MmSU5fBBc-.c2f7ab22.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/KFOlCnqEu92Fr1MmSU5fBBc-.c2f7ab22.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/KFOlCnqEu92Fr1MmWUlfBBc-.77ecb942.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/KFOlCnqEu92Fr1MmWUlfBBc-.77ecb942.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/KFOlCnqEu92Fr1MmYUtfBBc-.f5677eb2.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/KFOlCnqEu92Fr1MmYUtfBBc-.f5677eb2.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/KFOmCnqEu92Fr1Mu4mxM.f1e2a767.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/KFOmCnqEu92Fr1Mu4mxM.f1e2a767.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/fa-brands-400.2285773e.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/fa-brands-400.2285773e.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/fa-brands-400.d878b0a6.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/fa-brands-400.d878b0a6.woff2
--------------------------------------------------------------------------------
/dist/spa/fonts/fa-regular-400.7a333762.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/fa-regular-400.7a333762.woff2
--------------------------------------------------------------------------------
/dist/spa/fonts/fa-regular-400.bb58e57c.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/fa-regular-400.bb58e57c.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/fa-solid-900.1551f4f6.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/fa-solid-900.1551f4f6.woff2
--------------------------------------------------------------------------------
/dist/spa/fonts/fa-solid-900.eeccf4f6.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/fa-solid-900.eeccf4f6.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNa.4d73cb90.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNa.4d73cb90.woff
--------------------------------------------------------------------------------
/dist/spa/fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.0383092b.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.0383092b.woff2
--------------------------------------------------------------------------------
/dist/spa/icons/animeflv.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/animeflv.ico
--------------------------------------------------------------------------------
/dist/spa/icons/animepahe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/animepahe.png
--------------------------------------------------------------------------------
/dist/spa/icons/crunchyroll.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/crunchyroll.png
--------------------------------------------------------------------------------
/dist/spa/icons/favicon-128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/favicon-128x128.png
--------------------------------------------------------------------------------
/dist/spa/icons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/favicon-16x16.png
--------------------------------------------------------------------------------
/dist/spa/icons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/favicon-32x32.png
--------------------------------------------------------------------------------
/dist/spa/icons/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/favicon-96x96.png
--------------------------------------------------------------------------------
/dist/spa/icons/monoschinos.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/monoschinos.ico
--------------------------------------------------------------------------------
/dist/spa/icons/youtube.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/icons/youtube.png
--------------------------------------------------------------------------------
/dist/spa/index.html:
--------------------------------------------------------------------------------
1 | MyAnime
--------------------------------------------------------------------------------
/dist/spa/js/10.6910009d.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[10],{"172f":function(e,t,i){"use strict";i.r(t);var s=function(){var e=this,t=e._self._c;return t("q-card",[t("q-card-section",{staticClass:"row items-center no-wrap q-py-sm"},[t("a",{staticClass:"link text-center text-h6",attrs:{href:e.animeUrl,target:"_blank"}},[e._v(e._s(e.title))]),t("q-space"),t("q-btn",{directives:[{name:"close-popup",rawName:"v-close-popup"}],staticClass:"q-ml-md",attrs:{icon:"close",flat:"",round:"",dense:"",color:"grey"}})],1),t("q-separator"),t("q-card-section",[t("q-list",{staticClass:"column"},[t("div",{staticClass:"row"},[t("q-item-section",[t("q-item-label",{directives:[{name:"t",rawName:"v-t",value:"selectProvider",expression:"'selectProvider'"}],staticClass:"q-pt-none q-px-sm",attrs:{header:""}}),t("provider-select",{attrs:{value:e.provider,"options-dense":"",standout:"filter-options",tooltip:!1},on:{input:e.updateProvider}})],1)],1),e.anime.titles.length>1?t("div",{staticClass:"row"},[t("q-item-section",[t("q-item-label",{directives:[{name:"t",rawName:"v-t",value:"selectTitle",expression:"'selectTitle'"}],staticClass:"q-px-sm",attrs:{header:""}}),t("title-select",{attrs:{value:e.title,titles:e.anime.titles},on:{input:e.updateTitle}})],1)],1):e._e()])],1),t("q-separator")],1)},a=[],r=i("ded3"),n=i.n(r),l=i("2f62"),o={props:{anime:{type:Object,required:!0}},computed:n()(n()(n()({},Object(l["e"])("store",{animeUrl:function(e){var t=e.api;return t.animeUrl(this.anime)}})),Object(l["c"])("store",["providerByAnimeTitle","titleByAnimeId"])),{},{provider:function(){return this.providerByAnimeTitle(this.title)},title:function(){return this.titleByAnimeId(this.anime.id)||this.anime.title}}),methods:n()(n()({},Object(l["d"])("store",["setProviderByTitle","setAlternativeTitle"])),{},{updateProvider:function(e){this.setProviderByTitle({title:this.title,provider:e})},updateTitle:function(e){this.setAlternativeTitle({anime:this.anime,title:e})}})},c=o,p=i("2877"),d=i("f09f"),m=i("a370"),u=i("2c91"),v=i("9c40"),h=i("eb85"),q=i("1c1c"),f=i("4074"),b=i("0170"),w=i("7f67"),C=i("eebe"),T=i.n(C),y=Object(p["a"])(c,s,a,!1,null,null,null);t["default"]=y.exports;T()(y,"components",{QCard:d["a"],QCardSection:m["a"],QSpace:u["a"],QBtn:v["a"],QSeparator:h["a"],QList:q["a"],QItemSection:f["a"],QItemLabel:b["a"]}),T()(y,"directives",{ClosePopup:w["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/11.d74a3687.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[11],{df3a:function(e,t,a){"use strict";a.r(t);a("b0c0");var r=function(){var e=this,t=e._self._c;return e.icon?t("a",{attrs:{href:e.url,target:"_blank"}},[e.icon?t("q-avatar",{attrs:{size:e.size}},[t("img",{attrs:{src:e.picture}})]):e._e()],1):t("q-item",{directives:[{name:"ripple",rawName:"v-ripple"}],attrs:{tag:"a",href:e.url,target:"_blank"}},[t("q-item-section",{attrs:{avatar:""}},[t("q-avatar",{attrs:{size:e.size}},[t("img",{attrs:{src:e.picture}})])],1),t("q-item-section",[t("q-item-label",{staticClass:"text-h5"},[e._v(e._s(e.username||e.api.name))])],1)],1)},i=[],s=a("ded3"),n=a.n(s),c=a("2f62"),l={props:{size:{type:String,default:"56px"},icon:{type:Boolean,default:!1}},computed:n()(n()({},Object(c["e"])("store",["api","username","picture"])),{},{url:function(){return this.api.getUserProfileUrl(this.username)}})},p=l,u=a("2877"),o=a("cb32"),m=a("66e5"),f=a("4074"),b=a("0170"),d=a("714f"),v=a("eebe"),g=a.n(v),h=Object(u["a"])(p,r,i,!1,null,null,null);t["default"]=h.exports;g()(h,"components",{QAvatar:o["a"],QItem:m["a"],QItemSection:f["a"],QItemLabel:b["a"]}),g()(h,"directives",{Ripple:d["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/12.92284c22.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[12],{"66b7":function(n,t,o){"use strict";o.r(t);var e=function(){var n=this,t=n._self._c;return n.home?n._e():t("q-btn",{attrs:{round:"",flat:"",icon:"arrow_back"},on:{click:function(t){return n.back()}}})},c=[],r={name:"Back",computed:{home:function(){return"/"===this.$route.path}},methods:{back:function(){this.$router.go(-1)}}},u=r,a=o("2877"),i=o("9c40"),s=o("eebe"),l=o.n(s),b=Object(a["a"])(u,e,c,!1,null,null,null);t["default"]=b.exports;l()(b,"components",{QBtn:i["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/13.4d6a33f0.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[13],{"836a":function(n,t,a){"use strict";a.r(t);var e=function(){var n=this,t=n._self._c;return t("item-button",{attrs:{icon:"date_range",href:"https://notify.moe/calendar",target:"_blank",label:n.icon?"":n.$t("animeCalendar"),caption:n.$t("animeCalendarDescription")}})},o=[],i={props:{icon:{type:Boolean,default:!1}}},l=i,r=a("2877"),c=Object(r["a"])(l,e,o,!1,null,null,null);t["default"]=c.exports}}]);
--------------------------------------------------------------------------------
/dist/spa/js/14.a0442d14.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[14],{"7a1a":function(e,n,t){"use strict";t.r(n);var a=function(){var e=this,n=e._self._c;return e.open?n("div",{staticClass:"clickable non-selectable",attrs:{"aria-label":e.aria},on:{click:e.openOnce}},[e._t("default")],2):n("a",{staticClass:"non-selectable",attrs:{href:e.href,target:"_blank","aria-label":e.aria},on:{mousedown:function(e){e.preventDefault()}}},[e._t("default")],2)},i=[],r=t("7ec2"),s=t.n(r),c=t("c973"),l=t.n(c),o={props:{href:{type:String,default:"#"},open:{type:Function,default:void 0},aria:{type:String,default:void 0}},data:function(){return{isOpening:!1}},methods:{openOnce:function(){var e=this;return l()(s()().mark((function n(){return s()().wrap((function(n){while(1)switch(n.prev=n.next){case 0:if(e.isOpening){n.next=5;break}return e.isOpening=!0,n.next=4,e.open();case 4:e.isOpening=!1;case 5:case"end":return n.stop()}}),n)})))()}}},u=o,p=t("2877"),f=Object(p["a"])(u,a,i,!1,null,null,null);n["default"]=f.exports}}]);
--------------------------------------------------------------------------------
/dist/spa/js/15.601bcce8.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[15],{b8c0:function(e,n,t){"use strict";t.r(n);var a=function(){var e=this,n=e._self._c;return n("q-select",{ref:"languageSelect",attrs:{value:e.language,dark:"",dense:"",standout:"","options-selected-class":"filter-options","options-dark":!1,options:e.languages},on:{input:function(n){return e.selectLanguage(n)},"popup-show":function(n){return e.focus()},"popup-hide":function(n){return e.focus(!1)}},scopedSlots:e._u([{key:"prepend",fn:function(){return[n("q-icon",{attrs:{name:"language"}})]},proxy:!0},{key:"option",fn:function(t){return[n("q-item",e._g(e._b({directives:[{name:"ripple",rawName:"v-ripple"}]},"q-item",t.itemProps,!1),t.itemEvents),[n("q-item-section",{attrs:{avatar:""}},[e._v("\n "+e._s(t.opt.icon)+"\n ")]),n("q-item-section",[n("q-item-label",[e._v(e._s(t.opt.label))])],1)],1)]}}])})},s=[],i=(t("7db0"),t("d3b7"),t("4e82"),t("8847")),o={props:{value:{type:String,default:"en"}},data:function(){return{language:null,languages:[{label:"English",value:"en",icon:Object(i["b"])("en")},{label:"Español",value:"es",icon:Object(i["b"])("es")}]}},created:function(){var e=this;this.language=this.languages.find((function(n){return n.value===e.value}))||this.languages[0],this.sortLanguageOptions()},methods:{sortLanguageOptions:function(){var e=this;this.languages.sort((function(n,t){return n===e.language?-1:t===e.language?1:n.label.localeCompare(t.label)}))},selectLanguage:function(e){this.language=e,this.$emit("input",e.value),this.sortLanguageOptions()},focus:function(){var e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];this.$refs.languageSelect.focused=e}}},u=o,l=t("2877"),r=t("ddd8"),c=t("0016"),g=t("66e5"),p=t("4074"),f=t("0170"),d=t("714f"),v=t("eebe"),b=t.n(v),h=Object(l["a"])(u,a,s,!1,null,null,null);n["default"]=h.exports;b()(h,"components",{QSelect:r["a"],QIcon:c["a"],QItem:g["a"],QItemSection:p["a"],QItemLabel:f["a"]}),b()(h,"directives",{Ripple:d["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/16.ee88b894.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[16],{"40ad":function(e,t,s){"use strict";s.r(t);s("b0c0");var a=function(){var e=this,t=e._self._c;return t("q-dialog",{attrs:{persistent:""},model:{value:e.authNeeded,callback:function(t){e.authNeeded=t},expression:"authNeeded"}},[t("q-card",{staticStyle:{"min-width":"300px"}},[t("q-form",{on:{submit:e.setPassword}},[t("q-card-section",{staticClass:"row justify-between items-center"},[t("div",{directives:[{name:"t",rawName:"v-t",value:"login",expression:"'login'"}],staticClass:"text-h5 text-bold"}),t("a",{attrs:{href:e.api.homeUrl,target:"_blank"}},[t("q-avatar",{attrs:{rounded:"",size:"lg"}},[t("img",{attrs:{src:e.api.image}})])],1)]),t("q-card-section",{staticClass:"q-pt-none"},[t("div",[e._v("\n "+e._s(e.$t("loginDescription",{api:e.api.name}))+"\n ")])]),t("q-card-section",{staticClass:"q-pt-none column"},[t("q-input",{attrs:{dense:"",placeholder:e.$t("username"),rules:[function(t){return!e.isBlank(t)||e.$t("required.username")}]},model:{value:e.username,callback:function(t){e.username=t},expression:"username"}}),t("q-input",{attrs:{dense:"",autofocus:"",type:"password",placeholder:e.$t("password"),rules:[function(t){return!e.isBlank(t)||e.$t("required.password")}]},model:{value:e.password,callback:function(t){e.password=t},expression:"password"}})],1),t("q-card-section",{staticClass:"q-py-none text-grey-6"},[t("span",{directives:[{name:"t",rawName:"v-t",value:"notRegisteredYet",expression:"'notRegisteredYet'"}],staticClass:"q-mr-sm"}),t("a",{staticClass:"link link-underline",attrs:{href:e.api.registerUrl,target:"_blank"}},[e._v(e._s(e.$t("registerHere")))])]),e.api.setPasswordUrl?t("i18n",{staticClass:"q-pb-none text-italic text-grey-6",attrs:{path:"noPassword",tag:"q-card-section"}},[t("a",{staticClass:"link link-underline",attrs:{href:e.api.setPasswordUrl,target:"_blank"}},[e._v(e._s(e.$t("accountSettings")))])]):e._e(),t("q-card-actions",{staticClass:"text-primary",attrs:{align:"right"}},[e.isLoading?e._e():t("q-btn",{directives:[{name:"close-popup",rawName:"v-close-popup"}],attrs:{flat:"",color:"grey",label:e.$t("cancel")}}),t("q-btn",{attrs:{flat:"",color:"primary",type:"submit",label:e.$t("login"),disable:!e.isValid,loading:e.isLoading}})],1)],1)],1)],1)},r=[],n=s("ded3"),i=s.n(n),o=(s("d3b7"),s("e6cf"),s("a79d"),s("002d")),c=s("2f62"),l=s("5935"),d={data:function(){return{username:"",password:""}},computed:i()(i()(i()(i()(i()({},Object(c["e"])("store",{currentUsername:function(e){return e.username}})),Object(c["e"])("store",["api"])),Object(l["b"])("store",["authNeeded"])),Object(c["c"])("store",["isLoading"])),{},{isValid:function(){return!Object(o["a"])(this.password)&&!Object(o["a"])(this.username)}}),watch:{currentUsername:function(){this.username=this.currentUsername,this.password=""}},created:function(){this.username=this.currentUsername},methods:i()(i()(i()({},Object(c["b"])("store",["login","searchUser"])),Object(c["d"])("store",["setAuthNeeded"])),{},{isBlank:o["a"],setPassword:function(){var e=this,t=Object(o["d"])(this.username);this.login({username:t,password:this.password}).then((function(){e.searchUser(t)})).finally((function(){e.setAuthNeeded(!1)}))}})},u=d,p=s("2877"),m=s("24e8"),b=s("f09f"),h=s("0378"),f=s("a370"),g=s("cb32"),w=s("27f9"),v=s("4b7e"),q=s("9c40"),k=s("7f67"),C=s("eebe"),x=s.n(C),_=Object(p["a"])(u,a,r,!1,null,null,null);t["default"]=_.exports;x()(_,"components",{QDialog:m["a"],QCard:b["a"],QForm:h["a"],QCardSection:f["a"],QAvatar:g["a"],QInput:w["a"],QCardActions:v["a"],QBtn:q["a"]}),x()(_,"directives",{ClosePopup:k["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/17.0d3a8f1c.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[17],{"19a6":function(e,t,n){"use strict";n.r(t);var s=function(){var e=this,t=e._self._c;return t("item-button",{attrs:{dense:"",icon:"delete",label:e.$t("resetSettings"),caption:e.$t("resetSettingsDescription")},on:{click:e.reset}})},c=[],r=n("ded3"),i=n.n(r),l=n("2f62"),o={methods:i()(i()({},Object(l["d"])("store",["clear"])),{},{reset:function(){this.$q.localStorage.clear(),this.clear()}})},a=o,u=n("2877"),d=Object(u["a"])(a,s,c,!1,null,null,null);t["default"]=d.exports}}]);
--------------------------------------------------------------------------------
/dist/spa/js/18.f8da8327.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[18],{"6ec6":function(e,t,n){"use strict";n.r(t);var s=function(){var e=this,t=e._self._c;return t("q-form",{on:{submit:e.searchUserInput}},[t("q-input",{staticClass:"user-search",attrs:{dark:"",dense:"",standout:"",placeholder:"Username"},scopedSlots:e._u([{key:"prepend",fn:function(){return[t("span",{staticClass:"prefix"},[e._v("@")]),t("q-tooltip",{attrs:{anchor:"center right",self:"center left","transition-show":"fade","transition-hide":"fade",offset:[0,0]}},[e._v("\n "+e._s(e.api.profileUrl)+"\n ")])]},proxy:!0},{key:"append",fn:function(){return[t("q-btn",{attrs:{flat:e.username===e.input||!e.filled,loading:e.isLoading,disabled:!e.filled,icon:"search",type:"submit"},on:{click:e.searchUserInput}})]},proxy:!0}]),model:{value:e.input,callback:function(t){e.input=t},expression:"input"}}),t("password-dialog")],1)},i=[],r=n("ded3"),a=n.n(r),o=n("002d"),u=n("2f62"),c={data:function(){return{input:""}},computed:a()(a()(a()({},Object(u["e"])("store",["username","api"])),Object(u["c"])("store",["isLoading","hasUsername"])),{},{filled:function(){return!Object(o["a"])(this.input)}}),watch:{username:function(){this.username!==this.input&&(this.input=this.username)}},created:function(){this.hasUsername&&(this.input=this.username,this.searchUserInput())},methods:a()(a()({},Object(u["b"])("store",["searchUser"])),{},{searchUserInput:function(){this.searchUser(Object(o["d"])(this.input))}})},p=c,l=n("2877"),d=n("0378"),h=n("27f9"),f=n("05c0"),m=n("9c40"),b=n("eebe"),U=n.n(b),w=Object(l["a"])(p,s,i,!1,null,null,null);t["default"]=w.exports;U()(w,"components",{QForm:d["a"],QInput:h["a"],QTooltip:f["a"],QBtn:m["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/19.1e9e23d1.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[19],{"713b":function(t,e,s){"use strict";s.r(e);var a=function(){var t=this,e=t._self._c;return e("q-layout",{attrs:{view:"hHr lpR fFr"}},[e("q-header",[e("q-toolbar",{staticClass:"row justify-between items-center q-pa-md no-wrap scroll",attrs:{color:"purple"}},[e("div",{staticClass:"col-auto row items-center no-wrap",style:t.hiddenIfSettings},[e("back",{staticClass:"q-mr-md"}),e("div",{staticClass:"row justify-between items-center no-wrap"},[e("avatar",{staticClass:"col-shrink q-mr-lg gt-xxs",attrs:{icon:""}}),e("user-search",{staticClass:"col-grow"})],1)],1),t.settings?e("div",{staticClass:"col row justify-around items-center"},[e("div",{staticClass:"col-auto text-h4"},[t._v("My Anime")])]):e("div",{staticClass:"col row justify-end items-center no-wrap",on:{mousedown:function(t){t.preventDefault()}}},[e("provider-select",{ref:"providerSelect",staticClass:"col-auto q-mx-auto gt-xsm",attrs:{value:t.provider,dark:"",icon:""},on:{input:t.setProvider}}),e("div",{staticClass:"col-auto q-gutter-x-lg q-mr-auto row justify-between gt-md"},[e("status-select",{staticClass:"col-auto gt-md",attrs:{icon:"ondemand_video",caption:t.$t("animeStatusFilter"),options:t.config.airingStatuses},model:{value:t.airingStatusFilter,callback:function(e){t.airingStatusFilter=e},expression:"airingStatusFilter"}}),e("status-select",{staticClass:"col-auto gt-md",attrs:{icon:"tv",caption:t.$t("animeTypeFilter"),options:t.config.animeTypes},model:{value:t.typeFilter,callback:function(e){t.typeFilter=e},expression:"typeFilter"}})],1),e("div",{staticClass:"col-auto row justify-end q-gutter-x-md"},[e("calendar-button",{staticClass:"col-shrink gt-xs",attrs:{icon:""}})],1)],1),e("q-btn",{staticClass:"col-shrink q-ml-md q-py-xs",attrs:{id:"settings",flat:"",icon:"settings"},on:{click:function(e){t.settings=!t.settings},keydown:function(e){if(!e.type.indexOf("key")&&t._k(e.keyCode,"esc",27,e.key,["Esc","Escape"]))return null;t.settings=!1}}})],1),e("q-tabs",{staticClass:"text-grey-7",attrs:{"inline-label":"",align:"justify","active-color":"white","indicator-color":"transparent"},model:{value:t.status,callback:function(e){t.status=e},expression:"status"}},t._l(t.config.statuses,(function(s,a){return e("q-tab",{key:a,class:{"q-px-xs":t.$q.screen.lt.sm},attrs:{name:a,icon:s.icon,label:s.label}})})),1)],1),e("q-drawer",{staticClass:"text-white",attrs:{breakpoint:1152,side:"right","content-class":"bg-primary"},model:{value:t.settings,callback:function(e){t.settings=e},expression:"settings"}},[t.settings?e("q-list",{staticClass:"column justify-start full-height",attrs:{dark:""}},[e("avatar",{staticClass:"col-auto q-py-md",attrs:{size:"72px"}}),e("q-item-section",{staticClass:"col-auto"},[e("calendar-button")],1),e("div",{staticClass:"col row q-pt-lg"},[e("div",{staticClass:"col-auto full-width q-pl-md q-pr-sm"},[e("q-item-section",{staticClass:"q-pt-sm"},[e("q-item-label",{directives:[{name:"t",rawName:"v-t",value:"selectLanguage",expression:"'selectLanguage'"}],staticClass:"q-px-sm",attrs:{header:""}}),e("language-select",{staticClass:"q-pr-xs",model:{value:t.language,callback:function(e){t.language=e},expression:"language"}})],1),e("q-item-section",{staticClass:"q-pt-sm"},[e("q-item-label",{directives:[{name:"t",rawName:"v-t",value:"selectProvider",expression:"'selectProvider'"}],staticClass:"q-px-sm",attrs:{header:""}}),e("provider-select",{staticClass:"q-pr-xs",attrs:{value:t.provider,dark:"",icon:""},on:{input:t.setProvider}})],1),e("q-item-section",{staticClass:"q-pt-lg"},[e("q-item-label",{directives:[{name:"t",rawName:"v-t",value:"animeStatus",expression:"'animeStatus'"}],staticClass:"q-px-sm",attrs:{header:""}}),e("status-select",{staticClass:"q-pr-xs",attrs:{icon:"ondemand_video",options:t.config.airingStatuses},model:{value:t.airingStatusFilter,callback:function(e){t.airingStatusFilter=e},expression:"airingStatusFilter"}})],1),e("q-item-section",{staticClass:"q-pt-sm"},[e("q-item-label",{directives:[{name:"t",rawName:"v-t",value:"animeType",expression:"'animeType'"}],staticClass:"q-px-sm",attrs:{header:""}}),e("status-select",{staticClass:"q-pr-xs",attrs:{icon:"tv",options:t.config.animeTypes},model:{value:t.typeFilter,callback:function(e){t.typeFilter=e},expression:"typeFilter"}})],1),e("q-item-section",{staticClass:"q-pt-sm"},[e("q-item-label",{directives:[{name:"t",rawName:"v-t",value:"genre",expression:"'genre'"}],staticClass:"q-px-sm",attrs:{header:""}}),e("status-select",{staticClass:"q-pr-xs",attrs:{icon:"movie_filter","options-dense":"",and:"",options:t.config.genres},model:{value:t.genreFilter,callback:function(e){t.genreFilter=e},expression:"genreFilter"}})],1)],1),e("div",{staticClass:"col-auto row justify-center q-mt-auto full-width"},[e("reset-button",{staticClass:"col-auto full-width q-pb-sm"})],1)])],1):t._e()],1),e("q-page-container",[e("q-page",{staticClass:"gradient",attrs:{padding:"","style-fn":t.overlappingFooter}},[e("router-view")],1)],1),e("q-footer",{staticClass:"row justify-end fixed-bottom-right",on:{mousedown:function(t){t.preventDefault()}}},[e("q-btn",{staticClass:"square",attrs:{unelevated:"",color:"accent",icon:"description"},on:{click:function(e){t.info=!0}}},[e("q-tooltip",{attrs:{"transition-show":"fade","transition-hide":"fade","content-class":"bg-primary shadow-2"}},[t._v("\n "+t._s(t.$t("aboutApp"))+"\n ")])],1),e("q-dialog",{attrs:{"transition-show":"jump-up","transition-hide":"jump-down"},model:{value:t.info,callback:function(e){t.info=e},expression:"info"}},[e("about",{staticStyle:{width:"1000px","max-width":"80vw"}})],1),e("q-btn",{staticClass:"square",attrs:{square:"",unelevated:"",color:"accent",type:"a",icon:"fab fa-github",href:"https://github.com/Carleslc/MyAnime",target:"_blank"}}),e("support-me",{staticClass:"square q-px-sm"})],1)],1)},i=[],n=s("ded3"),o=s.n(n),r=(s("99af"),s("a861")),c={40:"down"};function l(t){return{mounted:function(){document.addEventListener("keydown",this.keyListener)},beforeDestroy:function(){document.removeEventListener("keydown",this.keyListener)},methods:{keyListener:function(e){var s=e.which||e.keyCode,a=c[s];if(a){var i=t[a];if(i){var n=this[i];n?n.call(this):console.error("Key listener ".concat(i," not found in component methods"))}}}}}}var u=s("2f62"),d={meta:{meta:{"og:image":{property:"og:image",content:"".concat(window.location.href,"/chibi.png")}}},mixins:[r["a"],l({down:"showProviderPopup"})],data:function(){return{settings:!1,info:!1}},computed:o()(o()(o()({},Object(u["e"])("store",["api","userFetched"])),Object(u["c"])("store",["provider","isLoading","isFetched","hasUsername"])),{},{hiddenIfSettings:function(){return this.settings?"display: none":void 0}}),watch:{status:function(){this.updateFetched()},isFetched:function(){this.updateAnimes()},isLoading:function(){this.updateAnimes()}},created:function(){this.info=!this.isRecurringUser},methods:o()(o()(o()({},Object(u["d"])("store",["setProvider","updateFetched"])),Object(u["b"])("store",["fetchAnimes"])),{},{overlappingFooter:function(t){var e="41px";return{minHeight:"calc(100vh - ".concat(t,"px + ").concat(e,")")}},updateAnimes:function(){!this.userFetched||this.isFetched||this.isLoading||!this.hasUsername||this.api.hasError||this.fetchAnimes()},showProviderPopup:function(){this.$refs.providerSelect.showPopup()}})},p=d,m=s("2877"),g=s("4d5a"),f=s("e359"),v=s("65c6"),h=s("9c40"),q=s("429b"),w=s("7460"),b=s("9404"),y=s("1c1c"),C=s("4074"),x=s("0170"),k=s("09e3"),F=s("9989"),j=s("7ff0"),S=s("05c0"),Q=s("24e8"),L=s("eebe"),P=s.n(L),_=Object(m["a"])(p,a,i,!1,null,null,null);e["default"]=_.exports;P()(_,"components",{QLayout:g["a"],QHeader:f["a"],QToolbar:v["a"],QBtn:h["a"],QTabs:q["a"],QTab:w["a"],QDrawer:b["a"],QList:y["a"],QItemSection:C["a"],QItemLabel:x["a"],QPageContainer:k["a"],QPage:F["a"],QFooter:j["a"],QTooltip:S["a"],QDialog:Q["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/2.ec70d99d.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[2],{"84ba":function(t,e,a){"use strict";a.r(e);a("c96a"),a("b0c0");var s=function(){var t=this,e=t._self._c;return e("q-card",{staticClass:"about"},[e("q-card-section",{staticClass:"row justify-center items-center q-pa-lg"},[e("h1",{staticClass:"text-center"},[t._v("MyAnime")]),e("div",{staticClass:"absolute-right"},[t.small?t._e():e("q-img",{staticClass:"q-ma-md",attrs:{src:"chibi.png",width:"96px"}})],1)]),e("q-separator"),e("q-card-section",{class:"q-py-lg ".concat(t.small?"q-px-lg":"q-px-xl")},[e("i18n",{staticClass:"q-mb-xs",attrs:{path:"about.description",tag:"p"},scopedSlots:t._u([{key:"api",fn:function(){return[e("a",{attrs:{href:t.api.homeUrl,target:"_blank"}},[t._v(t._s(t.api.name))])]},proxy:!0}])}),e("div",{staticClass:"section"},[e("h2",{directives:[{name:"t",rawName:"v-t",value:"about.why.header",expression:"'about.why.header'"}]}),e("div",{domProps:{innerHTML:t._s(t.p("about.why.content",{api:t.api.name}))}})]),e("div",{staticClass:"section"},[e("h2",{directives:[{name:"t",rawName:"v-t",value:"about.how.header",expression:"'about.how.header'"}]}),e("i18n",{attrs:{path:"about.how.enterYourUsername",tag:"p"},scopedSlots:t._u([{key:"api",fn:function(){return[t._v("\n "+t._s(t.api.name)+"\n ")]},proxy:!0},{key:"profileUrl",fn:function(){return[e("i",[t._v(t._s(t.api.profileUrl+"USERNAME"))])]},proxy:!0}])}),e("div",{domProps:{innerHTML:t._s(t.p("about.how.updateEpisode",{api:t.api.name}))}})],1),e("div",{staticClass:"section"},[e("h2",{directives:[{name:"t",rawName:"v-t",value:"about.features.header",expression:"'about.features.header'"}]}),e("ul",{staticClass:"q-gutter-y-sm",domProps:{innerHTML:t._s(t.li("about.features.list",{api:t.api.name}))}}),e("p",{directives:[{name:"t",rawName:"v-t",value:"about.features.note",expression:"'about.features.note'"}]})]),e("div",{staticClass:"section"},[e("h2",{directives:[{name:"t",rawName:"v-t",value:"about.providers.header",expression:"'about.providers.header'"}]}),e("ul",[e("li",[e("a",{attrs:{href:"https://myanimelist.net/",target:"_blank"}},[t._v("MyAnimeList")])]),e("li",[e("a",{attrs:{href:"https://www.crunchyroll.com/",target:"_blank"}},[t._v("Crunchyroll")])]),e("li",[e("a",{attrs:{href:"https://www.netflix.com/",target:"_blank"}},[t._v("Netflix")])]),e("li",[e("a",{attrs:{href:"https://www.google.com/",target:"_blank"}},[t._v("Google")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://9anime.to/",target:"_blank"}},[t._v("9Anime")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://zoro.to/",target:"_blank"}},[t._v("Zoro")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://yugen.to/",target:"_blank"}},[t._v("YugenAnime")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://gogoanime.bid/",target:"_blank"}},[t._v("GogoAnime")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://gogoanime.page/",target:"_blank"}},[t._v("GogoAnime.page")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://animesuge.to/",target:"_blank"}},[t._v("AnimeSuge")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://animepahe.ru/",target:"_blank"}},[t._v("animepahe")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://animeheaven.ru/",target:"_blank"}},[t._v("AnimeHeaven")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://animeflix.live/",target:"_blank"}},[t._v("Animeflix")])]),e("li",[t._v("🇬🇧 "),e("a",{attrs:{href:"https://duckduckgo.com/",target:"_blank"}},[t._v("I'm Feeling Lucky")])]),e("li",[t._v("🇪🇸 "),e("a",{attrs:{href:"https://duckduckgo.com/",target:"_blank"}},[t._v("Voy a tener suerte")])]),e("li",[t._v("🇪🇸 "),e("a",{attrs:{href:"https://www.animeflv.net",target:"_blank"}},[t._v("AnimeFlv")])]),e("li",[t._v("🇪🇸 "),e("a",{attrs:{href:"https://www.animeid.tv/",target:"_blank"}},[t._v("AnimeID")])]),e("li",[t._v("🇪🇸 "),e("a",{attrs:{href:"https://animefenix.tv/",target:"_blank"}},[t._v("AnimeFenix")])]),e("li",[t._v("🇪🇸 "),e("a",{attrs:{href:"http://jkanime.net/",target:"_blank"}},[t._v("jkanime")])]),e("li",[t._v("🇪🇸 "),e("a",{attrs:{href:"https://monoschinos2.com/",target:"_blank"}},[t._v("MonosChinos")])]),e("li",[t._v("🇪🇸 "),e("a",{attrs:{href:"https://animemovil2.com/",target:"_blank"}},[t._v("AnimeMovil")])])]),e("p",{domProps:{innerHTML:t._s(t.p("about.providers.note"))}})]),e("div",{staticClass:"section"},[e("h2",{directives:[{name:"t",rawName:"v-t",value:"about.contact.header",expression:"'about.contact.header'"}]}),e("p",[e("i18n",{attrs:{path:"about.contact.issue",tag:"i"},scopedSlots:t._u([{key:"issue",fn:function(){return[e("a",{attrs:{href:"http://github.com/Carleslc/MyAnime/issues",target:"_blank"}},[t._v(t._s(t.$t("here")))])]},proxy:!0}])})],1),e("p",{directives:[{name:"t",rawName:"v-t",value:"about.contact.note",expression:"'about.contact.note'"}],staticClass:"q-mb-none"})]),e("div",{staticClass:"section"},[e("i",[e("h2",{directives:[{name:"t",rawName:"v-t",value:"about.disclaimer.header",expression:"'about.disclaimer.header'"}]})]),e("div",{domProps:{innerHTML:t._s(t.p("about.disclaimer.content"))}})])],1),e("q-separator"),e("q-card-actions",{staticClass:"scroll q-pa-none",staticStyle:{"max-height":"80vh"},attrs:{align:"center"}},[e("q-btn",{directives:[{name:"close-popup",rawName:"v-close-popup"}],staticClass:"full-width q-pa-sm",attrs:{flat:"",label:"OK",color:"secondary"}})],1)],1)},r=[],i=a("ded3"),n=a.n(i),o=a("2f62"),l=a("002d"),c={computed:n()(n()({},Object(o["e"])("store",["api"])),{},{small:function(){return this.$q.screen.xs}}),methods:{p:function(t,e){return Object(l["b"])(this.$t(t,e),"p")},li:function(t,e){return Object(l["b"])(this.$t(t,e),"li")}}},p=c,h=(a("86c3"),a("2877")),u=a("f09f"),v=a("a370"),m=a("068f"),_=a("eb85"),d=a("4b7e"),b=a("9c40"),f=a("7f67"),g=a("eebe"),w=a.n(g),k=Object(h["a"])(p,s,r,!1,null,null,null);e["default"]=k.exports;w()(k,"components",{QCard:u["a"],QCardSection:v["a"],QImg:m["a"],QSeparator:_["a"],QCardActions:d["a"],QBtn:b["a"]}),w()(k,"directives",{ClosePopup:f["a"]})},"86c3":function(t,e,a){"use strict";a("b801")},b801:function(t,e,a){}}]);
--------------------------------------------------------------------------------
/dist/spa/js/20.0aeafe02.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[20],{"8b24":function(e,t,i){"use strict";i.r(t);var n=function(){var e=this,t=e._self._c;return t("q-infinite-scroll",{ref:"scroll",staticClass:"anime-container",attrs:{offset:1e3},on:{load:e.loadMoreAnimes},scopedSlots:e._u([{key:"loading",fn:function(){return[t("div",{staticClass:"column full-height justify-center items-center"},[t("q-spinner-dots",{attrs:{color:"primary",size:"64px"}})],1)]},proxy:!0}])},e._l(e.animesFilterByStatus,(function(i){return t("anime-episode",{key:i.title,attrs:{anime:i},on:{loaded:e.animeLoaded}})})),1)},s=[],a=i("ded3"),o=i.n(a),r=i("2f62"),l={data:function(){return{animeMounted:0}},computed:o()(o()({},Object(r["e"])("store",["status","api"])),Object(r["c"])("store",["animesFilterByStatus","isLoading","isFetched","hasUsername"])),watch:{animesFilterByStatus:function(){this.animesFilterByStatus.length>this.animeMounted&&this.animeLoading()},status:function(){this.animeMounted=0,this.$refs.scroll.reset()},isFetched:function(){this.isFetched&&this.$refs.scroll.reset()}},methods:o()(o()(o()({},Object(r["d"])("store",["loading","loaded"])),Object(r["b"])("store",["fetchMoreAnimes"])),{},{animeLoading:function(){this.loading(),this.animeMounted>=this.animesFilterByStatus.length&&(this.animeMounted=0)},animeLoaded:function(){this.animeMounted+=1,this.animeMounted===this.animesFilterByStatus.length&&(this.loaded(),this.$refs.scroll.resume())},loadMoreAnimes:function(e,t){var i=this.api.hasError||!this.hasUsername||!this.isFetched;i||this.isLoading?t(i):1===e?t():this.fetchMoreAnimes().then((function(e){t(!e)}))}})},c=l,d=i("2877"),u=i("ef35"),h=i("8380"),m=i("eebe"),f=i.n(m),p=Object(d["a"])(c,n,s,!1,null,null,null);t["default"]=p.exports;f()(p,"components",{QInfiniteScroll:u["a"],QSpinnerDots:h["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/3.f88a54a3.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[3],{"6e61":function(e,t,n){"use strict";n("f318")},f318:function(e,t,n){},ff1a:function(e,t,n){"use strict";n.r(t);var i=function(){var e=this,t=e._self._c;return t("q-item",{directives:[{name:"ripple",rawName:"v-ripple"}],staticClass:"non-selectable",class:{"icon-only":e.icon&&!e.label},attrs:{clickable:"",dense:e.dense,tag:e.isLink?"a":"span",href:e.isLink?e.href:void 0,target:e.isLink?e.target:void 0,draggable:!e.isLink&&void 0},on:{click:function(t){return e.$emit("click")}}},[e.icon?t("q-item-section",{attrs:{avatar:""}},[t("q-icon",{attrs:{name:e.icon}})],1):e._e(),e.label?t("q-item-section",[t("q-item-label",[e._v(e._s(e.label))]),e.caption?t("q-item-label",{attrs:{caption:""}},[e._v("\n "+e._s(e.caption)+"\n ")]):e._e()],1):e.caption?t("q-tooltip",{attrs:{"transition-show":"fade","transition-hide":"fade"}},[e._v("\n "+e._s(e.caption)+"\n ")]):e._e()],1)},a=[],o={props:{icon:{type:String,default:""},label:{type:String,default:""},href:{type:String,default:"#"},target:{type:String,default:"_self"},caption:{type:String,default:""},dense:{type:Boolean,default:!1}},computed:{isLink:function(){return"#"!==this.href}}},l=o,s=(n("6e61"),n("2877")),c=n("66e5"),r=n("4074"),p=n("0016"),f=n("0170"),u=n("05c0"),d=n("714f"),b=n("eebe"),m=n.n(b),v=Object(s["a"])(l,i,a,!1,null,null,null);t["default"]=v.exports;m()(v,"components",{QItem:c["a"],QItemSection:r["a"],QIcon:p["a"],QItemLabel:f["a"],QTooltip:u["a"]}),m()(v,"directives",{Ripple:d["a"]})}}]);
--------------------------------------------------------------------------------
/dist/spa/js/4.e5f9f4f4.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[4],{"995a":function(t,n,o){"use strict";o("f969")},e02e:function(t,n,o){"use strict";o.r(n);var e=function(){var t=this,n=t._self._c;return n("q-btn",{attrs:{type:"a",href:"https://ko-fi.com/carleslc",target:"_blank",color:"purple",label:"<3",icon:"img:kofi.png"}},[n("q-tooltip",{attrs:{"transition-show":"fade","transition-hide":"fade","content-class":"bg-primary shadow-2 no-scroll"}},[n("div",{domProps:{innerHTML:t._s(t.donate)}})])],1)},a=[],c=o("002d"),i={computed:{donate:function(){return Object(c["c"])(this.$t("donate"))}}},s=i,r=(o("995a"),o("2877")),l=o("9c40"),p=o("05c0"),u=o("0016"),d=o("eebe"),f=o.n(d),b=Object(r["a"])(s,e,a,!1,null,null,null);n["default"]=b.exports;f()(b,"components",{QBtn:l["a"],QTooltip:p["a"],QIcon:u["a"]})},f969:function(t,n,o){}}]);
--------------------------------------------------------------------------------
/dist/spa/js/5.f58dc392.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[5],{"1ce4":function(e,t,o){"use strict";o.r(t);var r=function(){var e=this,t=e._self._c;return t("q-select",{ref:"providerSelect",attrs:{dense:"",standout:!e.standout||e.standout,dark:e.dark,"options-selected-class":"filter-options","options-dark":!1,options:e.providers,"options-dense":e.optionsDense},on:{"popup-show":function(t){return e.focus()},"popup-hide":function(t){return e.focus(!1)}},scopedSlots:e._u([{key:"prepend",fn:function(){return[e.icon?t("q-icon",{attrs:{name:"cast"}}):t("q-avatar",{attrs:{square:"",size:"sm"}},[t("img",{attrs:{src:e.provider.value.icon}})]),e.tooltip?t("q-tooltip",{attrs:{"transition-show":"fade","transition-hide":"fade"}},[t("div",{domProps:{innerHTML:e._s(e.help)}})]):e._e()]},proxy:!0},{key:"option",fn:function(o){return[t("q-item",e._g(e._b({directives:[{name:"ripple",rawName:"v-ripple"}]},"q-item",o.itemProps,!1),o.itemEvents),[t("q-item-section",{attrs:{avatar:""}},[t("q-avatar",{attrs:{square:"",size:"sm"}},[t("img",{attrs:{src:o.opt.value.icon}})])],1),t("q-item-section",[t("q-item-label",[e._v(e._s(o.opt.label))])],1)],1)]}},e.provider&&e.icon?{key:"after",fn:function(){return[t("q-btn",{attrs:{flat:"",dense:"",type:"a",href:e.providerUrl,target:"_blank"},on:{click:e.openProvider}},[t("q-avatar",{attrs:{square:"",size:"sm"}},[t("img",{attrs:{src:e.provider.value.icon}})]),t("q-tooltip",{attrs:{"transition-show":"fade","transition-hide":"fade"}},[e._v("\n "+e._s(e.provider.label)+"\n ")])],1)]},proxy:!0}:null],null,!0),model:{value:e.provider,callback:function(t){e.provider=t},expression:"provider"}})},n=[],i=o("ded3"),s=o.n(i),a=o("b06b"),p=o("2f62"),c=o("002d"),u=o("c01e"),d={mixins:[Object(u["a"])("provider",Object)],props:{icon:{type:Boolean,default:!1},optionsDense:{type:Boolean,default:!1},dark:{type:Boolean,default:!1},tooltip:{type:Boolean,default:!0},standout:{type:String,default:void 0}},computed:s()(s()({},Object(p["c"])("store",["providers"])),{},{providerUrl:function(){return this.provider.value.url},help:function(){return Object(c["c"])(this.$t("providerSelect"))}}),methods:{openProvider:function(){var e=this.providerUrl;e?Object(a["a"])(e):this.showPopup()},showPopup:function(){this.$refs.providerSelect.showPopup()},focus:function(){var e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];this.$refs.providerSelect.focused=e}}},l=d,v=o("2877"),f=o("ddd8"),m=o("0016"),h=o("cb32"),b=o("05c0"),q=o("66e5"),w=o("4074"),k=o("0170"),y=o("9c40"),_=o("714f"),g=o("eebe"),Q=o.n(g),S=Object(v["a"])(l,r,n,!1,null,null,null);t["default"]=S.exports;Q()(S,"components",{QSelect:f["a"],QIcon:m["a"],QAvatar:h["a"],QTooltip:b["a"],QItem:q["a"],QItemSection:w["a"],QItemLabel:k["a"],QBtn:y["a"]}),Q()(S,"directives",{Ripple:_["a"]})},c01e:function(e,t,o){"use strict";function r(e,t){var o={props:{value:{type:t,required:!0}},computed:{}};return o.computed[e]={get:function(){return this.value},set:function(e){this.$emit("input",e)}},o}o.d(t,"a",(function(){return r}))}}]);
--------------------------------------------------------------------------------
/dist/spa/js/6.31eb4eef.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[6],{"8bcf":function(e,t,n){"use strict";n.r(t);n("a15b"),n("d81d"),n("4de4"),n("d3b7");var o=function(){var e=this,t=e._self._c;return t("q-select",{ref:"statusSelect",attrs:{multiple:"",dense:"",standout:"",dark:"","emit-value":"","options-dense":e.optionsDense,"options-dark":!1,options:e.options,"popup-content-class":"filter-options"},on:{"popup-show":function(t){return e.focus()},"popup-hide":function(t){return e.focus(!1)}},scopedSlots:e._u([{key:"prepend",fn:function(){return[e.icon?t("q-icon",{attrs:{name:e.icon}}):e._e(),e.caption?t("q-tooltip",{attrs:{"transition-show":"fade","transition-hide":"fade"}},[e._v("\n "+e._s(e.caption)+"\n ")]):e._e()]},proxy:!0},e.selected.length===e.allSelected?{key:"selected",fn:function(){return[e._v("\n "+e._s(e.$t(e.allTag))+"\n ")]},proxy:!0}:{key:"selected",fn:function(){return[e._v("\n "+e._s(e.options.filter(e.isSelected).map(e.label).join(", "))+"\n ")]},proxy:!0}],null,!0),model:{value:e.selected,callback:function(t){e.selected=t},expression:"selected"}})},s=[],i=(n("caad"),n("2532"),n("c01e")),c={mixins:[Object(i["a"])("selected",Array)],props:{options:{type:Array,required:!0},optionsDense:{type:Boolean,default:!1},icon:{type:String,default:""},caption:{type:String,default:""},and:{type:Boolean,default:!1}},computed:{allSelected:function(){return this.and?0:this.options.length},allTag:function(){return this.and?"any":"all"}},methods:{isSelected:function(e){return this.selected.includes(this.optValue(e))},optValue:function(e){return e instanceof Object?e.value:e},label:function(e){return e instanceof Object?e.label:e},focus:function(){var e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];this.$refs.statusSelect.focused=e}}},u=c,l=n("2877"),a=n("ddd8"),r=n("0016"),p=n("05c0"),d=n("eebe"),f=n.n(d),h=Object(l["a"])(u,o,s,!1,null,null,null);t["default"]=h.exports;f()(h,"components",{QSelect:a["a"],QIcon:r["a"],QTooltip:p["a"]})},c01e:function(e,t,n){"use strict";function o(e,t){var n={props:{value:{type:t,required:!0}},computed:{}};return n.computed[e]={get:function(){return this.value},set:function(e){this.$emit("input",e)}},n}n.d(t,"a",(function(){return o}))}}]);
--------------------------------------------------------------------------------
/dist/spa/js/7.c01ad7d9.js:
--------------------------------------------------------------------------------
1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[7],{"5f93":function(t,e,n){"use strict";n.r(e);var o=function(){var t=this,e=t._self._c;return e("q-select",{ref:"titleSelect",attrs:{dense:"","options-dense":"",standout:"filter-options","options-selected-class":"filter-options","options-dark":!1,options:t.titles},on:{"popup-show":function(e){return t.focus()},"popup-hide":function(e){return t.focus(!1)}},scopedSlots:t._u([{key:"prepend",fn:function(){return[e("q-icon",{attrs:{name:"list_alt"}})]},proxy:!0}]),model:{value:t.title,callback:function(e){t.title=e},expression:"title"}})},i=[],s=n("c01e"),u={mixins:[Object(s["a"])("title",String)],props:{titles:{type:Array,required:!0}},methods:{focus:function(){var t=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];this.$refs.titleSelect.focused=t}}},r=u,c=n("2877"),l=n("ddd8"),p=n("0016"),a=n("eebe"),f=n.n(a),d=Object(c["a"])(r,o,i,!1,null,null,null);e["default"]=d.exports;f()(d,"components",{QSelect:l["a"],QIcon:p["a"]})},c01e:function(t,e,n){"use strict";function o(t,e){var n={props:{value:{type:e,required:!0}},computed:{}};return n.computed[t]={get:function(){return this.value},set:function(t){this.$emit("input",t)}},n}n.d(e,"a",(function(){return o}))}}]);
--------------------------------------------------------------------------------
/dist/spa/kofi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/kofi.png
--------------------------------------------------------------------------------
/dist/spa/mal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/mal.png
--------------------------------------------------------------------------------
/dist/spa/old/FontSiteSans-Cond-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/old/FontSiteSans-Cond-webfont.woff
--------------------------------------------------------------------------------
/dist/spa/old/background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/old/background.jpg
--------------------------------------------------------------------------------
/dist/spa/old/http.js:
--------------------------------------------------------------------------------
1 | function Options(t){this.apply=function(n){t(n)}}function fetch(t,n,e,o,r){let s={url:n,type:t,cache:!1,success:function(t,n,o){e(o.responseText,o.status,t)},error:function(t,n,e){o&&o(t.responseText,t.status)}};return r&&r.apply(s),$.ajax(s)}function get(t,n,e,o){return fetch("GET",t,n,e,o)}function cors(t,n,e,o,r){return fetch(t,`https://cors-anywhere.herokuapp.com/${n}`,e,o,new Options((t=>{addRequestHeader(t,"Access-Control-Allow-Origin","*"),t.crossDomain=!0})).and(r))}function getCors(t,n,e,o){return cors("GET",t,n,e,o)}var post,postCors;function addRequestHeader(t,n,e){let o=t.beforeSend;t.beforeSend=function(t){o&&o(t),t.setRequestHeader(n,e)}}function contentType(t){return new Options((n=>{addRequestHeader(n,"Content-Type",t)}))}function auth(t,n){return new Options((e=>{addRequestHeader(e,"Authorization","Basic "+btoa(t+":"+n))}))}Options.prototype.and=function(t){let n=this;return t?new Options((e=>{n.apply(e),t.apply(e)})):n},function(){function t(t,n,e,o,r,s){return t("POST",n,o,r,new Options((t=>{t.data=e})).and(s))}post=function(n,e,o,r,s){return t(fetch,n,e,o,r,s)},postCors=function(n,e,o,r,s){return t(cors,n,e,o,r,s)}}();
--------------------------------------------------------------------------------
/dist/spa/old/load-fig.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/old/load-fig.gif
--------------------------------------------------------------------------------
/dist/spa/old/mal.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/dist/spa/old/mal.jpg
--------------------------------------------------------------------------------
/dist/spa/old/storage.js:
--------------------------------------------------------------------------------
1 | var storage;(function(){function t(){this.get=function(t){return localStorage.getItem(t)},this.with=function(t,e){let n=this.get(t);null!==n&&e(n)},this.set=function(t,e){localStorage.setItem(t,e)},this.remove=function(t){localStorage.removeItem(t)}}function e(){this.mem={},this.get=function(t){var e=this.mem[t];return void 0!==e?e:null},this.with=function(t,e){let n=this.get(t);null!==n&&e(n)},this.set=function(t,e){this.mem[t]=e},this.remove=function(t){this.mem[t]=void 0}}storage="undefined"!==typeof Storage?new t:new e})();
--------------------------------------------------------------------------------
/dist/spa/old/utils.js:
--------------------------------------------------------------------------------
1 | function pad(e,t){let n=String(e);while(n.length<(t||2))n="0"+n;return n}function now(){return luxon.DateTime.fromJSDate(new Date)}function formatDate(e){let t=e.weekdayLong,n=e.toLocaleString(luxon.DateTime.DATE_FULL);return`${t} ${n}`}function formatDateTime(e){let t=e.toLocaleString(luxon.DateTime.TIME_24_SIMPLE);return`${formatDate(e)} ~${t}h`}function formatTodayRaw(){let e=new Date;return`${pad(e.getMonth()+1)}${pad(e.getDate())}${e.getFullYear()}`}function asUrl(e,t,n){return t&&(e=`${e}-${t}`),e=e.toLowerCase(),(n||provider)in["gogoanime"]&&(e=e.replace(/;/g,"")),e=e.replace(/[^-a-z0-9]+/g,"-").replace(/-{2,}/,"-"),encodeURIComponent(e)}function idify(e,t){return asUrl(e,null,t||"none")}
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "vueCompilerOptions": {
3 | "target": 2,
4 | "extensions": [".vue"]
5 | },
6 | "compilerOptions": {
7 | "baseUrl": ".",
8 | "paths": {
9 | "src/*": ["src/*"],
10 | "app/*": ["*"],
11 | "components/*": ["src/components/*"],
12 | "layouts/*": ["src/layouts/*"],
13 | "pages/*": ["src/pages/*"],
14 | "assets/*": ["src/assets/*"],
15 | "boot/*": ["src/boot/*"],
16 | "vue$": ["node_modules/vue/dist/vue.esm.js"]
17 | }
18 | },
19 | "exclude": ["dist", ".quasar", "node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/old/FontSiteSans-Cond-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/old/FontSiteSans-Cond-webfont.woff
--------------------------------------------------------------------------------
/old/background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/old/background.jpg
--------------------------------------------------------------------------------
/old/http.js:
--------------------------------------------------------------------------------
1 | function Options(fOpts) {
2 | this.apply = function(ajaxOpts) {
3 | fOpts(ajaxOpts);
4 | };
5 | }
6 |
7 | Options.prototype.and = function(opts) {
8 | let self = this;
9 | return opts ? new Options(ajaxOpts => {
10 | self.apply(ajaxOpts);
11 | opts.apply(ajaxOpts);
12 | }) : self;
13 | }
14 |
15 | function fetch(method, url, success, error, opts) {
16 | //console.log(`${method} ${url}`);
17 | let sendOpts = {
18 | url: url,
19 | type: method,
20 | cache: false,
21 | success: function(response, textStatus, xhr) {
22 | //console.log(`Success (${url})`);
23 | success(xhr.responseText, xhr.status, response);
24 | },
25 | error: function(xhr, textStatus, errorThrown) {
26 | if (error) {
27 | error(xhr.responseText, xhr.status);
28 | }
29 | }
30 | };
31 | if (opts) {
32 | opts.apply(sendOpts);
33 | }
34 | return $.ajax(sendOpts);
35 | }
36 |
37 | function get(url, success, error, opts) {
38 | return fetch('GET', url, success, error, opts);
39 | }
40 |
41 | function cors(method, url, success, error, opts) {
42 | return fetch(method, `https://cors-anywhere.herokuapp.com/${url}`, success, error, new Options(ajaxOpts => {
43 | addRequestHeader(ajaxOpts, "Access-Control-Allow-Origin", "*");
44 | ajaxOpts.crossDomain = true;
45 | }).and(opts));
46 | }
47 |
48 | function getCors(url, success, error, opts) {
49 | return cors('GET', url, success, error, opts);
50 | }
51 |
52 | var post, postCors;
53 |
54 | (function() {
55 | function _post(base, url, data, success, error, opts) {
56 | return base('POST', url, success, error, new Options(ajaxOpts => {
57 | ajaxOpts.data = data;
58 | }).and(opts));
59 | }
60 |
61 | post = function(url, data, success, error, opts) {
62 | return _post(fetch, url, data, success, error, opts);
63 | }
64 |
65 | postCors = function(url, data, success, error, opts) {
66 | return _post(cors, url, data, success, error, opts);
67 | }
68 | })();
69 |
70 | function addRequestHeader(ajaxOpts, name, value) {
71 | let customBeforeSend = ajaxOpts.beforeSend;
72 | ajaxOpts.beforeSend = function(xhr) {
73 | if (customBeforeSend) {
74 | customBeforeSend(xhr);
75 | }
76 | xhr.setRequestHeader(name, value);
77 | };
78 | }
79 |
80 | function contentType(type) {
81 | return new Options(ajaxOpts => {
82 | addRequestHeader(ajaxOpts, "Content-Type", type);
83 | });
84 | }
85 |
86 | function auth(user, password) {
87 | return new Options(ajaxOpts => {
88 | addRequestHeader(ajaxOpts, "Authorization", "Basic " + btoa(user + ":" + password));
89 | });
90 | }
--------------------------------------------------------------------------------
/old/load-fig.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/old/load-fig.gif
--------------------------------------------------------------------------------
/old/mal.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/old/mal.jpg
--------------------------------------------------------------------------------
/old/storage.js:
--------------------------------------------------------------------------------
1 | var storage;
2 |
3 | (function() {
4 | function LocalStorage() {
5 | this.get = function(tag) {
6 | return localStorage.getItem(tag);
7 | };
8 | this.with = function(tag, callback) {
9 | let value = this.get(tag);
10 | if (value !== null) {
11 | callback(value);
12 | }
13 | };
14 | this.set = function(tag, value) {
15 | localStorage.setItem(tag, value);
16 | };
17 | this.remove = function(tag) {
18 | localStorage.removeItem(tag);
19 | };
20 | }
21 |
22 | function MemStorage() {
23 | this.mem = {};
24 | this.get = function(tag) {
25 | var value = this.mem[tag];
26 | return value !== undefined ? value : null;
27 | };
28 | this.with = function(tag, callback) {
29 | let value = this.get(tag);
30 | if (value !== null) {
31 | callback(value);
32 | }
33 | };
34 | this.set = function(tag, value) {
35 | this.mem[tag] = value;
36 | };
37 | this.remove = function(tag) {
38 | this.mem[tag] = undefined;
39 | };
40 | }
41 |
42 | storage = typeof(Storage) !== "undefined" ? new LocalStorage() : new MemStorage();
43 | })();
--------------------------------------------------------------------------------
/old/utils.js:
--------------------------------------------------------------------------------
1 | function pad(n, size) {
2 | let s = String(n);
3 | while (s.length < (size || 2)) {
4 | s = "0" + s;
5 | }
6 | return s;
7 | }
8 |
9 | function now() {
10 | return luxon.DateTime.fromJSDate(new Date());
11 | }
12 |
13 | function formatDate(luxonDate) {
14 | let weekday = luxonDate.weekdayLong;
15 | let date = luxonDate.toLocaleString(luxon.DateTime.DATE_FULL);
16 | return `${weekday} ${date}`;
17 | }
18 |
19 | function formatDateTime(luxonDate) {
20 | let time = luxonDate.toLocaleString(luxon.DateTime.TIME_24_SIMPLE);
21 | return `${formatDate(luxonDate)} ~${time}h`;
22 | }
23 |
24 | function formatTodayRaw() {
25 | let now = new Date();
26 | return `${pad(now.getMonth() + 1)}${pad(now.getDate())}${now.getFullYear()}`;
27 | }
28 |
29 | function asUrl(s, append, prov) {
30 | if (append) {
31 | s = `${s}-${append}`;
32 | }
33 | s = s.toLowerCase();
34 | if ((prov || provider) in ["gogoanime"]) {
35 | s = s.replace(/;/g, '');
36 | }
37 | s = s.replace(/[^-a-z0-9]+/g, '-').replace(/-{2,}/, '-');
38 | return encodeURIComponent(s);
39 | }
40 |
41 | function idify(s, prov) {
42 | return asUrl(s, null, prov || 'none');
43 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-anime",
3 | "version": "1.0",
4 | "description": "Watch your favourite animes with your usual provider, synchronized with MyAnimeList.",
5 | "productName": "MyAnime",
6 | "author": "Carleslc ",
7 | "private": true,
8 | "scripts": {
9 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore ./",
10 | "test": "echo \"No test specified\" && exit 0"
11 | },
12 | "dependencies": {
13 | "@quasar/extras": "^1.12.4",
14 | "axios": "^0.21.2",
15 | "copy-webpack-plugin": "^6.0.3",
16 | "core-js": "^3.0.0",
17 | "eslint-plugin-prettier": "^3.1.3",
18 | "luxon": "^1.24.1",
19 | "prettier": "^2.0.5",
20 | "qs": "^6.9.4",
21 | "quasar": "^1.22.5",
22 | "vue-i18n": "^8.0.0",
23 | "vuex-map-fields": "^1.4.0",
24 | "webpack": "^4.37.0"
25 | },
26 | "devDependencies": {
27 | "@quasar/app": "^2.4.3",
28 | "babel-eslint": "^10.0.1",
29 | "eslint": "^6.8.0",
30 | "eslint-config-airbnb-base": "^14.0.0",
31 | "eslint-config-prettier": "^6.11.0",
32 | "eslint-loader": "^3.0.3",
33 | "eslint-plugin-import": "^2.20.1",
34 | "eslint-plugin-vue": "^6.1.2"
35 | },
36 | "engines": {
37 | "node": ">= 10.18.1",
38 | "npm": ">= 6.13.4",
39 | "yarn": ">= 1.21.1"
40 | },
41 | "browserslist": [
42 | "last 10 versions, not dead, ie >= 11"
43 | ]
44 | }
45 |
--------------------------------------------------------------------------------
/public/chibi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/chibi.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/favicon.ico
--------------------------------------------------------------------------------
/public/icons/animeflv.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/animeflv.ico
--------------------------------------------------------------------------------
/public/icons/animepahe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/animepahe.png
--------------------------------------------------------------------------------
/public/icons/crunchyroll.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/crunchyroll.png
--------------------------------------------------------------------------------
/public/icons/favicon-128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/favicon-128x128.png
--------------------------------------------------------------------------------
/public/icons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/favicon-16x16.png
--------------------------------------------------------------------------------
/public/icons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/favicon-32x32.png
--------------------------------------------------------------------------------
/public/icons/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/favicon-96x96.png
--------------------------------------------------------------------------------
/public/icons/monoschinos.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/monoschinos.ico
--------------------------------------------------------------------------------
/public/icons/youtube.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/icons/youtube.png
--------------------------------------------------------------------------------
/public/kofi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/kofi.png
--------------------------------------------------------------------------------
/public/mal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/public/mal.png
--------------------------------------------------------------------------------
/quasar.conf.js:
--------------------------------------------------------------------------------
1 | /*
2 | * This file runs in a Node context (it's NOT transpiled by Babel), so use only
3 | * the ES6 features that are supported by your Node version. https://node.green/
4 | */
5 |
6 | // Configuration for your app
7 | // https://quasar.dev/quasar-cli/quasar-conf-js
8 | /* eslint-env node */
9 | /* eslint func-names: 0 */
10 | /* eslint global-require: 0 */
11 |
12 | const CopyWebpackPlugin = require('copy-webpack-plugin');
13 |
14 | module.exports = function (ctx) {
15 | return {
16 | // app boot file (/src/boot)
17 | // --> boot files are part of "main.js"
18 | // https://quasar.dev/quasar-cli/cli-documentation/boot-files
19 | boot: ['i18n', 'components'],
20 |
21 | // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css
22 | css: ['app.scss'],
23 |
24 | // https://github.com/quasarframework/quasar/tree/dev/extras
25 | extras: [
26 | // 'ionicons-v4',
27 | // 'mdi-v5',
28 | // 'eva-icons',
29 | // 'themify',
30 | // 'line-awesome',
31 |
32 | 'roboto-font',
33 | 'material-icons',
34 | 'fontawesome-v5',
35 | ],
36 |
37 | // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-framework
38 | framework: {
39 | iconSet: 'material-icons', // Quasar icon set
40 | lang: 'en-us', // Quasar language pack
41 |
42 | // Possible values for "all":
43 | // * 'auto' - Auto-import needed Quasar components & directives
44 | // (slightly higher compile time; next to minimum bundle size; most convenient)
45 | // * 'all' - Import everything from Quasar
46 | // (not treeshaking Quasar; biggest bundle size; convenient)
47 | importStrategy: 'auto',
48 |
49 | components: [],
50 | directives: [],
51 |
52 | // Quasar plugins
53 | plugins: ['LocalStorage', 'Notify', 'Meta'],
54 | },
55 |
56 | // https://quasar.dev/quasar-cli/cli-documentation/supporting-ts
57 | supportTS: false,
58 |
59 | // https://quasar.dev/quasar-cli/cli-documentation/prefetch-feature
60 | // preFetch: true
61 |
62 | // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-build
63 | build: {
64 | vueRouterMode: 'history', // available values: 'hash', 'history'
65 | publicPath: '', // MyAnime in gh-pages, empty (/) in Netlify
66 |
67 | // rtl: false, // https://quasar.dev/options/rtl-support
68 | // showProgress: false,
69 | // gzip: true,
70 | // analyze: true,
71 |
72 | // Options below are automatically set depending on the env, set them if you want to override
73 | // extractCSS: false,
74 |
75 | // https://quasar.dev/quasar-cli/cli-documentation/handling-webpack
76 | extendWebpack(cfg) {
77 | cfg.module.rules.push({
78 | enforce: 'pre',
79 | test: /\.(js|vue)$/,
80 | loader: 'eslint-loader',
81 | exclude: /node_modules/,
82 | options: {
83 | formatter: require('eslint').CLIEngine.getFormatter('stylish'),
84 | },
85 | });
86 | cfg.resolve.alias = {
87 | ...cfg.resolve.alias,
88 | '@': require('path').resolve(__dirname, 'src'),
89 | };
90 | if (ctx.prod) {
91 | cfg.plugins.push(
92 | new CopyWebpackPlugin({
93 | patterns: [{ from: 'old', to: 'old' }],
94 | })
95 | );
96 | }
97 | },
98 | },
99 |
100 | // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer
101 | devServer: {
102 | https: false,
103 | port: 8080,
104 | open: true, // opens browser window automatically
105 | },
106 |
107 | // animations: 'all', // --- includes all animations
108 | // https://quasar.dev/options/animations
109 | animations: [],
110 |
111 | // https://quasar.dev/quasar-cli/developing-ssr/configuring-ssr
112 | ssr: {
113 | pwa: false,
114 | },
115 |
116 | // https://quasar.dev/quasar-cli/developing-pwa/configuring-pwa
117 | pwa: {
118 | workboxPluginMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
119 | workboxOptions: {}, // only for GenerateSW
120 | manifest: {
121 | name: 'My Anime',
122 | short_name: 'MyAnime',
123 | description: 'Watch your favourite animes with your usual provider, synchronized with MyAnimeList.',
124 | display: 'standalone',
125 | orientation: 'portrait',
126 | background_color: '#ffffff',
127 | theme_color: '#027be3',
128 | icons: [
129 | {
130 | src: 'icons/icon-128x128.png',
131 | sizes: '128x128',
132 | type: 'image/png',
133 | },
134 | {
135 | src: 'icons/icon-192x192.png',
136 | sizes: '192x192',
137 | type: 'image/png',
138 | },
139 | {
140 | src: 'icons/icon-256x256.png',
141 | sizes: '256x256',
142 | type: 'image/png',
143 | },
144 | {
145 | src: 'icons/icon-384x384.png',
146 | sizes: '384x384',
147 | type: 'image/png',
148 | },
149 | {
150 | src: 'icons/icon-512x512.png',
151 | sizes: '512x512',
152 | type: 'image/png',
153 | },
154 | ],
155 | },
156 | },
157 |
158 | // Full list of options: https://quasar.dev/quasar-cli/developing-cordova-apps/configuring-cordova
159 | cordova: {
160 | // noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
161 | id: 'me.carleslc.my-anime',
162 | },
163 |
164 | // Full list of options: https://quasar.dev/quasar-cli/developing-capacitor-apps/configuring-capacitor
165 | capacitor: {
166 | hideSplashscreen: true,
167 | },
168 |
169 | // Full list of options: https://quasar.dev/quasar-cli/developing-electron-apps/configuring-electron
170 | electron: {
171 | bundler: 'packager', // 'packager' or 'builder'
172 |
173 | packager: {
174 | // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
175 | // OS X / Mac App Store
176 | // appBundleId: '',
177 | // appCategoryType: '',
178 | // osxSign: '',
179 | // protocol: 'myapp://path',
180 | // Windows only
181 | // win32metadata: { ... }
182 | },
183 |
184 | builder: {
185 | // https://www.electron.build/configuration/configuration
186 |
187 | appId: 'my-anime',
188 | },
189 |
190 | // More info: https://quasar.dev/quasar-cli/developing-electron-apps/node-integration
191 | nodeIntegration: true,
192 |
193 | extendWebpack(/* cfg */) {
194 | // do something with Electron main process Webpack cfg
195 | // chainWebpack also available besides this extendWebpack
196 | },
197 | },
198 | };
199 | };
200 |
--------------------------------------------------------------------------------
/redirect/README.md:
--------------------------------------------------------------------------------
1 | ## Deploy to GitHub Pages
2 |
3 | ```sh
4 | git subtree push --prefix redirect origin gh-pages
5 | ```
6 |
--------------------------------------------------------------------------------
/redirect/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Carleslc/MyAnime/3ced878014522daf4e76dcbdb8462bbff3c57900/redirect/favicon.ico
--------------------------------------------------------------------------------
/redirect/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | MyAnime - Redirecting...
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/redirect/style.css:
--------------------------------------------------------------------------------
1 | body, html {
2 | height: 100%;
3 | margin: 0;
4 | line-height: 1.5;
5 | background-color: #2d1e53;
6 | }
7 |
8 | .container {
9 | min-height: 100%;
10 | background-image: linear-gradient(#2d1e53, #e242bd);
11 | font-family: Roboto, Helvetica, Arial, sans-serif;
12 | color: white;
13 | }
14 |
15 | .container p,
16 | .container a {
17 | font-size: 1.25em;
18 | }
19 |
20 | .center {
21 | display: flex;
22 | justify-content: center;
23 | align-items: center;
24 | text-align: center;
25 | }
26 |
27 | a#new-site {
28 | color: #f2c037;
29 | font-weight: bold;
30 | text-decoration: none;
31 | }
32 |
33 | a#new-site:hover {
34 | text-decoration: underline;
35 | text-decoration-color: #e242bd;
36 | }
37 |
38 | .logo {
39 | margin-top: 2rem;
40 | }
41 |
42 | .logo img {
43 | width: 10rem;
44 | }
45 |
46 | .footer {
47 | position: fixed;
48 | bottom: 0;
49 | left: 50%;
50 | transform: translate(-50%, -50%);
51 | }
52 |
53 | .text-redirect {
54 | color: #5e3175;
55 | }
56 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/src/README.md:
--------------------------------------------------------------------------------
1 | # MyAnime (my-anime)
2 |
3 | Watch your favourite animes synchronized with your usual provider.
4 |
5 | ## Install the dependencies
6 |
7 | ```bash
8 | yarn
9 | ```
10 |
11 | ## Start the app in development mode (hot-code reloading, error reporting, etc.)
12 |
13 | ### `nvm use` (Node v16.19)
14 |
15 | ```bash
16 | quasar dev
17 | ```
18 |
19 | ### Node >= v17
20 |
21 | Set before `quasar dev`:
22 |
23 | ```bash
24 | export NODE_OPTIONS=--openssl-legacy-provider
25 | ```
26 |
27 | ## Lint the files
28 |
29 | ```bash
30 | yarn run lint
31 | ```
32 |
33 | ## Build the app for production
34 |
35 | ```bash
36 | quasar build
37 | ```
38 |
39 | ## Serve production app
40 |
41 | ```bash
42 | quasar serve -o --history dist/spa
43 | ```
44 |
45 | ## Upgrade Quasar
46 |
47 | ```bash
48 | quasar upgrade -i
49 | ```
50 |
51 | ### Customize the configuration
52 |
53 | See [Configuring quasar.conf.js](https://quasar.dev/quasar-cli/quasar-conf-js).
54 |
--------------------------------------------------------------------------------
/src/api/Backend.js:
--------------------------------------------------------------------------------
1 | import { newAxios } from './API';
2 |
3 | const API_URL = 'https://anime.carleslc.me';
4 |
5 | export default class Backend {
6 | constructor() {
7 | this.axios = newAxios({
8 | baseUrl: API_URL,
9 | });
10 | }
11 |
12 | get(endpoint, headers) {
13 | return this.axios.get(endpoint, headers);
14 | }
15 |
16 | async getCalendar() {
17 | const response = await this.get('/calendar');
18 |
19 | if (response.status !== 200 || !response.data.airingAnimes) {
20 | return {};
21 | }
22 |
23 | const airingAnimes = response.data.airingAnimes;
24 |
25 | const calendar = {};
26 |
27 | airingAnimes.forEach((airingAnime) => {
28 | let anime = calendar[airingAnime.title];
29 | if (!anime) {
30 | anime = {};
31 | calendar[airingAnime.title] = anime;
32 | }
33 | anime[airingAnime.episode] = airingAnime.date;
34 | });
35 |
36 | /*
37 | {
38 | "One Piece": {
39 | "835": "2018-05-06T00:30:00Z",
40 | ...
41 | },
42 | ...
43 | }
44 | */
45 | return calendar;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/api/MALSync.js:
--------------------------------------------------------------------------------
1 | import { newAxios } from './API';
2 |
3 | const API_URL = 'https://api.malsync.moe/';
4 |
5 | class MALSync {
6 | constructor() {
7 | this.axios = newAxios({
8 | baseUrl: API_URL,
9 | cors: true,
10 | });
11 | }
12 |
13 | async getAnimeSite(site, animeId, title, type = 'mal') {
14 | let response;
15 | try {
16 | response = await this.axios.get(`${type}/anime/${animeId}`);
17 | } catch (e) {
18 | console.error('MALSync', e);
19 | return null;
20 | }
21 | const data = response && response.data;
22 |
23 | if (data && data.Sites) {
24 | const siteSync = data.Sites[site];
25 |
26 | if (siteSync) {
27 | const entries = Object.entries(siteSync);
28 |
29 | let match;
30 |
31 | if (entries.length > 1) {
32 | let i = 0;
33 |
34 | while (!match && i < entries.length) {
35 | const entry = entries[i];
36 | const value = entry[1];
37 |
38 | if (value.title === title) {
39 | match = entry;
40 | }
41 |
42 | i += 1;
43 | }
44 | }
45 |
46 | if (!match && entries.length > 0) {
47 | match = entries[0];
48 | }
49 |
50 | if (match) {
51 | const key = match[0];
52 | const value = match[1];
53 |
54 | return {
55 | id: value.identifier || key,
56 | url: value.url,
57 | title: value.title,
58 | };
59 | }
60 | }
61 | }
62 |
63 | return undefined;
64 | }
65 | }
66 |
67 | export default new MALSync();
68 |
--------------------------------------------------------------------------------
/src/api/MyAnimeList.js:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon';
2 | import { i18n } from '@/boot/i18n';
3 | import { Anime } from '@/model/Anime';
4 | import { notifyError } from '@/utils/errors';
5 | import { API, encodeParams } from './API';
6 |
7 | function parseAnimes(animes) {
8 | return animes.map((data) => {
9 | const anime = data.node;
10 |
11 | const fields = {
12 | id: anime.id,
13 | title: anime.title,
14 | alternativeTitles: anime.alternative_titles,
15 | cover: anime.main_picture.medium,
16 | status: anime.my_list_status.status.replace(/_/g, '-'),
17 | type: anime.media_type.toLowerCase(),
18 | genres: anime.genres.map((genre) => genre.name.toLowerCase()),
19 | lastWatchedEpisode: anime.my_list_status.num_episodes_watched,
20 | startDate: anime.start_date,
21 | updatedAt: anime.my_list_status.updated_at,
22 | };
23 |
24 | if (anime.num_episodes > 0) {
25 | fields.totalEpisodes = anime.num_episodes;
26 | }
27 |
28 | if (anime.status) {
29 | fields.airingStatus = anime.status.replace(/_/g, ' ');
30 | }
31 |
32 | if (anime.broadcast) {
33 | fields.broadcast = {
34 | weekday: anime.broadcast.day_of_the_week,
35 | time: anime.broadcast.start_time,
36 | };
37 | }
38 |
39 | return new Anime(fields);
40 | });
41 | }
42 |
43 | const client = '6114d00ca681b7701d1e15fe11a4987e';
44 |
45 | export class MyAnimeList extends API {
46 | constructor() {
47 | super({
48 | name: 'MyAnimeList',
49 | image: 'mal.png',
50 | homeUrl: 'https://myanimelist.net/',
51 | profileUrl: 'https://myanimelist.net/profile/',
52 | registerUrl: 'https://myanimelist.net/register.php',
53 | setPasswordUrl: 'https://myanimelist.net/editprofile.php?go=myoptions',
54 | baseUrl: 'https://api.myanimelist.net',
55 | version: 'v2',
56 | headers: {
57 | 'X-MAL-Client-ID': client,
58 | },
59 | cors: true,
60 | });
61 | }
62 |
63 | animeUrl(anime) {
64 | return `${this.homeUrl}anime/${anime.id}/`;
65 | }
66 |
67 | async auth(username, password) {
68 | const response = await this.postFormEncoded('/auth/token', {
69 | client_id: client,
70 | grant_type: 'password',
71 | username,
72 | password,
73 | });
74 | if (response.ok && response.data) {
75 | this.updateAuthInfo(response.data);
76 | }
77 | }
78 |
79 | onError(e) {
80 | if (e.response && e.response.data && e.response.data.error) {
81 | // e.response.status e.response.data.error (e.response.data.message)
82 | // 400 invalid_grant (Incorrect username or password.)
83 | // 403 too_many_failed_login_attempts (Too many failed login attempts. Please try to login again after several hours.)
84 | const errorCode = e.response.data.error;
85 | let message = e.response.data.message;
86 | if (errorCode === 'invalid_grant') {
87 | message = i18n.t('invalidGrant');
88 | } else if (errorCode === 'too_many_failed_login_attempts') {
89 | message = i18n.t('tooManyFailedLoginAttempts');
90 | }
91 | notifyError(message);
92 | } else {
93 | super.onError(e);
94 | }
95 | }
96 |
97 | async refreshAccessToken() {
98 | if (!this.refreshToken) {
99 | throw this.needsAuth('Missing refresh token');
100 | }
101 | const response = await this.postFormEncoded(
102 | '/oauth2/token',
103 | {
104 | client_id: client,
105 | grant_type: 'refresh_token',
106 | refresh_token: this.refreshToken,
107 | },
108 | {
109 | baseURL: 'https://myanimelist.net/v1',
110 | }
111 | );
112 | if (response.ok && response.data) {
113 | this.updateAuthInfo(response.data);
114 | } else {
115 | throw this.needsAuth(this.error);
116 | }
117 | }
118 |
119 | updateAuthInfo(data) {
120 | this.commit('setAuthInfo', {
121 | accessToken: data.access_token,
122 | refreshToken: data.refresh_token,
123 | expiration: DateTime.utc().plus({ seconds: data.expires_in }),
124 | });
125 | }
126 |
127 | async getUserPicture() {
128 | await this.authenticated();
129 |
130 | const response = await this.get('/users/@me');
131 |
132 | if (response.ok && response.data) {
133 | return response.data.picture;
134 | }
135 | return null;
136 | }
137 |
138 | // eslint-disable-next-line class-methods-use-this
139 | getUserProfileUrl(username) {
140 | const suffix = username ? `profile/${username}` : '';
141 | return `https://myanimelist.net/${suffix}`;
142 | }
143 |
144 | async getAnimes(username, status = null, next = false) {
145 | if (next && !this.hasNext(username, status)) {
146 | return [];
147 | }
148 |
149 | await this.authenticated();
150 |
151 | const currentOffset = this.getCurrentOffset(username, status, next);
152 |
153 | const filters = {
154 | sort: 'list_updated_at',
155 | offset: currentOffset.offset,
156 | limit: 50,
157 | fields: [
158 | 'id',
159 | 'title',
160 | 'alternative_titles{en,synonyms}',
161 | 'main_picture',
162 | 'media_type',
163 | 'genres',
164 | 'status',
165 | 'start_date',
166 | 'end_date',
167 | 'broadcast',
168 | 'num_episodes',
169 | 'my_list_status{num_episodes_watched,status,updated_at}',
170 | ].join(','),
171 | };
172 |
173 | if (status) {
174 | filters.status = status.replace(/-/g, '_');
175 | }
176 |
177 | const response = await this.get(`/users/${username}/animelist?${encodeParams(filters)}`);
178 |
179 | if (!response.ok) {
180 | return [];
181 | }
182 |
183 | if (response.data.paging.next) {
184 | currentOffset.offset += filters.limit;
185 | } else {
186 | currentOffset.hasNext = false;
187 | }
188 |
189 | return parseAnimes(response.data.data);
190 | }
191 |
192 | updateEpisode(anime) {
193 | return this.putFormEncoded(`/anime/${anime.id}/my_list_status`, {
194 | num_watched_episodes: anime.nextEpisode,
195 | status: anime.isLastEpisode ? 'completed' : 'watching',
196 | });
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/src/boot/components.js:
--------------------------------------------------------------------------------
1 | export default async ({ Vue }) => {
2 | function kebabCase(s) {
3 | return s
4 | .replace(/([a-z])([A-Z])/g, '$1-$2')
5 | .replace(/\s+/g, '-')
6 | .toLowerCase();
7 | }
8 |
9 | const ComponentContext = require.context('components/', true, /\.vue$/i, 'lazy');
10 |
11 | ComponentContext.keys().forEach((componentFilePath) => {
12 | const componentName = kebabCase(componentFilePath.split('/').pop().split('.')[0]);
13 | Vue.component(componentName, () => ComponentContext(componentFilePath));
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/src/boot/i18n.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import VueI18n from 'vue-i18n';
3 | import messages from 'src/i18n';
4 | import { LocalStorage, Quasar } from 'quasar';
5 |
6 | const languages = {
7 | en: '🇬🇧',
8 | es: '🇪🇸',
9 | };
10 |
11 | Vue.use(VueI18n);
12 |
13 | function getLocale() {
14 | const language = LocalStorage.getItem('language');
15 | if (language && Object.keys(languages).includes(language)) {
16 | return language;
17 | }
18 | return Quasar.lang.getLocale().split('-')[0];
19 | }
20 |
21 | const i18n = new VueI18n({
22 | locale: getLocale(),
23 | fallbackLocale: 'en',
24 | messages,
25 | });
26 |
27 | export default ({ app }) => {
28 | app.i18n = i18n;
29 | };
30 |
31 | export { i18n };
32 |
33 | export function getLanguageIcon(language) {
34 | return languages[language];
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/About.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | MyAnime
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {{ api.name }}
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 | {{ api.name }}
25 |
26 |
27 | {{ api.profileUrl + 'USERNAME' }}
28 |
29 |
30 |
31 |
32 |
37 |
64 |
75 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
108 |
109 |
142 |
--------------------------------------------------------------------------------
/src/components/AnimeSettings.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ title }}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
76 |
--------------------------------------------------------------------------------
/src/components/Avatar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ username || api.name }}
15 |
16 |
17 |
18 |
19 |
41 |
--------------------------------------------------------------------------------
/src/components/Back.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
20 |
--------------------------------------------------------------------------------
/src/components/CalendarButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
21 |
--------------------------------------------------------------------------------
/src/components/DynamicButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
42 |
--------------------------------------------------------------------------------
/src/components/ItemButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
17 |
18 | {{ label }}
19 |
20 | {{ caption }}
21 |
22 |
23 |
24 | {{ caption }}
25 |
26 |
27 |
28 |
29 |
39 |
40 |
75 |
--------------------------------------------------------------------------------
/src/components/LanguageSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{ scope.opt.icon }}
22 |
23 |
24 | {{ scope.opt.label }}
25 |
26 |
27 |
28 |
29 |
30 |
31 |
77 |
--------------------------------------------------------------------------------
/src/components/PasswordDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {{ $t('loginDescription', { api: api.name }) }}
17 |
18 |
19 |
20 |
21 |
27 |
35 |
36 |
37 |
38 |
39 | {{ $t('registerHere') }}
40 |
41 |
42 |
48 | {{ $t('accountSettings') }}
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
112 |
--------------------------------------------------------------------------------
/src/components/ProviderSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | {{ scope.opt.label }}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {{ provider.label }}
44 |
45 |
46 |
47 |
48 |
49 |
50 |
107 |
--------------------------------------------------------------------------------
/src/components/ResetButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
24 |
--------------------------------------------------------------------------------
/src/components/StatusSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 |
20 | {{ caption }}
21 |
22 |
23 |
24 | {{ $t(allTag) }}
25 |
26 |
27 | {{ options.filter(isSelected).map(label).join(', ') }}
28 |
29 |
30 |
31 |
32 |
89 |
--------------------------------------------------------------------------------
/src/components/SupportMe.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
27 |
28 |
34 |
--------------------------------------------------------------------------------
/src/components/TitleSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
38 |
--------------------------------------------------------------------------------
/src/components/UserSearch.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | @
6 |
13 | {{ api.profileUrl }}
14 |
15 |
16 |
17 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
69 |
--------------------------------------------------------------------------------
/src/css/app.scss:
--------------------------------------------------------------------------------
1 | $header-height: 136px;
2 |
3 | body {
4 | * {
5 | font-size: 1rem;
6 | }
7 | background-color: $primary;
8 | }
9 |
10 | .q-page,
11 | .q-footer {
12 | background: transparent;
13 | }
14 |
15 | .q-footer {
16 | width: fit-content;
17 | left: auto;
18 | }
19 |
20 | .q-header {
21 | background-color: $primary;
22 | height: $header-height;
23 | z-index: 5;
24 |
25 | .q-toolbar {
26 | height: 88px;
27 | }
28 | }
29 |
30 | .gradient {
31 | background-image: linear-gradient($primary, $accent);
32 | background-color: $primary;
33 | }
34 |
35 | .q-page-container {
36 | padding-bottom: 0 !important; // overlap footer
37 | }
38 |
39 | .q-btn {
40 | color: white;
41 | }
42 |
43 | $breakpoint-xxs-max: 349px;
44 |
45 | @media (max-width: $breakpoint-xxs-max) {
46 | #settings {
47 | min-width: 30px;
48 | }
49 |
50 | .gt-xxs {
51 | display: none;
52 | }
53 |
54 | .q-drawer__opener {
55 | width: 4px;
56 | }
57 | }
58 |
59 | .q-drawer {
60 | max-width: 100vw;
61 |
62 | .q-item__section {
63 | margin: 0;
64 | }
65 | }
66 |
67 | .q-tooltip {
68 | max-width: 256px;
69 | text-align: center;
70 | font-size: 1em;
71 | background-color: $dark;
72 | z-index: 4000;
73 |
74 | > div,
75 | > p {
76 | font-size: 1.05em;
77 | }
78 | }
79 |
80 | .prefix {
81 | font-size: 16px;
82 | padding-bottom: 2px;
83 | }
84 |
85 | .user-search {
86 | min-width: 192px;
87 | max-width: 25vw;
88 |
89 | .q-field__control {
90 | padding-right: 0;
91 | }
92 |
93 | &.q-field--focused {
94 | .q-btn {
95 | color: white;
96 | background-color: $positive;
97 | }
98 |
99 | .q-btn--flat {
100 | color: $dark;
101 | background-color: transparent;
102 | }
103 | }
104 |
105 | @media (max-width: $breakpoint-xxs-max) {
106 | min-width: 152px;
107 |
108 | .q-btn {
109 | width: 40px;
110 | }
111 | }
112 | }
113 |
114 | .q-select {
115 | .q-field__before,
116 | .q-field__prepend {
117 | padding-right: 12px;
118 | }
119 | .q-field__after,
120 | .q-field__append {
121 | padding-left: 12px;
122 | }
123 | .q-field__inner {
124 | min-width: 42px;
125 | }
126 | }
127 |
128 | .q-select.q-field--standout.q-field--focused {
129 | .filter-options {
130 | background: rgba($primary, 0.1);
131 | .q-field__native {
132 | color: $dark;
133 | }
134 | .q-field__marginal {
135 | color: rgba(0, 0, 0, 0.54);
136 | }
137 | }
138 | }
139 |
140 | .filter-options {
141 | color: grey;
142 |
143 | .q-item--active {
144 | color: $accent;
145 | font-weight: bold;
146 | }
147 | }
148 |
149 | .gt-xsm {
150 | @media (max-width: 750px) {
151 | display: none;
152 | }
153 | }
154 |
155 | h1 {
156 | font-size: 3em;
157 | font-weight: bold;
158 | margin: 0;
159 | }
160 |
161 | h2 {
162 | font-size: 1.5em;
163 | font-weight: 600;
164 | margin: 0.25em 0;
165 | }
166 |
167 | p {
168 | text-align: justify;
169 | }
170 |
171 | a.link {
172 | color: $dark;
173 | text-decoration: none;
174 | &-underline {
175 | color: $primary;
176 | text-decoration: underline;
177 | }
178 | &:hover,
179 | &:active {
180 | color: $accent;
181 | }
182 | }
183 |
184 | .clickable {
185 | cursor: pointer;
186 | }
187 |
188 | .anime-container {
189 | display: grid;
190 | grid-gap: 10px;
191 | // 100% = 2 * margin + cols * width + (cols - 1) * gap
192 | // if margin = 0 then 100% = cols * width + (cols - 1) * gap -> width = (100% - (cols - 1) * gap) / cols
193 | $col-width: calc((100% - (8 - 1) * 10px) / 8);
194 | grid-template-columns: repeat(auto-fill, $col-width);
195 | justify-content: space-between;
196 |
197 | @media (min-width: $breakpoint-xl-min * 1.2) {
198 | $col-width: calc((100% - (12 - 1) * 10px) / 12);
199 | grid-template-columns: repeat(auto-fill, $col-width);
200 | }
201 |
202 | @media (max-width: $breakpoint-md-max) {
203 | $col-width: calc((100% - (6 - 1) * 10px) / 6);
204 | grid-template-columns: repeat(auto-fill, $col-width);
205 | }
206 |
207 | @media (max-width: $breakpoint-sm-max) {
208 | $col-width: calc((100% - (4 - 1) * 10px) / 4);
209 | grid-template-columns: repeat(auto-fill, $col-width);
210 | }
211 |
212 | @media (max-width: $breakpoint-xs-max) {
213 | $col-width: calc((100% - (2 - 1) * 10px) / 2);
214 | grid-template-columns: repeat(auto-fill, $col-width);
215 | }
216 |
217 | @media (max-width: 288px) {
218 | $col-width: 100%;
219 | grid-template-columns: repeat(auto-fill, $col-width);
220 | }
221 |
222 | .anime-episode.q-card {
223 | min-width: 128px;
224 | border-radius: 12px;
225 | overflow: hidden;
226 | box-shadow: none;
227 | outline: 0;
228 |
229 | h1,
230 | h2 {
231 | vertical-align: middle;
232 | text-align: center;
233 | color: white;
234 | text-shadow: 0 1px 0 #000;
235 | line-height: 1.2em;
236 | font-family: Roboto, Helvetica, Arial, sans-serif;
237 | z-index: 1;
238 | }
239 |
240 | h1 {
241 | font-size: 1.2em;
242 | font-weight: bold;
243 | }
244 |
245 | h2 {
246 | font-size: 1em;
247 | }
248 |
249 | &.small {
250 | h1 {
251 | font-size: 1em;
252 | }
253 | h2 {
254 | font-size: 0.8em;
255 | }
256 | }
257 |
258 | .q-btn,
259 | .q-chip {
260 | z-index: 2;
261 | }
262 |
263 | .q-img {
264 | image-rendering: optimizespeed;
265 | image-rendering: -moz-crisp-edges;
266 | image-rendering: -o-crisp-edges;
267 | image-rendering: -webkit-optimize-contrast;
268 | -ms-interpolation-mode: nearest-neighbor;
269 | min-height: 200px;
270 |
271 | @media (max-width: $breakpoint-xs-max) {
272 | min-height: 300px;
273 | }
274 | }
275 | }
276 | }
277 |
278 | .overlay::after {
279 | content: '';
280 | background: rgba(0, 0, 0, 0.6);
281 | position: absolute;
282 | top: 0;
283 | right: 0;
284 | bottom: 0;
285 | left: 0;
286 | }
287 |
288 | .on-hover {
289 | .hoverable {
290 | opacity: 0;
291 | -webkit-transition: opacity 200ms ease-in-out;
292 | -moz-transition: opacity 200ms ease-in-out;
293 | -o-transition: opacity 200ms ease-in-out;
294 | -ms-transition: opacity 200ms ease-in-out;
295 | transition: opacity 200ms ease-in-out;
296 | }
297 | }
298 |
299 | .on-hover {
300 | &:hover,
301 | &:focus,
302 | &:focus-within {
303 | .hoverable {
304 | opacity: 1;
305 | -webkit-transition: opacity 200ms ease-in-out;
306 | -moz-transition: opacity 200ms ease-in-out;
307 | -o-transition: opacity 200ms ease-in-out;
308 | -ms-transition: opacity 200ms ease-in-out;
309 | transition: opacity 200ms ease-in-out;
310 | }
311 | }
312 | }
313 |
314 | .q-skeleton::before {
315 | content: '';
316 | }
317 |
318 | .square {
319 | border-radius: 0;
320 | }
321 |
322 | .fit-width {
323 | width: 100%;
324 | }
325 |
--------------------------------------------------------------------------------
/src/css/quasar.variables.scss:
--------------------------------------------------------------------------------
1 | // Quasar SCSS (& Sass) Variables
2 | // --------------------------------------------------
3 | // To customize the look and feel of this app, you can override
4 | // the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
5 |
6 | // Check documentation for full list of Quasar variables
7 |
8 | // Your own variables (that are declared here) and Quasar's own
9 | // ones will be available out of the box in your .vue/.scss/.sass files
10 |
11 | // It's highly recommended to change the default colors
12 | // to match your app's branding.
13 | // Tip: Use the "Theme Builder" on Quasar's documentation website.
14 |
15 | $primary: #2d1e53;
16 | $secondary: #5e3175;
17 | $accent: #e242bd;
18 |
19 | $dark: #1d1d1d;
20 |
21 | $positive: #068b25;
22 | $negative: #c10015;
23 | $info: #31ccec;
24 | $warning: #f2c037;
25 |
--------------------------------------------------------------------------------
/src/i18n/en/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | all: 'All',
3 | any: 'Any',
4 | here: 'here',
5 | episode: 'Episode',
6 | complete: 'Complete',
7 | nextEpisode: 'Next episode',
8 | settings: 'Settings',
9 | animeCalendar: 'Anime Calendar',
10 | animeCalendarDescription: 'Calendar anime for this week',
11 | selectLanguage: 'Select language',
12 | selectProvider: 'Select provider',
13 | selectTitle: 'Select alternative title',
14 | animeStatus: 'Anime status',
15 | animeStatusFilter: 'Filter anime status',
16 | alreadyAired: 'Already aired',
17 | notYetAired: 'Not yet aired',
18 | status: {
19 | watching: 'Watching',
20 | onHold: 'On Hold',
21 | planToWatch: 'Plan to Watch'
22 | },
23 | genre: 'Genre',
24 | genres: {
25 | action: 'Action',
26 | adventure: 'Adventure',
27 | cars: 'Cars',
28 | comedy: 'Comedy',
29 | dementia: 'Dementia',
30 | demons: 'Demons',
31 | drama: 'Drama',
32 | ecchi: 'Ecchi',
33 | fantasy: 'Fantasy',
34 | game: 'Game',
35 | harem: 'Harem',
36 | hentai: 'Hentai',
37 | historical: 'Historical',
38 | horror: 'Horror',
39 | josei: 'Josei',
40 | kids: 'Kids',
41 | magic: 'Magic',
42 | martialArts: 'Martial Arts',
43 | mecha: 'Mecha',
44 | military: 'Military',
45 | music: 'Music',
46 | mystery: 'Mystery',
47 | parody: 'Parody',
48 | police: 'Police',
49 | psychological: 'Psychological',
50 | romance: 'Romance',
51 | samurai: 'Samurai',
52 | school: 'School',
53 | scifi: 'Sci-Fi',
54 | seinen: 'Seinen',
55 | shoujo: 'Shoujo',
56 | shoujoAi: 'Shoujo Ai',
57 | shounen: 'Shounen',
58 | shounenAi: 'Shounen Ai',
59 | sliceOfLife: 'Slice of Life',
60 | space: 'Space',
61 | sports: 'Sports',
62 | superPower: 'Super Power',
63 | supernatural: 'Supernatural',
64 | thriller: 'Thriller',
65 | vampire: 'Vampire',
66 | yaoi: 'Yaoi',
67 | yuri: 'Yuri'
68 | },
69 | animeType: 'Anime type',
70 | animeTypeFilter: 'Filter anime type',
71 | movie: 'Movie',
72 | special: 'Special',
73 | music: 'Music',
74 | resetSettings: 'Reset settings',
75 | resetSettingsDescription: 'Clean user data and filters',
76 | aboutApp: 'About this app',
77 | donate: `
78 | This app is completely free and has no ads.
79 | If you like this app, you can support me for the price of a coffee.
80 | Thank you!
81 | `,
82 | providerSelect: `
83 | Select which provider must be opened by default when clicking over an episode.
84 | Some options are based on search engine, trying to get a proper streamer, but it doesn't mean it always work.
85 | If selected provider cannot find an episode try to change the provider or choose an alternative title.
86 | You can override the default provider in the settings of each anime.
87 | `,
88 | login: 'Log In',
89 | loginDescription: 'Please, log in to your {api} account to view your anime list and update episodes directly within this app.',
90 | notRegisteredYet: 'Not registered yet?',
91 | registerHere: 'Register here',
92 | noPassword: 'If your account has no password because it uses social media login like Facebook, Twitter or Google go to your {0} and set a password for your account first.',
93 | accountSettings: 'Account Settings',
94 | updated: 'Updated {title} to episode {episode}',
95 | completed: "Hooray! You've completed {title}!",
96 | statusChanged: '{title} status changed to {status}',
97 | invalidGrant: 'Incorrect username or password',
98 | tooManyFailedLoginAttempts: 'Too many failed login attempts. Please try to login again after several hours.',
99 | required: {
100 | username: 'Username is required',
101 | password: 'Password is required',
102 | },
103 | username: 'Username',
104 | password: 'Password',
105 | cancel: 'Cancel',
106 | back: 'Go back',
107 | notFound: 'Sorry, nothing here...',
108 | error: 'Oops... an unexpected error has occurred 😣',
109 | about: {
110 | description: 'Watch your favourite animes with your usual provider, synchronized with {api}.',
111 | why: {
112 | header: 'Why to use?',
113 | content: `
114 | This is a shortcut to access your anime series with your preferred providers, all in sync with the selected animes in your {api} profile.
115 | Avoid surfing the internet or the providers looking for your episodes, just seat in and enjoy.
116 | With this single page you can test several providers and update your {api} episodes easily.
117 | It also skips some advertising from providers home sites, because you'll access to the anime or episode page directly.
118 | `
119 | },
120 | how: {
121 | header: 'How to use',
122 | enterYourUsername: 'Enter your {api} username ({profileUrl}), select your preferred provider and choose one of your animes to watch, then just enjoy your episode.',
123 | updateEpisode: `
124 | Click on '@:nextEpisode' at the top right corner of an episode to mark it as watched in your {api} profile.
125 | When updating your watched episodes in {api} then the next episode will be shown, waiting for you to be watched.
126 | `,
127 | },
128 | features: {
129 | header: 'Features',
130 | list: `
131 | Cover images and links for the series in which you are interested.
132 | Easy access to your next episodes of {api} lists @:status.watching, @:status.onHold and @:status.planToWatch, with several providers for streaming to choose.
133 | Filter episodes by status (@:alreadyAired, @:notYetAired), type (TV, @:movie, OVA, @:special, ONA, @:music) and genre.
134 | Some series have title synonyms, choose between original or alternative title in the settings of the anime.
135 | Update your watched episodes in your {api} profile directly within this page.
136 | `,
137 | note: 'Your preferences are saved in the browser, so you only need to change them when you need it. User is automatically retrieved based on the last user used.'
138 | },
139 | providers: {
140 | header: 'Supported providers',
141 | note: `
142 | Some options are based on search engine, trying to get a proper streamer, but it doesn't mean it always work. Sometimes it redirects to another anime, a non-related page, or it is in another language.
143 | If selected provider cannot find an episode try to change the provider or choose an alternative title.
144 | `
145 | },
146 | contact: {
147 | header: 'Contact',
148 | issue: 'Your favourite provider is not listed here? Please, open an issue {issue} and we will add it!',
149 | note: 'Open an issue too if you have any doubt, advice or you want to report about something broken.',
150 | },
151 | disclaimer: {
152 | header: 'Disclaimer',
153 | content: `
154 | This website does not host any video, it is a client-side website, just linking and sharing content from non-affiliated external providers.
155 | Official providers like Crunchyroll or Netflix are recommended. Use other providers at your own risk.
156 | `
157 | },
158 | }
159 | };
160 |
--------------------------------------------------------------------------------
/src/i18n/es/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | all: 'Todo',
3 | any: 'Cualquiera',
4 | here: 'aquí',
5 | episode: 'Episodio',
6 | complete: 'Completar',
7 | nextEpisode: 'Siguiente episodio',
8 | settings: 'Ajustes',
9 | animeCalendar: 'Calendario Anime',
10 | animeCalendarDescription: 'Animes de esta semana',
11 | selectLanguage: 'Seleccionar idioma',
12 | selectProvider: 'Seleccionar proveedor',
13 | selectTitle: 'Seleccionar título alternativo',
14 | animeStatus: 'Estado de anime',
15 | animeStatusFilter: 'Filtrar por estado',
16 | alreadyAired: 'Disponible',
17 | notYetAired: 'No disponible',
18 | status: {
19 | watching: 'Siguiendo',
20 | onHold: 'En espera',
21 | planToWatch: 'En un futuro'
22 | },
23 | genre: 'Género',
24 | genres: {
25 | action: 'Acción',
26 | adventure: 'Aventuras',
27 | cars: 'Coches',
28 | comedy: 'Comedia',
29 | dementia: 'Demencia',
30 | demons: 'Demonios',
31 | drama: 'Drama',
32 | ecchi: 'Ecchi',
33 | fantasy: 'Fantasía',
34 | game: 'Juegos',
35 | harem: 'Harem',
36 | hentai: 'Hentai',
37 | historical: 'Historia',
38 | horror: 'Horror',
39 | josei: 'Josei',
40 | kids: 'Para niños',
41 | magic: 'Magia',
42 | martialArts: 'Artes marciales',
43 | mecha: 'Mecha',
44 | military: 'Militar',
45 | music: 'Música',
46 | mystery: 'Misterio',
47 | parody: 'Parodia',
48 | police: 'Policíaco',
49 | psychological: 'Psicológico',
50 | romance: 'Romance',
51 | samurai: 'Samurai',
52 | school: 'Escolar',
53 | scifi: 'Ciencia ficción',
54 | seinen: 'Seinen',
55 | shoujo: 'Shoujo',
56 | shoujoAi: 'Shoujo Ai',
57 | shounen: 'Shounen',
58 | shounenAi: 'Shounen Ai',
59 | sliceOfLife: 'Recuentos de la vida',
60 | space: 'Espacial',
61 | sports: 'Deportes',
62 | superPower: 'Superpoderes',
63 | supernatural: 'Supernatural',
64 | thriller: 'Thriller',
65 | vampire: 'Vampiros',
66 | yaoi: 'Yaoi',
67 | yuri: 'Yuri'
68 | },
69 | animeType: 'Tipo de anime',
70 | animeTypeFilter: 'Filtrar tipo de anime',
71 | movie: 'Película',
72 | special: 'Especial',
73 | music: 'Música',
74 | resetSettings: 'Restablecer ajustes',
75 | resetSettingsDescription: 'Borrar datos de usuario y filtros',
76 | aboutApp: 'Sobre esta aplicación',
77 | donate: `
78 | Esta aplicación es completamente gratuita y sin anuncios.
79 | Si te gusta la aplicación, puedes apoyarme por el precio de un café.
80 | ¡Muchas gracias!
81 | `,
82 | providerSelect: `
83 | Selecciona qué proveedor debe abrirse por defecto cuando hagas click en un episodio.
84 | Algunas opciones están basada en buscadores, intentando obtener un proveedor correcto, pero no siempre funciona.
85 | Si el proveedor seleccionado no puede encontrar un episodio prueba con otro proveedor o selecciona un título alternativo.
86 | Puedes sobreescribir el proveedor por defecto en los ajustes de cada anime.
87 | `,
88 | login: 'Iniciar sesión',
89 | loginDescription: 'Por favor, inicia sesión en tu cuenta de {api} para ver tu lista de animes y actualizar los episodios directamente desde esta app.',
90 | notRegisteredYet: '¿Todavía no estás registrado?',
91 | registerHere: 'Regístrate aquí',
92 | noPassword: 'Si tu cuenta no tiene contraseña porque utilizas una red social como Facebook, Twitter o Google para iniciar sesión ve a tus {0} y establece una contraseña primero.',
93 | accountSettings: 'ajustes de cuenta',
94 | updated: '{title} actualizado al episodio {episode}',
95 | completed: "¡Genial! ¡Has completado {title}!",
96 | statusChanged: 'El estado de {title} se ha cambiado a {status}',
97 | invalidGrant: 'Nombre de usuario o contraseña incorrectos',
98 | tooManyFailedLoginAttempts: 'Demasiados intentos fallidos. Prueba a iniciar sesión de nuevo en unas horas.',
99 | required: {
100 | username: 'Introduce tu nombre de usuario',
101 | password: 'Introduce tu contraseña',
102 | },
103 | username: 'Nombre de usuario',
104 | password: 'Contraseña',
105 | cancel: 'Cancelar',
106 | back: 'Volver',
107 | notFound: 'Lo sentimos, no hay nada aquí...',
108 | error: 'Vaya... ha ocurrido un error inesperado 😣',
109 | about: {
110 | description: 'Disfruta de tus animes favoritos con tu proveedor habitual, sincronizado con {api}.',
111 | why: {
112 | header: '¿Por qué usarme?',
113 | content: `
114 | Se trata de un acceso directo a los animes de {api} que estás siguiendo con tus páginas web favoritas.
115 | Evita buscar los episodios por internet o los proveedores de anime, simplemente siéntate y disfruta de tu siguiente episodio, esperándote a solo un click.
116 | Desde aquí puedes probar diferentes proveedores de anime y actualizar tus episodios en {api} con un solo click.
117 | También te ahorrarás algunos anuncios de las páginas principales de los proveedores donde ves anime porque accederás directamente a la página del anime o del episodio seleccionado.
118 | `
119 | },
120 | how: {
121 | header: 'Cómo usarme',
122 | enterYourUsername: 'Introduce tu nombre de usuario de {api} ({profileUrl}), selecciona tu proveedor favorito y elige uno de tus animes para ver, luego simplemente disfruta de tu episodio.',
123 | updateEpisode: `
124 | Haz click en '@:nextEpisode' en la esquina superior derecha de un episodio para marcarlo como visto en tu perfil de {api}.
125 | Cuando actualices tus episodios vistos en {api} el siguiente episodio se mostrará, esperándote a que lo veas.
126 | `,
127 | },
128 | features: {
129 | header: 'Características',
130 | list: `
131 | Imágenes de portada y enlaces a los animes en los que estás interesado.
132 | Fácil acceso a tus episodios pendientes de tus listas de {api} (@:status.watching, @:status.onHold y @:status.planToWatch), con varias páginas web donde poder verlos.
133 | Filtra episodios por estado (@:alreadyAired, @:notYetAired), tipo (TV, @:movie, OVA, @:special, ONA, @:music) y género.
134 | Algunos animes tienen varios títulos, escoge entre el original o un título alternativo en los ajustes del anime.
135 | Actualiza tus episodios vistos en tu perfil de {api} directamente desde esta página.
136 | `,
137 | note: 'Las preferencias se guardan en tu navegador, así que solo tendrás que cambiarlas cuando lo necesites.'
138 | },
139 | providers: {
140 | header: 'Proveedores soportados',
141 | note: `
142 | Algunas opciones se basan en motores de búsqueda, intentando obtener un proveedor adecuado, pero eso no signfica que funcione siempre. Algunas veces te puede redirigir a otro anime, a una página que no tiene nada que ver o simplemente a un episodio en otro idioma.
143 | Si el proveedor seleccionado no puede encontrar un episodio prueba a cambiar de proveedor o selecciona un título alternativo.
144 | `
145 | },
146 | contact: {
147 | header: 'Contacto',
148 | issue: '¿Tu proveedor favorito no está listado aquí? Por favor, abre una sugerencia {issue} y lo añadiremos.',
149 | note: 'Abre una sugerencia también si tienes alguna duda, consejo o si encuentras algo que no funciona como debería.',
150 | },
151 | disclaimer: {
152 | header: 'Atención',
153 | content: `
154 | Esta página web no almacena ningún vídeo, es solo una aplicación que enlaza el contenido de otras páginas web externas no afiliadas.
155 | Se recomienda utilizar los proveedores oficiales como Crunchyroll o Netflix. Usa otros proveedores bajo tu responsabilidad.
156 | `
157 | },
158 | }
159 | };
160 |
--------------------------------------------------------------------------------
/src/i18n/index.js:
--------------------------------------------------------------------------------
1 | import en from './en';
2 | import es from './es';
3 |
4 | export default {
5 | en,
6 | es,
7 | };
8 |
--------------------------------------------------------------------------------
/src/index.template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= productName %>
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/layouts/MainLayout.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
15 |
16 |
24 |
25 |
32 |
39 |
40 |
41 |
42 |
43 |
44 |
52 |
53 |
61 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | {{ $t('aboutApp') }}
130 |
131 |
132 |
133 |
134 |
135 |
145 |
146 |
147 |
148 |
149 |
150 |
212 |
--------------------------------------------------------------------------------
/src/mixins/bind.js:
--------------------------------------------------------------------------------
1 | export default function bindValue(name, type) {
2 | const mixin = {
3 | props: {
4 | value: {
5 | type,
6 | required: true,
7 | },
8 | },
9 | computed: {},
10 | };
11 |
12 | mixin.computed[name] = {
13 | get() {
14 | return this.value;
15 | },
16 | set(value) {
17 | this.$emit('input', value);
18 | },
19 | };
20 |
21 | return mixin;
22 | }
23 |
--------------------------------------------------------------------------------
/src/mixins/keyboard.js:
--------------------------------------------------------------------------------
1 | const keys = {
2 | 40: 'down',
3 | };
4 |
5 | /** @param keyListeners - object mapping key string codes to method names */
6 | export function registerKeyListeners(keyListeners) {
7 | return {
8 | mounted() {
9 | document.addEventListener('keydown', this.keyListener);
10 | },
11 | beforeDestroy() {
12 | document.removeEventListener('keydown', this.keyListener);
13 | },
14 | methods: {
15 | keyListener(e) {
16 | const keyCode = e.which || e.keyCode;
17 | const keyMapping = keys[keyCode];
18 | if (keyMapping) {
19 | const listenerName = keyListeners[keyMapping];
20 | if (listenerName) {
21 | const listener = this[listenerName];
22 | if (listener) {
23 | listener.call(this);
24 | } else {
25 | console.error(`Key listener ${listenerName} not found in component methods`);
26 | }
27 | }
28 | }
29 | },
30 | },
31 | };
32 | }
33 |
--------------------------------------------------------------------------------
/src/model/Anime.js:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon';
2 |
3 | export class Anime {
4 | /**
5 | * @param {Object} anime - anime object
6 | * @param {String} anime.id - provider's anime id
7 | * @param {String} anime.title - main title
8 | * @param {Object} anime.alternativeTitles - alternative titles
9 | * @param {String?} anime.alternativeTitles.en - english title
10 | * @param {Array} anime.alternativeTitles.synonyms - alternative titles
11 | * @param {String} anime.cover - image url
12 | * @param {String} anime.status - watching, on-hold, plan-to-watch
13 | * @param {String} anime.type - tv, ova, movie, special, ona, music, unknown
14 | * @param {Array} anime.genres - action, adventure, cars, comedy, dementia, demons, drama, ecchi, fantasy, game, harem, hentai, historical, horror, josei, kids, magic, martial arts, mecha, military, music, mystery, parody, police, psychological, romance, samurai, school, sci-fi, seinen, shoujo, shoujo ai, shounen, shounen ai, slice of life, space, sports, super power, supernatural, thriller, vampire, yaoi, yuri
15 | * @param {Number?} anime.totalEpisodes - anime total episodes
16 | * @param {String?} anime.startDate - anime start date (yyyy-MM-dd) (JST)
17 | * @param {Object?} anime.broadcast - anime episodes broadcasting
18 | * @param {String} anime.broadcast.weekday - monday to sunday (JST)
19 | * @param {String?} anime.broadcast.time - HH:mm (JST)
20 | * @param {String?} anime.airingStatus - not yet aired, currently airing, finished airing
21 | * @param {String?} anime.updatedAt - last time this anime was updated in user's list
22 | * @param {Number} anime.lastWatchedEpisode - user's last watched episode
23 | */
24 | constructor({
25 | id,
26 | title,
27 | alternativeTitles,
28 | cover,
29 | status,
30 | type,
31 | genres,
32 | totalEpisodes,
33 | startDate,
34 | broadcast,
35 | airingStatus,
36 | updatedAt,
37 | lastWatchedEpisode = 0,
38 | }) {
39 | this.id = id;
40 | this.title = title;
41 | this.alternativeTitles = alternativeTitles;
42 | this.cover = cover;
43 | this.status = status;
44 | this.type = type;
45 | this.genres = genres;
46 | this.totalEpisodes = totalEpisodes;
47 | this.broadcast = broadcast;
48 | this.airingStatus = airingStatus;
49 | this.updatedAt = DateTime.fromISO(updatedAt);
50 | this.lastWatchedEpisode = lastWatchedEpisode;
51 |
52 | const titleWithoutDashes = this.title.replace('-', '');
53 | if (titleWithoutDashes !== title && !this.alternativeTitles.synonyms.includes(titleWithoutDashes)) {
54 | this.alternativeTitles.synonyms.push(titleWithoutDashes);
55 | }
56 |
57 | this.setAiringDate(startDate);
58 | }
59 |
60 | get titles() {
61 | const titles = [this.title];
62 | if (this.alternativeTitles.en && this.alternativeTitles.en !== this.title) {
63 | titles.push(this.alternativeTitles.en);
64 | }
65 | titles.push(...this.alternativeTitles.synonyms);
66 | return titles;
67 | }
68 |
69 | get hasManyEpisodes() {
70 | return this.type === 'tv' || this.totalEpisodes > 1;
71 | }
72 |
73 | get nextEpisode() {
74 | return this.lastWatchedEpisode + 1;
75 | }
76 |
77 | get isLastEpisode() {
78 | return this.totalEpisodes === this.nextEpisode;
79 | }
80 |
81 | get isCompleted() {
82 | return this.nextEpisode > this.totalEpisodes;
83 | }
84 |
85 | get progress() {
86 | return this.totalEpisodes ? this.nextEpisode / this.totalEpisodes : 0;
87 | }
88 |
89 | get isAired() {
90 | return (
91 | (this.airingStatus && this.airingStatus !== 'not yet aired') ||
92 | (this.airingDate && this.airingDate <= DateTime.local())
93 | );
94 | }
95 |
96 | setAiringDate(startDate) {
97 | function fromFormat(format) {
98 | return DateTime.fromFormat(startDate, format, { zone: 'Asia/Tokyo' });
99 | }
100 |
101 | if (startDate) {
102 | const parts = startDate.split('-').length;
103 |
104 | if (parts === 3) {
105 | this.airingDatePrecision = 'day';
106 | this.airingDate = fromFormat('yyyy-MM-dd').toLocal();
107 | } else if (parts === 2) {
108 | this.airingDatePrecision = 'month';
109 | this.airingDate = fromFormat('yyyy-MM').endOf('month').toLocal();
110 | } else if (parts === 1) {
111 | this.airingDatePrecision = 'year';
112 | this.airingDate = fromFormat('yyyy').endOf('year').toLocal();
113 | }
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/model/providers/AnimeFLV.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 |
3 | // Some urls do not have standard title encoding
4 | const exceptionsMapping = {
5 | 'One Piece': 'one-piece-tv',
6 | };
7 |
8 | class AnimeFLV extends Provider {
9 | constructor() {
10 | super('https://animeflv.net/', 3, ['es']);
11 | }
12 |
13 | // eslint-disable-next-line class-methods-use-this
14 | get icon() {
15 | return 'icons/animeflv.ico';
16 | }
17 |
18 | episodeUrl({ anime, title, episode }) {
19 | return `${this.url}ver/${exceptionsMapping[anime.title] || Provider.encode(title)}-${episode}`;
20 | }
21 | }
22 |
23 | /*
24 | withSearch(({ title, episode }) => {
25 | return encodeURIComponent(`site:${this.url} inurl:"/ver" intitle:"${title}" intitle:"Episodio ${episode}");
26 | });
27 | */
28 |
29 | export default new AnimeFLV();
30 |
--------------------------------------------------------------------------------
/src/model/providers/AnimeFenix.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 |
3 | class AnimeFenix extends Provider {
4 | constructor() {
5 | super('https://animefenix.tv/', 2, ['es']);
6 | }
7 |
8 | episodeUrl({ title, episode }) {
9 | return `${this.url}ver/${Provider.encode(title)}-${episode}`;
10 | }
11 | }
12 |
13 | export default new AnimeFenix();
14 |
--------------------------------------------------------------------------------
/src/model/providers/AnimeHeaven.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 | import { withSearch } from './FeelingLucky';
3 |
4 | class AnimeHeaven extends Provider {
5 | constructor() {
6 | super('https://animeheaven.ru/', 2, ['en']);
7 |
8 | this.search = withSearch(({ title, episode }) => {
9 | // https://animeheaven.ru/watch/one-piece-sub.79238?ep=163672
10 | // https://animeheaven.ru/watch/one-piece-film-gold
11 | return encodeURIComponent(`site:${this.url}watch intitle:"${title} Episode ${episode}"`);
12 | });
13 | }
14 |
15 | // eslint-disable-next-line class-methods-use-this
16 | get icon() {
17 | // Alternative: https://static.anmedm.com/static/favicon.ico
18 | return 'https://static.anmedm.com/static/css/animeheaven-logo.png';
19 | }
20 |
21 | episodeUrl({ anime, title }) {
22 | if (anime.type === 'movie') {
23 | return `${this.url}watch/${encodeURIComponent(Provider.trimSpecials(title))}`;
24 | }
25 | // return this.search.episodeUrl({ anime, title, episode });
26 | return `${this.url}search?q=${encodeURIComponent(Provider.trimSpecials(title))}`;
27 | }
28 | }
29 |
30 | export default new AnimeHeaven();
31 |
--------------------------------------------------------------------------------
/src/model/providers/AnimeID.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 |
3 | class AnimeID extends Provider {
4 | constructor() {
5 | super('https://www.animeid.tv/', 3, ['es']);
6 | }
7 |
8 | episodeUrl({ title, episode }) {
9 | return `${this.url}v/${Provider.encode(title)}-${episode}`;
10 | }
11 | }
12 |
13 | export default new AnimeID();
14 |
--------------------------------------------------------------------------------
/src/model/providers/AnimeMovil2.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 |
3 | class AnimeMovil2 extends Provider {
4 | constructor() {
5 | super('https://animemovil2.com/', 3, ['es']);
6 | }
7 |
8 | // eslint-disable-next-line class-methods-use-this
9 | get icon() {
10 | return `${this.url}media/icons/ico.ico`;
11 | }
12 |
13 | episodeUrl({ title, episode }) {
14 | return `${this.url}ver/${Provider.encode(title)}-${episode}`;
15 | }
16 | }
17 |
18 | export default new AnimeMovil2();
19 |
--------------------------------------------------------------------------------
/src/model/providers/AnimeSuge.js:
--------------------------------------------------------------------------------
1 | import MALSync from '@/api/MALSync';
2 | import { openURL } from 'quasar';
3 | import { withSearchResolve } from './FeelingLucky';
4 | import Provider from './Provider.js';
5 |
6 | class AnimeSuge extends Provider {
7 | constructor(search) {
8 | super('https://animesuge.to/', 2, ['en']);
9 |
10 | this.delegate(search, ['open']);
11 | }
12 |
13 | // eslint-disable-next-line class-methods-use-this
14 | get icon() {
15 | return 'https://animesuge.to/assets/sites/animesuge/icons/favicon.png';
16 | }
17 | }
18 |
19 | function altEpisodeUrl(animeUrl, { anime, episode }) {
20 | const ep = anime.type === 'movie' ? 'full' : episode;
21 | return `${animeUrl}/ep-${ep}`;
22 | }
23 |
24 | // https://animesuge.to/anime/one-piece-ov8/ep-1049
25 | // https://animesuge.to/anime/one-piece-film-gold-71vy/ep-full
26 | const AnimeSugeLucky = withSearchResolve(({ provider, anime, title }) => {
27 | return encodeURIComponent(`site:${provider.url}anime/${anime.alternativeTitles.en || title}`);
28 | }, altEpisodeUrl);
29 |
30 | export default new AnimeSuge({
31 | async open(args) {
32 | const { provider, anime, title } = args;
33 | const animeSite = await MALSync.getAnimeSite('9anime', anime.id, title);
34 | if (animeSite) {
35 | openURL(altEpisodeUrl(`${provider.url}anime/${Provider.encode(animeSite.title)}-${animeSite.id}`, args)); // animeSite.id is the same in gogoanime.page and 9anime
36 | } else {
37 | await AnimeSugeLucky.open(args);
38 | }
39 | }
40 | });
41 |
--------------------------------------------------------------------------------
/src/model/providers/Animeflix.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 |
3 | class Animeflix extends Provider {
4 | constructor() {
5 | super('https://animeflix.live/', 2, ['en']);
6 | }
7 |
8 | episodeUrl({ anime, title, episode }) {
9 | // https://animeflix.live/watch/one-piece-episode-1006/21/
10 | return `${this.url}watch/${Provider.encode(title)}-episode-${episode}/${anime.id}/`;
11 | }
12 | }
13 |
14 | export default new Animeflix();
15 |
--------------------------------------------------------------------------------
/src/model/providers/Animepahe.js:
--------------------------------------------------------------------------------
1 | import { openURL } from 'quasar';
2 | import MALSync from '@/api/MALSync';
3 | import Provider from './Provider.js';
4 | import { withSearchResolve } from './FeelingLucky';
5 |
6 | // TODO: API https://animepahe.ru/api?m=search&q=one%20piece
7 |
8 | class Animepahe extends Provider {
9 | constructor(search) {
10 | super('https://animepahe.ru/', 2, ['en']);
11 |
12 | this.delegate(search);
13 | }
14 |
15 | // eslint-disable-next-line class-methods-use-this
16 | get icon() {
17 | return 'icons/animepahe.png';
18 | }
19 | }
20 |
21 | const AnimepaheLucky = withSearchResolve(({ provider, title, episode }) => {
22 | return encodeURIComponent(`site:${provider.url} "${title}" Ep. "${episode}"`);
23 | });
24 |
25 | export default new Animepahe({
26 | async open(args) {
27 | const { anime, title } = args;
28 | const animeSite = await MALSync.getAnimeSite('animepahe', anime.id, title);
29 |
30 | if (animeSite && animeSite.url) {
31 | openURL(animeSite.url);
32 | } else {
33 | await AnimepaheLucky.open(args);
34 | }
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/src/model/providers/Crunchyroll.js:
--------------------------------------------------------------------------------
1 | import { openURL } from 'quasar';
2 | import MALSync from '@/api/MALSync';
3 | import Provider from './Provider.js';
4 |
5 | class Crunchyroll extends Provider {
6 | constructor(search) {
7 | super('https://www.crunchyroll.com/', 1);
8 |
9 | this.delegate(search);
10 | }
11 |
12 | // eslint-disable-next-line class-methods-use-this
13 | get icon() {
14 | // `${this.url}favicons/favicon-32x32.png`;
15 | return 'icons/crunchyroll.png';
16 | }
17 |
18 | episodeUrl({ title }) {
19 | // return `${this.url}search?q=${encodeURI(`${title} ${episode}`)}&o=m&r=f`;
20 | return `${this.url}${Provider.encode(title)}`;
21 | }
22 | }
23 |
24 | export default new Crunchyroll({
25 | async open(args) {
26 | const { provider, anime, title } = args;
27 | const animeSite = await MALSync.getAnimeSite('Crunchyroll', anime.id, title);
28 |
29 | openURL(animeSite && animeSite.url ? animeSite.url : provider.episodeUrl(args));
30 |
31 | // TODO: Scrap animeSite.url for episode url
32 | },
33 | });
34 |
--------------------------------------------------------------------------------
/src/model/providers/FeelingLucky.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-classes-per-file */
2 | import { openURL } from 'quasar';
3 | import { newAxios } from '@/api/API.js';
4 | import { notifyError } from '@/utils/errors.js';
5 | import Provider from './Provider.js';
6 |
7 | export class FeelingLucky extends Provider {
8 | constructor(url, prefix, search, languages) {
9 | super(url, 0, languages);
10 |
11 | this.prefix = prefix;
12 | this.search = search;
13 | }
14 |
15 | episodeUrl(args) {
16 | return this.url + this.prefix + this.search(args);
17 | }
18 | }
19 |
20 | export class FeelingLuckyResolve extends FeelingLucky {
21 | constructor(url, prefix, search, languages) {
22 | super(url, prefix, search, languages);
23 |
24 | this.client = newAxios({
25 | baseUrl: url + prefix,
26 | });
27 | }
28 |
29 | /* eslint-disable no-unused-vars */
30 | /* eslint-disable no-empty-function */
31 | /* eslint-disable class-methods-use-this */
32 | async resolve(searchResponse, args) {} // Template
33 |
34 | async open(args) {
35 | const searchResponse = await this.client.get(this.search(args));
36 | const episodeResolveUrl = await this.resolve(searchResponse, args);
37 |
38 | if (episodeResolveUrl) {
39 | openURL(episodeResolveUrl);
40 | } else {
41 | notifyError();
42 | }
43 | }
44 | }
45 |
46 | class FeelingDuckyResolve extends FeelingLuckyResolve {
47 | constructor(search, toUrl, languages) {
48 | super('https://api.duckduckgo.com/', '?format=json&no_redirect=1&t=MyAnime&q=!ducky+', search, languages);
49 |
50 | this.toUrl = toUrl || ((episodeRedirectUrl) => episodeRedirectUrl);
51 | }
52 |
53 | async resolve(searchResponse, args) {
54 | const episodeRedirectUrl = searchResponse.data && searchResponse.data.Redirect;
55 | const episodeResolveUrl = episodeRedirectUrl && (await this.toUrl(episodeRedirectUrl, args));
56 | return episodeResolveUrl || `https://duckduckgo.com/?q=!ducky+${this.search(args)}`;
57 | }
58 | }
59 |
60 | function luckySpanish({ anime, title, episode }) {
61 | const suffix = 'online español -english';
62 | return encodeURIComponent(
63 | `"${title}"${anime.hasManyEpisodes || episode > 1 ? ` "episodio ${episode}" inurl:${episode}` : ''} ${suffix}`
64 | );
65 | }
66 |
67 | function luckyEnglish({ anime, title, episode }) {
68 | const suffix = 'online english anime -español';
69 | return encodeURIComponent(
70 | `"${title}"${anime.hasManyEpisodes || episode > 1 ? ` "episode ${episode}" inurl:${episode}` : ''} ${suffix}`
71 | );
72 | }
73 |
74 | export function withSearch(search, languages) {
75 | return new FeelingLucky('https://duckduckgo.com/', '?q=!ducky+', search, languages);
76 | }
77 |
78 | export function withSearchResolve(search, toUrl, languages) {
79 | return new FeelingDuckyResolve(search, toUrl, languages);
80 | }
81 |
82 | export const FeelingDuckyES = withSearch(luckySpanish, ['es']);
83 | export const FeelingDuckyEN = withSearch(luckyEnglish, ['en']);
84 |
85 | export const FeelingLuckyES = new FeelingLucky('https://www.google.es/', 'search?btnI&q=', luckySpanish, ['es']);
86 | export const FeelingLuckyEN = new FeelingLucky('https://www.google.com/', 'search?btnI&q=', luckyEnglish, ['en']);
87 |
--------------------------------------------------------------------------------
/src/model/providers/Gogoanime.js:
--------------------------------------------------------------------------------
1 | import { openURL } from 'quasar';
2 | import MALSync from '@/api/MALSync';
3 | import Provider from './Provider.js';
4 | import { withSearchResolve } from './FeelingLucky';
5 |
6 | class Gogoanime extends Provider {
7 | constructor(url, search) {
8 | super(url, 2, ['en']);
9 |
10 | this.delegate(search);
11 | }
12 |
13 | // eslint-disable-next-line class-methods-use-this
14 | get icon() {
15 | // Alternative: https://s2.fastcache.ru/assets/gogoanime/favicon.png
16 | return 'https://cdn.gogocdn.net/files/gogo/img/favicon.png';
17 | }
18 | }
19 |
20 | // https://gogoanime.news/
21 | export const GogoanimeOfficial = new Gogoanime('https://gogoanime.bid/', {
22 | episodeUrl({ provider, title, episode }) {
23 | return `${provider.url}${Provider.encode(title)}-episode-${episode}`;
24 | },
25 | });
26 |
27 | function altEpisodeUrl(animeUrl, { anime, episode }) {
28 | const ep = anime.type === 'movie' ? 'full' : episode;
29 | return `${animeUrl}/ep-${ep}`;
30 | }
31 |
32 | // https://gogoanime.page/watch/one-piece.ov8/ep-931
33 | // https://gogoanime.page/watch/one-piece-film-gold.71vy/ep-full
34 | const GogoanimeAltLucky = withSearchResolve(({ provider, anime, title }) => {
35 | return encodeURIComponent(`site:${provider.url}anime/${anime.alternativeTitles.en || title}`);
36 | }, altEpisodeUrl);
37 |
38 | export const GogoanimeAlt = new Gogoanime('https://gogoanime.page/', {
39 | async open(args) {
40 | const { provider, anime, title } = args;
41 | const animeSite = await MALSync.getAnimeSite('9anime', anime.id, title);
42 | if (animeSite) {
43 | openURL(altEpisodeUrl(`${provider.url}watch/${Provider.encode(animeSite.title)}.${animeSite.id}`, args)); // animeSite.id is the same in gogoanime.page and 9anime
44 | } else {
45 | await GogoanimeAltLucky.open(args);
46 | }
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/src/model/providers/MonosChinos.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 |
3 | class MonosChinos extends Provider {
4 | constructor() {
5 | super('https://monoschinos2.com/', 2, ['es']);
6 | }
7 |
8 | // eslint-disable-next-line class-methods-use-this
9 | get icon() {
10 | return 'icons/monoschinos.ico';
11 | }
12 |
13 | episodeUrl({ title, episode }) {
14 | return `${this.url}ver/${Provider.encode(title)}-${episode}`;
15 | }
16 | }
17 |
18 | export default new MonosChinos();
19 |
--------------------------------------------------------------------------------
/src/model/providers/MyAnimeList.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 |
3 | class MyAnimeList extends Provider {
4 | constructor() {
5 | super('https://myanimelist.net/');
6 | }
7 |
8 | episodeUrl({ anime: { id }, episode }) {
9 | return `${this.url}anime/${id}/-/episode/${episode}`;
10 | }
11 | }
12 |
13 | export default new MyAnimeList();
14 |
--------------------------------------------------------------------------------
/src/model/providers/Netflix.js:
--------------------------------------------------------------------------------
1 | import { openURL } from 'quasar';
2 | import MALSync from '@/api/MALSync';
3 | import Provider from './Provider.js';
4 |
5 | class Netflix extends Provider {
6 | constructor(search) {
7 | super('https://www.netflix.com/');
8 |
9 | this.delegate(search);
10 | }
11 |
12 | episodeUrl({ title }) {
13 | return `${this.url}search?q=${encodeURI(title)}`;
14 | }
15 | }
16 |
17 | export default new Netflix({
18 | async open(args) {
19 | const { provider, anime, title } = args;
20 | const animeSite = await MALSync.getAnimeSite('Netflix', anime.id, title);
21 |
22 | openURL(animeSite && animeSite.url ? animeSite.url : provider.episodeUrl(args));
23 | },
24 | });
25 |
--------------------------------------------------------------------------------
/src/model/providers/NineAnime.js:
--------------------------------------------------------------------------------
1 | import { openURL } from 'quasar';
2 | import MALSync from '@/api/MALSync';
3 | import Provider from './Provider.js';
4 | import { withSearchResolve } from './FeelingLucky';
5 |
6 | class NineAnime extends Provider {
7 | constructor(search) {
8 | super('https://9anime.to/', 2, ['en']);
9 |
10 | this.delegate(search);
11 | }
12 |
13 | // eslint-disable-next-line class-methods-use-this
14 | get icon() {
15 | return `${this.url}assets/sites/9anime/icons/favicon.png`;
16 | }
17 | }
18 |
19 | function toEpisodeUrl(animeUrl, { anime, episode }) {
20 | const ep = anime.type === 'movie' ? 'full' : episode;
21 | return `${animeUrl}/ep-${ep}`;
22 | }
23 |
24 | // https://9anime.to/watch/one-piece.ov8/ep-1006
25 | // https://9anime.to/watch/one-piece-film-gold.71vy/ep-full
26 | const NineAnimeLucky = withSearchResolve(({ provider, anime, title }) => {
27 | return encodeURIComponent(`site:${provider.url}watch/${Provider.encode(anime.alternativeTitles.en || title)}`);
28 | }, toEpisodeUrl);
29 |
30 | export const NineAnimeSync = new NineAnime({
31 | async open(args) {
32 | const { anime, title } = args;
33 | const animeSite = await MALSync.getAnimeSite('9anime', anime.id, title);
34 | if (animeSite) {
35 | openURL(toEpisodeUrl(animeSite.url, args));
36 | } else {
37 | await NineAnimeLucky.open(args);
38 | }
39 | },
40 | });
41 |
42 | // SEARCH NOT WORKING (403): vrf verification token required
43 | /*
44 | export const NineAnimeSearch = new NineAnime({
45 | episodeUrl({ provider, anime, title }) {
46 | return `${provider.url}search?keyword=${encodeURIComponent(anime.alternativeTitles.en || title)}`;
47 | },
48 | });
49 | */
50 |
--------------------------------------------------------------------------------
/src/model/providers/Provider.js:
--------------------------------------------------------------------------------
1 | export default class Provider {
2 | constructor(url, offset = 0, languages) {
3 | this.url = url;
4 | this.offset = offset;
5 | this.languages = languages;
6 | }
7 |
8 | get icon() {
9 | return `${this.url}favicon.ico`;
10 | }
11 |
12 | /* eslint-disable no-unused-vars */
13 |
14 | episodeUrl({ anime, title, episode }) {
15 | return this.url;
16 | }
17 |
18 | /*
19 | async open({ anime, title, episode }) {
20 | // Optional. If defined, this will override episodeUrl
21 | }
22 | */
23 |
24 | delegate(auxProvider, methods = ['episodeUrl', 'open']) {
25 | if (!auxProvider) {
26 | return;
27 | }
28 |
29 | const withProvider = (args) => {
30 | args.provider = this;
31 | return args;
32 | };
33 |
34 | methods.forEach((method) => {
35 | if (typeof auxProvider[method] === 'function') {
36 | this[method] = (args) => auxProvider[method](withProvider(args));
37 | }
38 | });
39 | }
40 |
41 | static encode(s, sep = '-') {
42 | let encoded = encodeURIComponent(
43 | s
44 | .toLowerCase()
45 | .replace(/[^- a-z0-9]+/g, '')
46 | .replace(/\s+/g, sep)
47 | .replace(new RegExp(`(${sep}){2,}`, 'g'), sep)
48 | );
49 | if (encoded[encoded.length - 1] === sep) {
50 | encoded = encoded.substring(0, encoded.length - 1);
51 | }
52 | return encoded;
53 | }
54 |
55 | static trimSpecials(s) {
56 | return s.replace(/[^- a-zA-Z0-9]+$/, '');
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/model/providers/YouTube.js:
--------------------------------------------------------------------------------
1 | import { FeelingLucky } from '@/model/providers/FeelingLucky';
2 |
3 | class YouTube extends FeelingLucky {
4 | constructor(search, languages) {
5 | super('https://www.youtube.com/', 'results?search_query=', search, languages);
6 | }
7 |
8 | // eslint-disable-next-line class-methods-use-this
9 | get icon() {
10 | return 'icons/youtube.png';
11 | }
12 | }
13 |
14 | function luckySpanish({ anime, title, episode }) {
15 | return encodeURIComponent(
16 | `${title}${anime.hasManyEpisodes || episode > 1 ? ` episodio intitle:${episode}` : ''} español`
17 | );
18 | }
19 |
20 | function luckyEnglish({ anime, title, episode }) {
21 | return encodeURIComponent(
22 | `${title}${anime.hasManyEpisodes || episode > 1 ? ` episode intitle:${episode}` : ''} english`
23 | );
24 | }
25 |
26 | export const YouTubeES = new YouTube(luckySpanish, ['es']);
27 | export const YouTubeEN = new YouTube(luckyEnglish, ['en']);
28 |
--------------------------------------------------------------------------------
/src/model/providers/YugenAnime.js:
--------------------------------------------------------------------------------
1 | import { openURL } from 'quasar';
2 | import MALSync from '@/api/MALSync';
3 | import Provider from './Provider.js';
4 | import { withSearchResolve } from './FeelingLucky';
5 |
6 | class YugenAnime extends Provider {
7 | constructor(search) {
8 | super('https://yugen.to/', 2, ['en']);
9 |
10 | this.delegate(search);
11 | }
12 |
13 | get icon() {
14 | return `${this.url}static/img/favicon-32x32.png`;
15 | }
16 | }
17 |
18 | // https://yugen.to/watch/798/one-piece/1049/
19 | const YugenAnimeLucky = withSearchResolve(({ provider, title, episode }) => {
20 | return encodeURIComponent(`site:${provider.url} "${title}" Episode "${episode}"`);
21 | });
22 |
23 | export default new YugenAnime({
24 | async open(args) {
25 | const { anime, episode, title } = args;
26 | const animeSite = await MALSync.getAnimeSite('YugenAnime', anime.id, title);
27 |
28 | if (animeSite && animeSite.url) {
29 | openURL(`${animeSite.url.replace('anime', 'watch')}${episode}`);
30 | } else {
31 | await YugenAnimeLucky.open(args);
32 | }
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/src/model/providers/Zoro.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-classes-per-file */
2 | import MALSync from '@/api/MALSync';
3 | import { newAxios } from '@/api/API.js';
4 | import { openURL } from 'quasar';
5 | import { withSearchResolve } from './FeelingLucky';
6 | import Provider from './Provider.js';
7 |
8 | class Zoro extends Provider {
9 | constructor(search) {
10 | super('https://zoro.to/', 2, ['en']);
11 |
12 | this.delegate(search);
13 | }
14 | }
15 |
16 | const ENCODED_TITLE_HREF_REGEX = /\/?([- a-z0-9]*-([0-9]+))\?ref=search/;
17 |
18 | // https://zoro.to/watch/one-piece-100?ep=86062
19 |
20 | class ZoroAPI extends Zoro {
21 | constructor() {
22 | super();
23 |
24 | this.axios = newAxios({
25 | baseUrl: this.url,
26 | cors: true,
27 | });
28 |
29 | this.api = newAxios({
30 | baseUrl: `${this.url}ajax/v2/`,
31 | cors: true,
32 | });
33 | }
34 |
35 | async open({ anime, title, episode }) {
36 | const animeSite = await MALSync.getAnimeSite('Zoro', anime.id, title);
37 |
38 | let animeEntry;
39 |
40 | if (animeSite) {
41 | animeEntry = animeSite;
42 | animeEntry.url = `${Provider.encode(animeEntry.title)}-${animeEntry.id}`;
43 | } else {
44 | const search = await this.axios.get(`search?keyword=${anime.alternativeTitles.en || title}`);
45 | animeEntry = search.data && ZoroAPI.findAnimeEntry(search.data, anime.title);
46 | }
47 |
48 | let episodeResolveUrl = animeEntry && animeEntry.url && (await this.parseEpisodeUrl(animeEntry, episode));
49 |
50 | if (!episodeResolveUrl) {
51 | episodeResolveUrl = `${this.url}search?keyword=${encodeURIComponent(title)}+${episode}`;
52 | }
53 |
54 | openURL(episodeResolveUrl);
55 | }
56 |
57 | static findAnimeEntry(data, title) {
58 | const parser = new DOMParser().parseFromString(data, 'text/html');
59 |
60 | const animes = parser
61 | .querySelectorAll('#main-content .tab-content .flw-item > .film-detail > .film-name > a.dynamic-name')
62 | .values();
63 |
64 | let { done, value } = animes.next();
65 |
66 | if (done) {
67 | return null;
68 | }
69 |
70 | let matchEntry;
71 |
72 | do {
73 | const entryTitle = value.getAttribute('data-jname');
74 |
75 | if (entryTitle === title) {
76 | const hrefMatch = value.getAttribute('href').match(ENCODED_TITLE_HREF_REGEX);
77 |
78 | if (hrefMatch.length > 2) {
79 | matchEntry = {
80 | id: hrefMatch[2],
81 | url: hrefMatch[1],
82 | title: entryTitle,
83 | };
84 | done = true;
85 | }
86 | } else {
87 | ({ done, value } = animes.next());
88 | }
89 | } while (!done);
90 |
91 | return matchEntry;
92 | }
93 |
94 | async parseEpisodeUrl(animeEntry, episode) {
95 | const animeUrl = `${this.url}watch/${animeEntry.url}`;
96 |
97 | const episodeList = await this.api.get(`episode/list/${animeEntry.id}`);
98 |
99 | if (!episodeList.data || !episodeList.data.status || episode > episodeList.data.totalItems) {
100 | return animeUrl;
101 | }
102 |
103 | const html = episodeList.data && episodeList.data.html;
104 |
105 | const parser = new DOMParser().parseFromString(html, 'text/html');
106 |
107 | const episodes = parser.querySelectorAll('a.ssl-item.ep-item');
108 |
109 | const episodeEntry = episodes.item(episode - 1);
110 | const episodeId = episodeEntry !== null && episodeEntry.getAttribute('data-id');
111 |
112 | if (episodeId === false) {
113 | return animeUrl;
114 | }
115 |
116 | return `${animeUrl}?ep=${episodeId}`;
117 | }
118 | }
119 |
120 | export const ZoroSearch = new Zoro({
121 | episodeUrl({ provider, title, episode }) {
122 | return `${provider.url}search?keyword=${encodeURIComponent(title)}+${episode}`;
123 | },
124 | });
125 |
126 | export const ZoroLucky = new Zoro(
127 | withSearchResolve(({ provider, anime, title }) => {
128 | return encodeURIComponent(`site:${provider.url}watch ${Provider.encode(anime.alternativeTitles.en || title)}`);
129 | })
130 | );
131 |
132 | export const ZoroWithAPI = new ZoroAPI();
133 |
--------------------------------------------------------------------------------
/src/model/providers/jkAnime.js:
--------------------------------------------------------------------------------
1 | import Provider from './Provider.js';
2 |
3 | class jkAnime extends Provider {
4 | constructor() {
5 | super('https://jkanime.net/', 5, ['es']);
6 | }
7 |
8 | // eslint-disable-next-line class-methods-use-this
9 | get icon() {
10 | return 'https://cdn.jkanime.net/assets/images/favicon.ico';
11 | }
12 |
13 | episodeUrl({ title, episode }) {
14 | return `${this.url}${Provider.encode(title)}/${episode}/`;
15 | }
16 | }
17 |
18 | // eslint-disable-next-line new-cap
19 | export default new jkAnime();
20 |
--------------------------------------------------------------------------------
/src/pages/Error404.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ $t('notFound') }}
8 | (404)
9 |
10 |
11 |
12 |
13 |
14 |
19 |
--------------------------------------------------------------------------------
/src/pages/Index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
77 |
--------------------------------------------------------------------------------
/src/router/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import VueRouter from 'vue-router';
3 |
4 | import routes from './routes';
5 |
6 | Vue.use(VueRouter);
7 |
8 | /*
9 | * If not building with SSR mode, you can
10 | * directly export the Router instantiation;
11 | *
12 | * The function below can be async too; either use
13 | * async/await or return a Promise which resolves
14 | * with the Router instance.
15 | */
16 |
17 | export default function (/* { store, ssrContext } */) {
18 | const Router = new VueRouter({
19 | scrollBehavior: () => ({ x: 0, y: 0 }),
20 | routes,
21 |
22 | // Leave these as they are and change in quasar.conf.js instead!
23 | // quasar.conf.js -> build -> vueRouterMode
24 | // quasar.conf.js -> build -> publicPath
25 | mode: process.env.VUE_ROUTER_MODE,
26 | base: process.env.VUE_ROUTER_BASE,
27 | });
28 |
29 | return Router;
30 | }
31 |
--------------------------------------------------------------------------------
/src/router/routes.js:
--------------------------------------------------------------------------------
1 | const routes = [
2 | {
3 | path: '/',
4 | component: () => import('layouts/MainLayout.vue'),
5 | children: [{ name: 'home', path: '', component: () => import('pages/Index.vue') }],
6 | },
7 | ];
8 |
9 | // Always leave this as last one
10 | routes.push({
11 | path: '*',
12 | component: () => import('pages/Error404.vue')
13 | });
14 |
15 | export default routes;
16 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import Vuex from 'vuex';
3 |
4 | import store from './store';
5 |
6 | Vue.use(Vuex);
7 |
8 | export default function () {
9 | const Store = new Vuex.Store({
10 | modules: {
11 | store
12 | },
13 |
14 | // enable strict mode (adds overhead!) for dev mode only
15 | strict: process.env.DEV,
16 | });
17 |
18 | return Store;
19 | }
20 |
--------------------------------------------------------------------------------
/src/store/store-flag.d.ts:
--------------------------------------------------------------------------------
1 | // THIS FEATURE-FLAG FILE IS AUTOGENERATED,
2 | // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
3 | import "quasar/dist/types/feature-flag";
4 |
5 | declare module "quasar/dist/types/feature-flag" {
6 | interface QuasarFeatureFlags {
7 | store: true;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/store/store/actions.js:
--------------------------------------------------------------------------------
1 | import { AuthenticationNeededException, notifyError } from '@/utils/errors';
2 |
3 | function withLoading(commit, promise) {
4 | commit('loading');
5 | return promise
6 | .catch((e) => {
7 | if (e instanceof AuthenticationNeededException) {
8 | commit('setAuthNeeded', true);
9 | } else {
10 | notifyError(e);
11 | }
12 | })
13 | .finally(() => {
14 | commit('loaded');
15 | });
16 | }
17 |
18 | export default {
19 | async login({ commit, state }, { username, password }) {
20 | commit('setUsername', username);
21 | await withLoading(commit, state.api.auth(username, password));
22 | },
23 | fetchAnimes({ commit, state: { api, username, status } }, next = false) {
24 | return withLoading(
25 | commit,
26 | api.getAnimes(username, status, next).then((animes) => {
27 | commit(next ? 'addAnimes' : 'setAnimes', {
28 | status,
29 | animes,
30 | });
31 | commit('updateFetched');
32 | return animes;
33 | })
34 | );
35 | },
36 | async fetchMoreAnimes({ dispatch, state: { api, username, status } }) {
37 | if (api.hasNext(username, status)) {
38 | await dispatch('fetchAnimes', true);
39 | }
40 | return api.hasNext(username, status);
41 | },
42 | fetchCalendar({ commit, state: { backend } }) {
43 | return withLoading(
44 | commit,
45 | backend.getCalendar().then((calendar) => {
46 | commit('setCalendar', calendar);
47 | return calendar;
48 | })
49 | );
50 | },
51 | searchUser({ commit, dispatch, state }, username) {
52 | state.api.commit = commit;
53 | return withLoading(
54 | commit,
55 | new Promise((resolve) => {
56 | commit('setUserFetched', false);
57 | commit('resetAnimes');
58 | if (username !== state.username) {
59 | commit('logout');
60 | commit('setUsername', username);
61 | commit('setPicture', state.api.image);
62 | }
63 | dispatch('updatePicture');
64 | dispatch('fetchAnimes').finally(() => {
65 | if (!state.api.hasError) {
66 | commit('setUserFetched', true);
67 | }
68 | resolve();
69 | });
70 | dispatch('fetchCalendar');
71 | })
72 | );
73 | },
74 | updatePicture({ commit, state: { api, username } }) {
75 | return withLoading(
76 | commit,
77 | api.getUserPicture(username).then((picture) => {
78 | commit('setPicture', picture || api.image);
79 | })
80 | );
81 | },
82 | updateEpisode({ commit, state: { api } }, anime) {
83 | return withLoading(
84 | commit,
85 | api.updateEpisode(anime).then((response) => {
86 | if (response.ok) {
87 | commit('nextEpisode', anime);
88 | commit('setAnimeWatching', anime);
89 | }
90 | return response;
91 | })
92 | );
93 | },
94 | };
95 |
--------------------------------------------------------------------------------
/src/store/store/getters.js:
--------------------------------------------------------------------------------
1 | import { getField } from 'vuex-map-fields';
2 | import { isBlank } from '@/utils/strings';
3 | import { providers } from '@/mixins/configuration';
4 |
5 | export default {
6 | getField,
7 | animesFilterByStatus({ animes, status }) {
8 | return animes[status];
9 | },
10 | isFetched({ fetched }) {
11 | return fetched;
12 | },
13 | isLoading({ loading }) {
14 | return loading > 0;
15 | },
16 | hasUsername({ username }) {
17 | return !isBlank(username);
18 | },
19 | providerByAnimeTitle({ providersByAnimeTitle }, getters) {
20 | return (title) => providersByAnimeTitle[title] || getters.provider;
21 | },
22 | providers({ language }) {
23 | return Object.freeze(
24 | providers.filter((provider) => !provider.value.languages || provider.value.languages.includes(language))
25 | );
26 | },
27 | provider({ providersByLanguage, language }) {
28 | return providersByLanguage[language];
29 | },
30 | titleByAnimeId({ api, titlesByAnimeId }) {
31 | return (id) => titlesByAnimeId[api.name][id];
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/src/store/store/index.js:
--------------------------------------------------------------------------------
1 | import state from './state';
2 | import mutations from './mutations';
3 | import getters from './getters';
4 | import actions from './actions';
5 |
6 | export default {
7 | namespaced: true,
8 | state,
9 | getters,
10 | mutations,
11 | actions,
12 | };
13 |
--------------------------------------------------------------------------------
/src/store/store/mutations.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import { updateField } from 'vuex-map-fields';
3 | import { newState } from './state';
4 |
5 | function sorted({ status, animes }) {
6 | if (status === 'plan-to-watch') {
7 | animes.sort((a, b) => {
8 | if (a.airingStatus === 'currently airing' && b.airingStatus !== 'currently airing') {
9 | return -1;
10 | }
11 | if (b.airingStatus === 'currently airing' && a.airingStatus !== 'currently airing') {
12 | return 1;
13 | }
14 | if (a.airingStatus === 'not yet aired' && b.airingStatus === 'not yet aired' && a.airingDate && b.airingDate) {
15 | return a.airingDate.diff(b.airingDate, 'minutes').toObject().minutes;
16 | }
17 | return b.updatedAt.diff(a.updatedAt, 'minutes').toObject().minutes;
18 | });
19 | }
20 | return animes;
21 | }
22 |
23 | export default {
24 | updateField,
25 | setAnimes(state, payload) {
26 | state.animes[payload.status] = sorted(payload);
27 | },
28 | addAnimes(state, payload) {
29 | state.animes[payload.status].push(...sorted(payload));
30 | },
31 | resetAnimes(state) {
32 | state.api.resetOffsets();
33 | state.fetched = false;
34 | state.animes = {
35 | watching: [],
36 | 'on-hold': [],
37 | 'plan-to-watch': [],
38 | };
39 | },
40 | updateFetched(state) {
41 | state.fetched = state.api.isFetched(state.username, state.status);
42 | },
43 | setAPI(state, api) {
44 | state.api = api;
45 | },
46 | setUsername(state, username) {
47 | state.username = username;
48 | },
49 | setPicture(state, picture) {
50 | state.picture = picture;
51 | },
52 | setUserFetched(state, userFetched) {
53 | state.userFetched = userFetched;
54 | },
55 | nextEpisode(_state, anime) {
56 | anime.lastWatchedEpisode = anime.nextEpisode;
57 | },
58 | setAnimeWatching(state, anime) {
59 | if (anime.status !== 'watching') {
60 | // Remove from old list
61 | const animeList = state.animes[anime.status];
62 | animeList.splice(animeList.indexOf(anime), 1);
63 | // Update status
64 | anime.status = 'watching';
65 | // Add to watching list (if not fetched it will be added later)
66 | if (state.api.isFetched(state.username, anime.status)) {
67 | state.animes.watching.unshift(anime);
68 | }
69 | }
70 | },
71 | setProvider(state, provider) {
72 | state.providersByLanguage[state.language] = provider;
73 | },
74 | setProviderByTitle(state, { title, provider }) {
75 | if (provider === state.providersByLanguage[state.language]) {
76 | Vue.delete(state.providersByAnimeTitle, title);
77 | } else {
78 | Vue.set(state.providersByAnimeTitle, title, provider);
79 | }
80 | },
81 | setAlternativeTitle(state, { anime, title }) {
82 | if (title === anime.title) {
83 | Vue.delete(state.titlesByAnimeId[state.api.name], anime.id);
84 | } else {
85 | Vue.set(state.titlesByAnimeId[state.api.name], anime.id, title);
86 | }
87 | },
88 | setCalendar(state, calendar) {
89 | state.calendar = calendar;
90 | },
91 | loading(state) {
92 | state.loading += 1;
93 | },
94 | loaded(state) {
95 | if (state.loading > 0) {
96 | state.loading -= 1;
97 | }
98 | },
99 | setAuthNeeded(state, needed) {
100 | state.authNeeded = needed;
101 | },
102 | clear(state) {
103 | Object.assign(state, newState());
104 | },
105 | setAuthInfo({ api }, { accessToken, refreshToken, expiration }) {
106 | // avoids mutation outside vuex store for token refresh
107 | api.accessToken = accessToken;
108 | api.refreshToken = refreshToken;
109 | api.expiration = expiration;
110 | api.updateAuthorization();
111 | api.saveAuthInfo();
112 | },
113 | logout({ api }) {
114 | api.logout();
115 | },
116 | };
117 |
--------------------------------------------------------------------------------
/src/store/store/state.js:
--------------------------------------------------------------------------------
1 | import Backend from '@/api/Backend';
2 | import { MyAnimeList } from '@/api/MyAnimeList';
3 | import { defaults } from '@/mixins/configuration';
4 |
5 | export function newState() {
6 | const backend = new Backend();
7 | const defaultAPI = new MyAnimeList();
8 | return {
9 | backend,
10 | authNeeded: false,
11 | api: defaultAPI,
12 | picture: defaultAPI.image,
13 | language: defaults.language,
14 | username: defaults.username,
15 | status: defaults.status,
16 | providersByLanguage: defaults.providersByLanguage,
17 | providersByAnimeTitle: defaults.providersByAnimeTitle,
18 | titlesByAnimeId: defaults.titlesByAnimeId,
19 | airingStatusFilter: defaults.airingStatusFilter,
20 | typeFilter: defaults.typeFilter,
21 | genreFilter: defaults.genreFilter,
22 | loading: 0,
23 | fetched: false,
24 | userFetched: false,
25 | animes: {
26 | watching: [],
27 | 'on-hold': [],
28 | 'plan-to-watch': [],
29 | },
30 | calendar: {},
31 | };
32 | }
33 |
34 | export default newState;
35 |
--------------------------------------------------------------------------------
/src/utils/errors.js:
--------------------------------------------------------------------------------
1 | import { Notify } from 'quasar';
2 | import { i18n } from '@/boot/i18n';
3 |
4 | export function notifyError(message) {
5 | if (message instanceof Error) {
6 | console.error(message.message);
7 | message = undefined;
8 | }
9 | Notify.create({
10 | type: 'negative',
11 | timeout: 5000,
12 | message: message || i18n.t('error'),
13 | });
14 | }
15 |
16 | export class AuthenticationNeededException extends Error {
17 | constructor(message) {
18 | super(message || 'Unauthenticated');
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/strings.js:
--------------------------------------------------------------------------------
1 | export function trim(s) {
2 | if (s === undefined || s === null) {
3 | return '';
4 | }
5 | return s.replace(/^\s+|\s+$/g, '');
6 | }
7 |
8 | export function isBlank(s) {
9 | return trim(s).length === 0;
10 | }
11 |
12 | export function nl2br(s) {
13 | return s.trim().replace(/\r?\n/g, '
');
14 | }
15 |
16 | export function nl2(s, tag) {
17 | return s
18 | .trim()
19 | .split('\n')
20 | .map((line) => `<${tag}>${line}${tag}>`)
21 | .join('\n');
22 | }
23 |
24 | export function pad(s, n = 2, padding = '0') {
25 | s = String(s);
26 | while (s.length < n) {
27 | s = padding + s;
28 | }
29 | return s;
30 | }
31 |
--------------------------------------------------------------------------------
/src/utils/subscription.js:
--------------------------------------------------------------------------------
1 | export class SubscriptionQueue {
2 | constructor() {
3 | this.subscribers = [];
4 | }
5 |
6 | subscribe(f) {
7 | this.subscribers.push(f);
8 | }
9 |
10 | consume(...params) {
11 | this.subscribers.forEach((f) => f(...params));
12 | this.subscribers = [];
13 | }
14 | }
15 |
--------------------------------------------------------------------------------