commit 3eda5d1ee870c81110ffd7f1f1713bc97c00ea55 Author: John Shaver Date: Mon Aug 19 23:19:59 2024 -0700 initial commit diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5d1185c --- /dev/null +++ b/.eslintrc @@ -0,0 +1,40 @@ +{ + "extends": ["plugin:@typescript-eslint/recommended", "prettier"], + "parser": "@typescript-eslint/parser", + "parserOptions": {}, + "plugins": ["@typescript-eslint"], + "root": true, + "rules": { + "camelcase": "error", + "no-console": ["error", { "allow": ["warn", "error"]}], + "no-empty-function": "error", + "no-empty": "error", + "no-irregular-whitespace": "warn", + "no-unneeded-ternary": "warn", + "no-var": "warn", + "spaced-comment": "warn", + "no-lonely-if": "warn", + "space-before-blocks": ["warn", "always"], + "prefer-destructuring": [ + "warn", + { + "AssignmentExpression": { + "array": false, + "object": false + }, + "VariableDeclarator": { + "array": false, + "object": true + } + }, + { + "enforceForRenamedProperties": false + } + ], + "prefer-template": "warn", + "prefer-spread": "warn", + "prefer-arrow-callback": "warn", + "arrow-spacing": "warn", + "arrow-parens": ["warn", "as-needed"] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ce39093 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "tabWidth": 2, + "printWidth": 90, + "singleQuote": true, + "bracketSpacing": true, + "semi": true, + "arrowParens": "avoid" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..29c098f --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# jellyfin-to-m3u + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.24. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..e80aca4 Binary files /dev/null and b/bun.lockb differ diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..d45165c --- /dev/null +++ b/index.ts @@ -0,0 +1,78 @@ +import { Command } from 'commander'; +import { checkbox } from '@inquirer/prompts'; +import { getAllPlaylists, getSonglistForPlaylist } from './lib/services/jellyfin.ts'; +import type { Playlist, SongMeta } from './lib/services/jellyfin.ts'; +import { downloadSongs } from './lib/fileDownloader.ts'; +import { writeM3UFile } from './lib/m3uWriter.ts'; +import 'dotenv/config'; + +const program = new Command(); + +const { JF_SERVER, JF_USERNAME, JF_PASSWORD, DEST_FOLDER, PLAYLISTS } = process.env; + +program + .name('') + .description('CLI to download Jellyfin Playlists to mp3 player m3u format.') + .version('0.0.1'); + +program + .command('wizard') + .description('Start the CLI wizzard to select playlists and start the downloads') + .action(async (str, options) => { + console.log('Fetching playlist information...'); + if (!JF_SERVER) { + console.error('Missing required env var JF_SERVER. Exiting...'); + process.exit(); + } + if (!JF_USERNAME) { + console.error('Missing required env var JF_USERNAME. Exiting...'); + process.exit(); + } + if (!JF_PASSWORD) { + console.error('Missing required env var JF_PASSWORD. Exiting...'); + process.exit(); + } + if (!DEST_FOLDER) { + console.error('Missing required env var DEST_FOLDER. Exiting...'); + process.exit(); + } + const creds = { + server: JF_SERVER, + user: JF_USERNAME, + password: JF_PASSWORD, + }; + const playlistMetas = await getAllPlaylists(creds); + const answer = await checkbox({ + message: 'Which playlists would you like to download?', + choices: playlistMetas.map((pl: Playlist) => ({ name: pl.Name, value: pl.Id })), + }); + const playlists = await Promise.all( + answer.map(async id => { + const pl = { + ...playlistMetas.find(p => p.Id === id), + }; + pl.songlist = await getSonglistForPlaylist(creds, id); + return pl; + }), + ); + + const allSongs = playlists.reduce( + (songs, pl) => { + console.log('PLAYLIST: ', pl); + pl.songlist?.forEach(song => { + songs[song.Id] = song; + }); + return songs; + }, + {} as Record, + ); + await downloadSongs(creds, Object.values(allSongs), DEST_FOLDER); + + await Promise.all( + playlists.map((pl, i) => + writeM3UFile(DEST_FOLDER, pl.Name || `playlist${i}`, pl.songlist as SongMeta[]), + ), + ); + }); + +await program.parseAsync(process.argv); diff --git a/lib/fileDownloader.ts b/lib/fileDownloader.ts new file mode 100644 index 0000000..a5da27f --- /dev/null +++ b/lib/fileDownloader.ts @@ -0,0 +1,53 @@ +import fs from 'fs'; +import nodePath from 'node:path'; +import { finished } from 'stream/promises'; + +import type { SongMeta, ServerCreds } from './services/jellyfin.ts'; +import { getSongFileBuffer } from './services/jellyfin.ts'; + +const QUEUE: (() => Promise)[] = []; +let downloadCount = 0; +const DOWNLOAD_LIMIT = 10; + +export const downloadSongs = async ( + creds: ServerCreds, + songs: SongMeta[], + path: string, +) => { + const promises = songs.map(s => getSongDownloader(creds, s, path)); + QUEUE.push(...promises); + processQueue(); + await Promise.all(promises); +}; + +const getSongDownloader = + (creds: ServerCreds, song: SongMeta, path: string) => async () => { + const extension = song.Path.split('.').pop(); + const filePath = nodePath.join(path, `${song.Id}.${extension}`); + try { + const write = fs.createWriteStream(filePath, { + flags: 'wx', + }); + console.log('Downloading: ', filePath); + const download = await getSongFileBuffer(creds, song.Id); + await finished(download.pipe(write)); + } catch (err: any) { + if (err?.code !== 'EEXIST') { + throw err; + } + console.log('File already exists, skipping: ', filePath); + } + }; + +const processQueue = () => { + if (downloadCount < DOWNLOAD_LIMIT && QUEUE.length) { + downloadCount++; + const next = QUEUE.shift(); + next && + next().then(() => { + downloadCount--; + processQueue(); + }); + processQueue(); + } +}; diff --git a/lib/m3uWriter.ts b/lib/m3uWriter.ts new file mode 100644 index 0000000..b39fbd4 --- /dev/null +++ b/lib/m3uWriter.ts @@ -0,0 +1,18 @@ +import { writeFile } from 'node:fs/promises'; +import nodePath from 'node:path'; +import m3u from 'm3u'; +import type { SongMeta } from './services/jellyfin.ts'; + +const createM3U = (name: string, songlist: SongMeta[]) => { + const m3uWriter = m3u.writer(); + for (const song of songlist) { + const extension = song.Path.split('.').pop(); + m3uWriter.file(`${song.Id}.${extension}`); + } + return m3uWriter.toString(); +}; + +export const writeM3UFile = async (path: string, name: string, songlist: SongMeta[]) => { + const contents = createM3U(name, songlist); + return writeFile(nodePath.join(path, `${name}.m3u`), contents, 'utf8'); +}; diff --git a/lib/services/jellyfin.ts b/lib/services/jellyfin.ts new file mode 100644 index 0000000..35fdd4c --- /dev/null +++ b/lib/services/jellyfin.ts @@ -0,0 +1,120 @@ +import { hostname, userInfo } from 'node:os'; +import { Readable } from 'stream'; + +interface AuthInfo { + token: string; + userId: string; + authHeaderValue: string; +} + +export interface ServerCreds { + server: string; + user: string; + password: string; +} + +interface View { + Id: string; + CollectionType: string; +} + +export interface Playlist { + Name: string; + Id: string; + MediaType: string; + songlist: SongMeta[]; +} + +export interface SongMeta { + Id: string; + Name: string; + Path: string; +} + +let tokenPromise: Promise | undefined = undefined; + +const getAuthHeaderValue = (token?: string) => { + let val = `MediaBrowser Client="JF-to-M3U",Device="CLI",DeviceId="${ + userInfo().username + }-${hostname()}",Version="0.0.1"`; + + if (token) { + val += `,Token="${token}"`; + } + return val; +}; + +const getAuthInfo = async ({ server, user, password }: ServerCreds) => { + if (!tokenPromise) { + const url = `${server}/Users/authenticatebyname`; + console.log('URL: ', url); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: getAuthHeaderValue(), + }, + body: JSON.stringify({ + Username: user, + Pw: password, + }), + }); + if (!response.ok) { + console.error('Error authenticating to server: ', await response.text()); + console.log('Exiting due unrecoverable error'); + process.exit(); + } + const authInfo = await response.json(); + const token = authInfo.AccessToken; + const userId = authInfo.User.Id; + const authHeaderValue = getAuthHeaderValue(token); + tokenPromise = Promise.resolve({ token, authHeaderValue, userId }); + } + return tokenPromise as Promise; +}; + +export const getAllViews = async (creds: ServerCreds): Promise => { + const auth = await getAuthInfo(creds); + const url = `${creds.server}/UserViews?userId=${auth.userId}`; + + const response = await fetch(url, { headers: { Authorization: auth.authHeaderValue } }); + return (await response.json()).Items as View[]; +}; + +export const getItemsByParent = async (creds: ServerCreds, parentId: string) => { + const auth = await getAuthInfo(creds); + const url = `${creds.server}/Items?userId=${auth.userId}&parentId=${parentId}`; + + const response = await fetch(url, { headers: { Authorization: auth.authHeaderValue } }); + return (await response.json()).Items; +}; + +export const getAllPlaylists = async (creds: ServerCreds): Promise => { + const auth = await getAuthInfo(creds); + const views = await getAllViews(creds); + const musicViews = views.filter(c => c.CollectionType === 'music'); + const playlistViews = views.filter(c => c.CollectionType === 'playlists'); + const playlists: Playlist[] = ( + await Promise.all(playlistViews.map(c => getItemsByParent(creds, c.Id))) + ).flat(Infinity); + return playlists.filter(p => p.MediaType === 'Audio'); +}; + +export const getSonglistForPlaylist = async (creds: ServerCreds, id: string) => { + const auth = await getAuthInfo(creds); + const response = await fetch( + `${creds.server}/Playlists/${id}/Items?userId=${auth.userId}&fields=Path`, + { + headers: { Authorization: auth.authHeaderValue }, + }, + ); + return (await response.json()).Items as SongMeta[]; +}; + +export const getSongFileBuffer = async (creds: ServerCreds, id: string) => { + const auth = await getAuthInfo(creds); + const response = await fetch(`${creds.server}/Items/${id}/File?userId=${auth.userId}`, { + headers: { Authorization: auth.authHeaderValue }, + }); + return Readable.fromWeb(response.body); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..9fb43a4 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "jellyfin-to-m3u", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@inquirer/prompts": "^5.3.8", + "@types/node": "^22.4.1", + "cli-progress": "^3.12.0", + "commander": "^12.1.0", + "dotenv": "^16.4.5", + "eslint": "^9.9.0", + "m3u": "^0.0.2", + "prettier": "^3.3.3" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}